import { NextRequest } from "next/server"; import path from "node:path"; import fs from "node:fs"; import fsp from "node:fs/promises"; import { findVideosForCode } from "@/lib/video"; import { assertLocalRequest } from "@/lib/api/localOnly"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const MIME_BY_EXT: Record = { ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo", ".wmv": "video/x-ms-wmv", ".ts": "video/mp2t", ".mpg": "video/mpeg", ".mpeg": "video/mpeg", ".flv": "video/x-flv", }; 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 files = findVideosForCode(decoded); if (files.length === 0) return new Response("not found", { status: 404 }); const file = files[Math.min(part, files.length - 1)]; let stat: import("node:fs").Stats; try { stat = await fsp.stat(file.abs); } catch { return new Response("not found", { status: 404 }); } const total = stat.size; const ext = path.extname(file.abs).toLowerCase(); const mime = MIME_BY_EXT[ext] ?? "application/octet-stream"; // Stable identity for the byte stream — lets the browser's HTTP cache // hold onto previously fetched ranges (the moov tail in particular) // instead of re-hitting our endpoint on every seek / buffer-ahead. const etag = `"${stat.size.toString(36)}-${Math.floor(stat.mtimeMs).toString(36)}"`; const lastModified = new Date(stat.mtimeMs).toUTCString(); const range = req.headers.get("range"); const baseHeaders: Record = { "Content-Type": mime, "Accept-Ranges": "bytes", "Cache-Control": "private, max-age=3600", "ETag": etag, "Last-Modified": lastModified, "Content-Disposition": `inline; filename="${encodeURIComponent(file.filename)}"`, }; if (!range) { return new Response(streamFile(file.abs, undefined, undefined, req.signal), { status: 200, headers: { ...baseHeaders, "Content-Length": String(total) }, }); } // Parse "bytes=START-END"; END may be empty for "until end", and // START may be empty for HTTP suffix ranges ("last N bytes"). const m = /^bytes=(\d*)-(\d*)$/.exec(range); if (!m) return new Response("bad range", { status: 416 }); let start: number; let end: number; if (m[1] === "") { const suffixLen = Number(m[2]); if (!Number.isFinite(suffixLen) || suffixLen <= 0) { return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } }); } start = Math.max(total - suffixLen, 0); end = total - 1; } else { start = Number(m[1]); end = m[2] === "" ? total - 1 : Number(m[2]); } if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) { return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } }); } const len = end - start + 1; return new Response(streamFile(file.abs, start, end, req.signal), { status: 206, headers: { ...baseHeaders, "Content-Range": `bytes ${start}-${end}/${total}`, "Content-Length": String(len), }, }); } /** * Pipe a file slice into a Web ReadableStream that the runtime can hand * to fetch's Response. Tying the read stream to the request's AbortSignal * is the bit that fixes "Invalid state: Controller is already closed": * when the browser cancels (modal close, seek, network blip) the Node * stream is destroyed before it can push more bytes into a stream the * runtime has already closed. */ function streamFile( abs: string, start: number | undefined, end: number | undefined, signal: AbortSignal, ): ReadableStream { let node: fs.ReadStream | null = null; let closed = false; return new ReadableStream({ start(controller) { node = fs.createReadStream(abs, { start, end }); 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 */ } }; node.on("data", (chunk: unknown) => { if (closed) return; try { const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk as ArrayBufferLike); controller.enqueue(u8); } catch { closed = true; node?.destroy(); } }); node.on("end", finish); node.on("error", (err) => fail(err as Error)); const onAbort = () => { closed = true; node?.destroy(); }; if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }, cancel() { // ReadableStream.cancel() fires when the consumer is done before // req.signal aborts (e.g. browser closes the response body cleanly // after a Range fulfill). Without destroying the node stream here, // the open file handle leaks until GC. closed = true; node?.destroy(); }, }); }