Compare commits

..

13 Commits

Author SHA1 Message Date
CallMeVerity 87e408f1a7 Re-add static file serving catch-all route 2026-06-04 22:47:25 +01:00
CallMeVerity 32e13fcc85 Change /video/ route to /run/, add embed support 2026-06-04 22:42:39 +01:00
CallMeVerity f0f50a6f12 Retry S3 GetObject on first-request failures, return 502 for stream errors 2026-06-03 06:55:57 +01:00
CallMeVerity c0b93a2a6e Remove server-side transcoding 2026-06-03 04:56:31 +01:00
CallMeVerity ff2c3997a0 Auto-transcode on upload, show transcode status to user 2026-06-03 04:38:51 +01:00
CallMeVerity 8121369a58 Stream download for transcode, multipart upload for large output 2026-06-03 04:34:26 +01:00
CallMeVerity cc3904705c Add video transcoding to H264/MP4, use high quality CRF 18 2026-06-03 04:32:01 +01:00
CallMeVerity 17ea6ce7c4 Expose Content-Range, Accept-Ranges, Content-Length in CORS 2026-06-03 04:24:09 +01:00
CallMeVerity 1704b0b736 Fix video streaming: parse total size from S3 Content-Range header 2026-06-03 04:18:21 +01:00
CallMeVerity 6da2539c03 Stream video through backend with Range request support
Previously videos were served via direct RustFS URLs, which meant:
- No HTTP Range support (browsers had to download the entire file)
- Large videos couldn't play at all
- Video player rendered broken

Now videos stream through GET /api/videos/:id/stream which:
- Proxies video data from RustFS to the browser
- Supports Range requests (HTTP 206 Partial Content) for seeking
- Sets proper headers (Accept-Ranges, Content-Range, Content-Type)
- Caches for 24 hours

Frontend changes:
- VideoPlayer: added playsInline, preload=metadata, object-contain, error state
- VideoDetail: removed duplicate inline video, now uses VideoPlayer component
- index.css: style WebKit video controls (dark panel, no border-radius)
2026-06-03 03:56:39 +01:00
CallMeVerity dc021e4856 S3 multipart upload for large videos, EISDIR fix 2026-06-03 03:30:54 +01:00
CallMeVerity ba9752d33c Fix directory EISDIR on root path, remove favicon 2026-06-03 01:43:24 +01:00
CallMeVerity 3369f22f69 Initial commit 2026-06-03 00:44:48 +01:00
6 changed files with 8 additions and 27 deletions
+3 -3
View File
@@ -29,7 +29,7 @@
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
@@ -71,7 +71,7 @@
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1058.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q=="], "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1058.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="], "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="], "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
@@ -99,7 +99,7 @@
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="], "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="],
+1 -13
View File
@@ -24,7 +24,7 @@ function mapDisplayName(mapName: string): string {
} }
function pageTitle(video: VideoEntry): string { function pageTitle(video: VideoEntry): string {
return `${mapDisplayName(video.mapName)} ${video.playerName} · surf.nathan.rip`; return `${mapDisplayName(video.mapName)} - ${video.playerName} · surf.nathan.rip`;
} }
function ogTitle(video: VideoEntry): string { function ogTitle(video: VideoEntry): string {
@@ -49,7 +49,6 @@ function buildOgsHtml(video: VideoEntry): string {
const videoStreamUrl = `${BASE_URL}${getStreamUrl(video.id)}`; const videoStreamUrl = `${BASE_URL}${getStreamUrl(video.id)}`;
let tags = let tags =
// oEmbed discovery — lets Discord (and others) find the JSON endpoint
` <link rel="alternate" type="application/json+oembed" href="${escapeHtml(oembedUrl)}" title="${escapeHtml(title)}" />\n` + ` <link rel="alternate" type="application/json+oembed" href="${escapeHtml(oembedUrl)}" title="${escapeHtml(title)}" />\n` +
` <meta property="og:title" content="${escapeHtml(title)}" />\n` + ` <meta property="og:title" content="${escapeHtml(title)}" />\n` +
` <meta property="og:description" content="${escapeHtml(description)}" />\n` + ` <meta property="og:description" content="${escapeHtml(description)}" />\n` +
@@ -64,14 +63,12 @@ function buildOgsHtml(video: VideoEntry): string {
` <meta property="og:image:height" content="720" />`; ` <meta property="og:image:height" content="720" />`;
} }
// Video embed — Discord & Facebook
tags += tags +=
`\n <meta property="og:video" content="${videoStreamUrl}" />\n` + `\n <meta property="og:video" content="${videoStreamUrl}" />\n` +
` <meta property="og:video:type" content="video/mp4" />\n` + ` <meta property="og:video:type" content="video/mp4" />\n` +
` <meta property="og:video:width" content="1280" />\n` + ` <meta property="og:video:width" content="1280" />\n` +
` <meta property="og:video:height" content="720" />`; ` <meta property="og:video:height" content="720" />`;
// Twitter / X
tags += tags +=
`\n <meta name="twitter:card" content="summary_large_image" />\n` + `\n <meta name="twitter:card" content="summary_large_image" />\n` +
` <meta name="twitter:title" content="${escapeHtml(title)}" />\n` + ` <meta name="twitter:title" content="${escapeHtml(title)}" />\n` +
@@ -81,8 +78,6 @@ function buildOgsHtml(video: VideoEntry): string {
tags += `\n <meta name="twitter:image" content="${thumbnailUrl}" />`; tags += `\n <meta name="twitter:image" content="${thumbnailUrl}" />`;
} }
// Steam uses the same og: tags, nothing extra needed
return tags; return tags;
} }
@@ -118,12 +113,6 @@ export function getOembed(id: string): OembedResponse | null {
}; };
} }
/**
* For a given run ID, return the SPA's index.html with OG meta tags
* injected into <head> and the <title> updated. If the run doesn't exist,
* returns the plain SPA html (React Router will show a 404). If the
* static index.html isn't available, returns null.
*/
export function renderRunPage( export function renderRunPage(
id: string, id: string,
staticDir: string, staticDir: string,
@@ -139,7 +128,6 @@ export function renderRunPage(
const video = getVideoById(id); const video = getVideoById(id);
if (!video) { if (!video) {
// Serve the plain SPA — React Router handles the 404
return { html: indexHtml, contentType: "text/html" }; return { html: indexHtml, contentType: "text/html" };
} }
-1
View File
@@ -391,7 +391,6 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
":", ":",
msg, msg,
); );
// S3 errors are server errors, not "not found"
const statusCode = msg.includes("NoSuchKey") ? 404 : 502; const statusCode = msg.includes("NoSuchKey") ? 404 : 502;
return status(statusCode, { return status(statusCode, {
error: error:
-6
View File
@@ -168,14 +168,12 @@ export async function getVideoStream(
): Promise<VideoStreamResult> { ): Promise<VideoStreamResult> {
const s3 = getRustFsClient(); const s3 = getRustFsClient();
// Build the GetObject command, optionally with a Range
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: RUSTFS_BUCKET, Bucket: RUSTFS_BUCKET,
Key: key, Key: key,
...(rangeHeader ? { Range: rangeHeader } : {}), ...(rangeHeader ? { Range: rangeHeader } : {}),
}); });
// Retry once on transient failures (cold S3 connections can fail the first request)
let result; let result;
try { try {
result = await s3.send(command); result = await s3.send(command);
@@ -200,14 +198,11 @@ export async function getVideoStream(
const contentType = result.ContentType || "video/webm"; const contentType = result.ContentType || "video/webm";
// Convert SDK stream to web ReadableStream
const sdkStream = result.Body as any; const sdkStream = result.Body as any;
const webStream: ReadableStream<Uint8Array> = sdkStream.transformToWebStream const webStream: ReadableStream<Uint8Array> = sdkStream.transformToWebStream
? sdkStream.transformToWebStream() ? sdkStream.transformToWebStream()
: sdkStream; : sdkStream;
// If we sent a range request, parse the Content-Range header from S3
// Format: "bytes start-end/total"
if (rangeHeader && result.ContentRange) { if (rangeHeader && result.ContentRange) {
const match = result.ContentRange.match(/bytes (\d+)-(\d+)\/(\d+)/); const match = result.ContentRange.match(/bytes (\d+)-(\d+)\/(\d+)/);
if (match) { if (match) {
@@ -223,7 +218,6 @@ export async function getVideoStream(
} }
} }
// No range request (or no Content-Range in response) — full object
return { return {
stream: webStream, stream: webStream,
contentType, contentType,
+3 -3
View File
@@ -57,7 +57,7 @@
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
@@ -83,9 +83,9 @@
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+1 -1
View File
@@ -68,7 +68,7 @@ export default function VideoDetail() {
fetchVideo(id) fetchVideo(id)
.then((v) => { .then((v) => {
setVideo(v); setVideo(v);
document.title = `${getMapDisplayName(v.mapName)} ${v.playerName} · surf.nathan.rip`; document.title = `${getMapDisplayName(v.mapName)} - ${v.playerName} · surf.nathan.rip`;
}) })
.catch((err) => setError(err.message)); .catch((err) => setError(err.message));
return () => { return () => {