diff --git a/backend/src/routes/videos.ts b/backend/src/routes/videos.ts index 9f1425b..b60abae 100644 --- a/backend/src/routes/videos.ts +++ b/backend/src/routes/videos.ts @@ -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) diff --git a/backend/src/services/rustfs.ts b/backend/src/services/rustfs.ts index e1ff736..b7d6542 100644 --- a/backend/src/services/rustfs.ts +++ b/backend/src/services/rustfs.ts @@ -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; + contentType: string; + contentLength: number; + range?: { + start: number; + end: number; + }; +} + +export async function getVideoStream( + key: string, + rangeHeader?: string | null, +): Promise { + 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 = sdkStream.transformToWebStream + ? sdkStream.transformToWebStream() + : sdkStream; + + return { + stream: webStream, + contentType, + contentLength: totalSize, + range, + }; +} + +export function getStreamUrl(videoId: string): string { + return `/api/videos/${videoId}/stream`; +} diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index 4c528b1..4dd3c7a 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -4,20 +4,22 @@ import { formatRunTime } from "../api/client"; export default function VideoPlayer({ video }: { video: VideoDetail }) { const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(false); const videoRef = useRef(null); return (
- {!loaded && ( + {} + {!loaded && !error && (
- {video.thumbnailUrl ? ( + {video.thumbnailUrl && ( - ) : null} + )}
@@ -26,13 +28,48 @@ export default function VideoPlayer({ video }: { video: VideoDetail }) {
)} + + {} + {error && ( +
+ {video.thumbnailUrl && ( + + )} +
+ + + + + Failed to load video + +
+
+ )} + diff --git a/frontend/src/index.css b/frontend/src/index.css index 24c2ba3..3f3cf05 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -24,3 +24,15 @@ a { color: #1896d3; 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); +} diff --git a/frontend/src/pages/VideoDetail.tsx b/frontend/src/pages/VideoDetail.tsx index bf6a24c..8aea319 100644 --- a/frontend/src/pages/VideoDetail.tsx +++ b/frontend/src/pages/VideoDetail.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; +import VideoPlayer from "../components/VideoPlayer"; import VideoSidebar from "../components/VideoSidebar"; import { fetchVideo, formatRunTime } from "../api/client"; import type { VideoDetail } from "../types/video"; @@ -60,9 +61,7 @@ function PageSkeleton() { export default function VideoDetail() { const { id } = useParams<{ id: string }>(); const [video, setVideo] = useState(null); - const [videoReady, setVideoReady] = useState(false); const [error, setError] = useState(null); - const videoRef = useRef(null); useEffect(() => { if (!id) return; @@ -79,22 +78,8 @@ export default function VideoDetail() { ); } - if (!video || !videoReady) { - return ( - <> - - {video && ( -