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)
This commit is contained in:
CallMeVerity
2026-06-03 03:56:39 +01:00
parent 1693b3849b
commit 2f62e68688
5 changed files with 184 additions and 62 deletions
+46 -4
View File
@@ -13,10 +13,12 @@ import {
uploadObject,
deleteObject,
getObjectUrl,
getStreamUrl,
createMultipartUpload,
getPresignedUploadPartUrl,
completeMultipartUpload,
abortMultipartUpload,
getVideoStream,
} from "../services/rustfs";
import { parseMtvFile } from "../services/mtv-parser";
import { getMapInfo } from "../services/momentum-api";
@@ -212,7 +214,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
parts,
uploadId,
},
videoUrl: getObjectUrl(videoKey),
videoUrl: getStreamUrl(id),
mtvUrl: getObjectUrl(mtvKey),
thumbnailUrl: thumbnailKey
? getObjectUrl(thumbnailKey)
@@ -304,7 +306,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
parts,
uploadId,
},
videoUrl: getObjectUrl(videoKey),
videoUrl: getStreamUrl(id),
mtvUrl: getObjectUrl(mtvKey),
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
};
@@ -333,7 +335,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
return {
...video,
videoUrl: getObjectUrl(video.videoKey),
videoUrl: getStreamUrl(video.id),
mtvUrl: getObjectUrl(video.mtvKey),
thumbnailUrl: video.thumbnailKey
? getObjectUrl(video.thumbnailKey)
@@ -341,12 +343,52 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
};
})
.get("/:id/stream", async ({ params: { id }, request, set }) => {
const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" });
const rangeHeader = request.headers.get("Range");
try {
const result = await getVideoStream(video.videoKey, rangeHeader);
const headers = new Headers();
headers.set("Content-Type", result.contentType);
headers.set("Accept-Ranges", "bytes");
headers.set("Cache-Control", "public, max-age=86400");
if (result.range) {
headers.set(
"Content-Range",
`bytes ${result.range.start}-${result.range.end}/${result.contentLength}`,
);
headers.set(
"Content-Length",
String(result.range.end - result.range.start + 1),
);
return new Response(result.stream, {
status: 206,
headers,
});
} else {
headers.set("Content-Length", String(result.contentLength));
return new Response(result.stream, {
status: 200,
headers,
});
}
} catch (err: any) {
console.error("Stream error:", err);
return status(404, { error: "Video not found" });
}
})
.get("/:id", ({ params: { id } }) => {
const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" });
return {
...video,
videoUrl: getObjectUrl(video.videoKey),
videoUrl: getStreamUrl(video.id),
mtvUrl: getObjectUrl(video.mtvKey),
thumbnailUrl: video.thumbnailKey
? getObjectUrl(video.thumbnailKey)