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 dc021e4856
commit 6da2539c03
5 changed files with 181 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)
+77
View File
@@ -6,6 +6,8 @@ import {
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
GetObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -150,3 +152,78 @@ export async function abortMultipartUpload(
}),
);
}
export interface VideoStreamResult {
stream: ReadableStream<Uint8Array>;
contentType: string;
contentLength: number;
range?: {
start: number;
end: number;
};
}
export async function getVideoStream(
key: string,
rangeHeader?: string | null,
): Promise<VideoStreamResult> {
const s3 = getRustFsClient();
const headResult = await s3.send(
new HeadObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
}),
);
const totalSize = headResult.ContentLength ?? 0;
const contentType = headResult.ContentType || "video/webm";
let range: { start: number; end: number } | undefined;
let command: GetObjectCommand;
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match) {
const start = match[1] ? parseInt(match[1], 10) : 0;
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
range = { start, end: Math.min(end, totalSize - 1) };
command = new GetObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
Range: `bytes=${range.start}-${range.end}`,
});
} else {
command = new GetObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
});
}
} else {
command = new GetObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
});
}
const result = await s3.send(command);
if (!result.Body) {
throw new Error("Empty response body from RustFS");
}
const sdkStream = result.Body as any;
const webStream: ReadableStream<Uint8Array> = sdkStream.transformToWebStream
? sdkStream.transformToWebStream()
: sdkStream;
return {
stream: webStream,
contentType,
contentLength: totalSize,
range,
};
}
export function getStreamUrl(videoId: string): string {
return `/api/videos/${videoId}/stream`;
}