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:
@@ -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)
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user