import { NextRequest } from "next/server"; import { spawn, type ChildProcess } from "node:child_process"; import fsp from "node:fs/promises"; import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video"; import { getAppSetting } from "@/lib/db/appSettings"; import { probeDuration } from "@/lib/video/duration"; import { assertLocalRequest } from "@/lib/api/localOnly"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** * HLS segment endpoint. Each request transcodes a single 6-second * window of the source via NVENC into MPEG-TS and pipes to the * response. -bf 0 keeps Chromium's H.264 sink happy. -force_key_frames * 0 (and NVENC's -forced-idr) ensure the segment opens with an IDR so * it's independently decodable — required by HLS. */ const SEGMENT_SECONDS = 6; export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { const blocked = assertLocalRequest(req); if (blocked) return blocked; const { code } = await ctx.params; const decoded = decodeURIComponent(code); const url = new URL(req.url); const partRaw = url.searchParams.get("part"); const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); const iRaw = url.searchParams.get("i"); const segmentIndex = iRaw == null ? 0 : Math.max(0, parseInt(iRaw, 10) || 0); let files = findVideosForCode(decoded); if (files.length === 0) { const main = (getAppSetting("videoLibraryPath") || "").trim(); const extras = getAppSetting("videoExtraPaths") ?? []; const expected = [main, ...extras].filter(Boolean); const idx = getVideoIndex(); const haveAll = expected.length === idx.rootsScanned.length && expected.every((r, i) => r === idx.rootsScanned[i]); if (expected.length > 0 && !haveAll) { await rescanVideoIndex(); files = findVideosForCode(decoded); } } if (files.length === 0) return new Response("not found", { status: 404 }); const file = files[Math.min(part, files.length - 1)]; try { await fsp.stat(file.abs); } catch { return new Response("not found", { status: 404 }); } const duration = await probeDuration(file.abs, req.signal); if (duration == null) { return new Response("ffprobe failed", { status: 500 }); } const startTime = segmentIndex * SEGMENT_SECONDS; if (startTime >= duration) { return new Response("segment out of range", { status: 416 }); } const segDur = Math.min(SEGMENT_SECONDS, duration - startTime); const ffmpegArgs: string[] = [ "-hide_banner", "-loglevel", "error", // -ss before -i = fast container-level seek (lands on the prior key // frame, NVENC's first emitted frame is an IDR by spec). "-ss", startTime.toFixed(3), "-t", segDur.toFixed(3), "-i", file.abs, "-map", "0:v:0", "-map", "0:a:0?", "-c:v", "h264_nvenc", "-preset", "p4", "-tune", "ll", "-profile:v", "high", "-bf", "0", "-forced-idr", "1", "-rc", "cbr", "-b:v", "8M", "-maxrate", "8M", "-bufsize", "16M", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-ac", "2", "-f", "mpegts", "-mpegts_flags", "+resend_headers", // Shift output timestamps so segment N's PTS starts at N*SEGMENT_SECONDS. // Without this, every segment would emit at PTS≈0 and hls.js / MSE // can't lay them out on a continuous timeline (would need // #EXT-X-DISCONTINUITY markers for that). Continuous PTS = clean // append, smooth playback across segment boundaries. "-output_ts_offset", startTime.toFixed(3), "pipe:1", ]; let ffmpeg: ChildProcess; try { ffmpeg = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] }); } catch (e) { return new Response(`ffmpeg spawn failed: ${(e as Error).message}`, { status: 500 }); } ffmpeg.stderr?.on("data", (chunk: Buffer) => { const text = chunk.toString(); if (text.trim()) console.error(`[hls ${decoded} seg=${segmentIndex}] ${text.trim()}`); }); return new Response(streamFromFfmpeg(ffmpeg, req.signal), { status: 200, headers: { "Content-Type": "video/mp2t", // Allow short-term caching — within a single playback session hls.js // may re-request a segment if its buffer was evicted, and a cache // hit avoids re-spawning ffmpeg. "Cache-Control": "private, max-age=300", }, }); } function streamFromFfmpeg(proc: ChildProcess, signal: AbortSignal): ReadableStream { return new ReadableStream({ start(controller) { let closed = false; const finish = () => { if (closed) return; closed = true; try { controller.close(); } catch { /* already closed */ } }; const fail = (err: Error) => { if (closed) return; closed = true; try { controller.error(err); } catch { /* already closed */ } }; proc.stdout?.on("data", (chunk: Buffer) => { if (closed) return; try { controller.enqueue(new Uint8Array(chunk)); } catch { closed = true; try { proc.kill("SIGKILL"); } catch { /* ignore */ } } }); proc.stdout?.on("end", finish); proc.on("error", (e) => fail(e)); proc.on("exit", finish); const onAbort = () => { try { proc.kill("SIGKILL"); } catch { /* ignore */ } }; if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }, cancel() { try { proc.kill("SIGKILL"); } catch { /* ignore */ } }, }); }