diff --git a/backend/src/routes/videos.ts b/backend/src/routes/videos.ts index 871ffbf..5d40a3a 100644 --- a/backend/src/routes/videos.ts +++ b/backend/src/routes/videos.ts @@ -9,6 +9,7 @@ import { getVideoByMapAndPlayer, updateVideoPB, updateTranscodedKey, + updateTranscodeStatus, } from "../services/db"; import { uploadObject, @@ -335,6 +336,25 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" }) await completeMultipartUpload(video.videoKey, uploadId, s3Parts); + if ( + !video.transcodedKey && + video.transcodeStatus !== "pending" && + video.transcodeStatus !== "processing" + ) { + updateTranscodeStatus(id, "pending"); + const videoKey = video.videoKey; + transcodeVideo(videoKey) + .then((transcodedKey) => { + updateTranscodedKey(id, transcodedKey); + updateTranscodeStatus(id, "done"); + console.log(`[transcode] Done: ${id} -> ${transcodedKey}`); + }) + .catch((err) => { + console.error(`[transcode] Failed: ${id}`, err); + updateTranscodeStatus(id, "failed"); + }); + } + return { ...video, videoUrl: getStreamUrl(video.id), @@ -349,6 +369,16 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" }) const video = getVideoById(id); if (!video) return status(404, { error: "Not found" }); + if ( + video.transcodeStatus === "processing" || + video.transcodeStatus === "pending" + ) { + return status(409, { + error: "Transcode already in progress", + transcodeStatus: video.transcodeStatus, + }); + } + if (video.transcodedKey) { return { ...video, @@ -360,18 +390,21 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" }) }; } - const transcodedKey = await transcodeVideo(video.videoKey); - updateTranscodedKey(id, transcodedKey); + updateTranscodeStatus(id, "pending"); - return { - ...video, - transcodedKey, - videoUrl: getStreamUrl(video.id), - mtvUrl: getObjectUrl(video.mtvKey), - thumbnailUrl: video.thumbnailKey - ? getObjectUrl(video.thumbnailKey) - : undefined, - }; + const videoKey = video.videoKey; + transcodeVideo(videoKey) + .then((transcodedKey) => { + updateTranscodedKey(id, transcodedKey); + updateTranscodeStatus(id, "done"); + console.log(`[transcode] Done: ${id} -> ${transcodedKey}`); + }) + .catch((err) => { + console.error(`[transcode] Failed: ${id}`, err); + updateTranscodeStatus(id, "failed"); + }); + + return { id, transcodeStatus: "pending" }; }) .get("/:id/stream", async ({ params: { id }, request, set }) => { diff --git a/backend/src/services/db.ts b/backend/src/services/db.ts index 0326715..1ceb314 100644 --- a/backend/src/services/db.ts +++ b/backend/src/services/db.ts @@ -60,6 +60,11 @@ export function initDb(): void { if (!columns.some((col) => col.name === "transcoded_key")) { db.exec("ALTER TABLE videos ADD COLUMN transcoded_key TEXT"); } + if (!columns.some((col) => col.name === "transcode_status")) { + db.exec( + "ALTER TABLE videos ADD COLUMN transcode_status TEXT DEFAULT NULL", + ); + } } function getDb(): Database { @@ -145,7 +150,6 @@ function rowToEntry(row: any): VideoEntry { videoKey: row.video_key, mtvKey: row.mtv_key, thumbnailKey: row.thumbnail_key ?? undefined, - transcodedKey: row.transcoded_key ?? undefined, tier: row.tier ?? undefined, mapId: row.map_id ?? undefined, jsonStats: row.json_stats ?? undefined, @@ -153,9 +157,22 @@ function rowToEntry(row: any): VideoEntry { previousPbs: row.previous_pbs ? JSON.parse(row.previous_pbs) : undefined, + transcodedKey: row.transcoded_key ?? undefined, + transcodeStatus: row.transcode_status ?? undefined, }; } +export function updateTranscodeStatus( + id: string, + status: "pending" | "processing" | "done" | "failed" | null, +): boolean { + const d = getDb(); + const result = d + .prepare("UPDATE videos SET transcode_status = ? WHERE id = ?") + .run(status, id); + return result.changes > 0; +} + export function updateThumbnailKey( id: string, thumbnailKey: string | null, diff --git a/backend/src/services/rustfs.ts b/backend/src/services/rustfs.ts index 614a246..82a225f 100644 --- a/backend/src/services/rustfs.ts +++ b/backend/src/services/rustfs.ts @@ -224,6 +224,7 @@ export async function transcodeVideo(originalKey: string): Promise { const outputPath = join(tmpdir(), `transcode-output-${Date.now()}.mp4`); try { + console.log(`[transcode] Downloading ${originalKey} from S3...`); const getCmd = new GetObjectCommand({ Bucket: RUSTFS_BUCKET, Key: originalKey, @@ -260,6 +261,7 @@ export async function transcodeVideo(originalKey: string): Promise { ); } + console.log(`[transcode] Running ffmpeg on ${inputPath}...`); const proc = Bun.spawn( [ "ffmpeg", @@ -298,6 +300,7 @@ export async function transcodeVideo(originalKey: string): Promise { ? originalKey.substring(0, lastDot) + ".mp4" : originalKey + ".mp4"; + console.log(`[transcode] Uploading ${transcodedKey} to S3...`); const outputFile = Bun.file(outputPath); const fileSize = outputFile.size; const CHUNK_SIZE = 80 * 1024 * 1024; diff --git a/backend/src/types/video.ts b/backend/src/types/video.ts index ae748d6..e85ac51 100644 --- a/backend/src/types/video.ts +++ b/backend/src/types/video.ts @@ -51,12 +51,13 @@ export interface VideoEntry { videoKey: string; mtvKey: string; thumbnailKey?: string; - transcodedKey?: string; tier?: number | null; mapId?: number | null; jsonStats?: string; createdAt: string; previousPbs?: PreviousPB[]; + transcodedKey?: string; + transcodeStatus?: "pending" | "processing" | "done" | "failed" | null; } export interface VideoListItem { diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index 9e36edb..c9dd398 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -1,45 +1,84 @@ import type { VideoDetail } from "../types/video"; import { formatRunTime } from "../api/client"; +function TranscodeBanner({ status }: { status: string }) { + if (status === "done" || !status) return null; + + const isPending = status === "pending" || status === "processing"; + + return ( +
+ {isPending ? ( +
+
+ + Video is being prepared for playback. This may take a + few minutes depending on file size. + +
+ ) : ( + + Video processing failed. The original format may not be + supported by your browser. + + )} +
+ ); +} + export default function VideoPlayer({ video }: { video: VideoDetail }) { return ( -
- +
+ {(video.transcodeStatus === "pending" || + video.transcodeStatus === "processing" || + video.transcodeStatus === "failed") && ( + + )} -
- + + Your browser does not support video playback. + - - {formatRunTime(video.runTime)} - +
+ + + + + .mtv replay + + + + {formatRunTime(video.runTime)} + +
); diff --git a/frontend/src/types/video.ts b/frontend/src/types/video.ts index 6df3c14..81c785c 100644 --- a/frontend/src/types/video.ts +++ b/frontend/src/types/video.ts @@ -35,4 +35,5 @@ export interface VideoDetail { jsonStats?: string; createdAt: string; previousPbs?: PreviousPB[]; + transcodeStatus?: "pending" | "processing" | "done" | "failed" | null; }