import { NextRequest } from "next/server"; 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 playlist generator. Returns an m3u8 with N segment URLs covering * the full video duration. Segments are produced on demand by the * sibling /segment endpoint (each one is a fresh NVENC transcode of a * fixed time window). Player (hls.js) requests segments as needed for * playback and seeking. */ 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); 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 segCount = Math.ceil(duration / SEGMENT_SECONDS); const lines: string[] = [ "#EXTM3U", "#EXT-X-VERSION:3", `#EXT-X-TARGETDURATION:${SEGMENT_SECONDS}`, "#EXT-X-MEDIA-SEQUENCE:0", "#EXT-X-PLAYLIST-TYPE:VOD", ]; for (let i = 0; i < segCount; i++) { const remaining = duration - i * SEGMENT_SECONDS; const segDur = Math.min(SEGMENT_SECONDS, remaining); lines.push(`#EXTINF:${segDur.toFixed(3)},`); // Relative URL — resolves against the playlist URL's directory. // Playlist is at /api/video-hls/[code]/playlist, so its directory is // /api/video-hls/[code]/ and `segment?...` resolves to the sibling. lines.push(`segment?part=${part}&i=${i}`); } lines.push("#EXT-X-ENDLIST"); return new Response(lines.join("\n"), { status: 200, headers: { "Content-Type": "application/vnd.apple.mpegurl", "Cache-Control": "no-store", }, }); }