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, uploadObject,
deleteObject, deleteObject,
getObjectUrl, getObjectUrl,
getStreamUrl,
createMultipartUpload, createMultipartUpload,
getPresignedUploadPartUrl, getPresignedUploadPartUrl,
completeMultipartUpload, completeMultipartUpload,
abortMultipartUpload, abortMultipartUpload,
getVideoStream,
} from "../services/rustfs"; } from "../services/rustfs";
import { parseMtvFile } from "../services/mtv-parser"; import { parseMtvFile } from "../services/mtv-parser";
import { getMapInfo } from "../services/momentum-api"; import { getMapInfo } from "../services/momentum-api";
@@ -212,7 +214,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
parts, parts,
uploadId, uploadId,
}, },
videoUrl: getObjectUrl(videoKey), videoUrl: getStreamUrl(id),
mtvUrl: getObjectUrl(mtvKey), mtvUrl: getObjectUrl(mtvKey),
thumbnailUrl: thumbnailKey thumbnailUrl: thumbnailKey
? getObjectUrl(thumbnailKey) ? getObjectUrl(thumbnailKey)
@@ -304,7 +306,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
parts, parts,
uploadId, uploadId,
}, },
videoUrl: getObjectUrl(videoKey), videoUrl: getStreamUrl(id),
mtvUrl: getObjectUrl(mtvKey), mtvUrl: getObjectUrl(mtvKey),
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined, thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
}; };
@@ -333,7 +335,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
return { return {
...video, ...video,
videoUrl: getObjectUrl(video.videoKey), videoUrl: getStreamUrl(video.id),
mtvUrl: getObjectUrl(video.mtvKey), mtvUrl: getObjectUrl(video.mtvKey),
thumbnailUrl: video.thumbnailKey thumbnailUrl: video.thumbnailKey
? getObjectUrl(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 } }) => { .get("/:id", ({ params: { id } }) => {
const video = getVideoById(id); const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" }); if (!video) return status(404, { error: "Not found" });
return { return {
...video, ...video,
videoUrl: getObjectUrl(video.videoKey), videoUrl: getStreamUrl(video.id),
mtvUrl: getObjectUrl(video.mtvKey), mtvUrl: getObjectUrl(video.mtvKey),
thumbnailUrl: video.thumbnailKey thumbnailUrl: video.thumbnailKey
? getObjectUrl(video.thumbnailKey) ? getObjectUrl(video.thumbnailKey)
+77
View File
@@ -6,6 +6,8 @@ import {
UploadPartCommand, UploadPartCommand,
CompleteMultipartUploadCommand, CompleteMultipartUploadCommand,
AbortMultipartUploadCommand, AbortMultipartUploadCommand,
GetObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; 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`;
}
+41 -4
View File
@@ -4,20 +4,22 @@ import { formatRunTime } from "../api/client";
export default function VideoPlayer({ video }: { video: VideoDetail }) { export default function VideoPlayer({ video }: { video: VideoDetail }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
return ( return (
<div className="rounded-lg border border-white/5 overflow-hidden"> <div className="rounded-lg border border-white/5 overflow-hidden">
<div className="aspect-video bg-black relative"> <div className="aspect-video bg-black relative">
{!loaded && ( {}
{!loaded && !error && (
<div className="absolute inset-0 flex items-center justify-center z-10"> <div className="absolute inset-0 flex items-center justify-center z-10">
{video.thumbnailUrl ? ( {video.thumbnailUrl && (
<img <img
src={video.thumbnailUrl} src={video.thumbnailUrl}
alt="" alt=""
className="absolute inset-0 w-full h-full object-cover blur-sm opacity-50" className="absolute inset-0 w-full h-full object-cover blur-sm opacity-50"
/> />
) : null} )}
<div className="relative z-20 flex flex-col items-center gap-2"> <div className="relative z-20 flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" /> <div className="w-10 h-10 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" />
<span className="text-xs text-white/40"> <span className="text-xs text-white/40">
@@ -26,13 +28,48 @@ export default function VideoPlayer({ video }: { video: VideoDetail }) {
</div> </div>
</div> </div>
)} )}
{}
{error && (
<div className="absolute inset-0 flex items-center justify-center z-10">
{video.thumbnailUrl && (
<img
src={video.thumbnailUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-30"
/>
)}
<div className="relative z-20 flex flex-col items-center gap-2">
<svg
className="w-12 h-12 text-red-400/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-xs text-white/40">
Failed to load video
</span>
</div>
</div>
)}
<video <video
ref={videoRef} ref={videoRef}
src={video.videoUrl} src={video.videoUrl}
controls controls
className="w-full h-full" playsInline
preload="metadata"
className="w-full h-full object-contain"
poster={video.thumbnailUrl} poster={video.thumbnailUrl}
onCanPlay={() => setLoaded(true)} onCanPlay={() => setLoaded(true)}
onError={() => setError(true)}
> >
Your browser does not support video playback. Your browser does not support video playback.
</video> </video>
+12
View File
@@ -24,3 +24,15 @@ a {
color: #1896d3; color: #1896d3;
text-decoration: none; text-decoration: none;
} }
video::-webkit-media-controls {
opacity: 1;
}
video::-webkit-media-controls-enclosure {
border-radius: 0;
}
video::-webkit-media-controls-panel {
background: rgba(0, 0, 0, 0.75);
}
+5 -54
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import VideoPlayer from "../components/VideoPlayer";
import VideoSidebar from "../components/VideoSidebar"; import VideoSidebar from "../components/VideoSidebar";
import { fetchVideo, formatRunTime } from "../api/client"; import { fetchVideo, formatRunTime } from "../api/client";
import type { VideoDetail } from "../types/video"; import type { VideoDetail } from "../types/video";
@@ -60,9 +61,7 @@ function PageSkeleton() {
export default function VideoDetail() { export default function VideoDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [video, setVideo] = useState<VideoDetail | null>(null); const [video, setVideo] = useState<VideoDetail | null>(null);
const [videoReady, setVideoReady] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -79,22 +78,8 @@ export default function VideoDetail() {
); );
} }
if (!video || !videoReady) { if (!video) {
return ( return <PageSkeleton />;
<>
<PageSkeleton />
{video && (
<video
ref={videoRef}
src={video.videoUrl}
preload="auto"
onCanPlay={() => setVideoReady(true)}
className="fixed opacity-0 pointer-events-none"
aria-hidden
/>
)}
</>
);
} }
return ( return (
@@ -114,41 +99,7 @@ export default function VideoDetail() {
<div className="flex flex-col lg:flex-row gap-4 lg:items-start"> <div className="flex flex-col lg:flex-row gap-4 lg:items-start">
<div className="lg:w-1/2 min-w-0"> <div className="lg:w-1/2 min-w-0">
<div className="rounded-lg border border-white/5 overflow-hidden"> <VideoPlayer video={video} />
<div className="aspect-video bg-black">
<video
ref={videoRef}
src={video.videoUrl}
controls
className="w-full h-full"
poster={video.thumbnailUrl}
>
Your browser does not support video playback.
</video>
</div>
<div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3">
<a
href={video.mtvUrl}
download
className="inline-flex items-center gap-2 rounded-md bg-white/4 border border-white/7 px-3 py-1.5 text-xs font-medium text-white/50 hover:bg-white/8 hover:text-white/80 hover:border-white/15 transition-colors no-underline"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
.mtv replay
</a>
</div>
</div>
</div> </div>
<div className="lg:w-1/2"> <div className="lg:w-1/2">
<VideoSidebar video={video} /> <VideoSidebar video={video} />