import { NextRequest, NextResponse } from "next/server"; import path from "node:path"; import fs from "node:fs/promises"; import { spawn } from "node:child_process"; import { assertLocalRequest } from "@/lib/api/localOnly"; import { srtToVtt, SUBTITLE_EXTS, decodeSubtitleBuffer } from "@/lib/video/subtitles"; import { isAllowedSubtitlePath } from "@/lib/video/subtitleAccess"; import { cachePath, readCache, writeCache } from "@/lib/video/subtitleCache"; import { findVideosForCode } from "@/lib/video"; import { runFfprobeSubtitles } from "@/lib/video/metadata"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const VTT_HEADERS = { "Content-Type": "text/vtt; charset=utf-8", "Cache-Control": "no-store", } as const; function decodeSide(src: string): string | null { if (!src.startsWith("side:")) return null; const b64 = src.slice("side:".length); try { const decoded = Buffer.from(b64, "base64url").toString("utf8"); if (!decoded) return null; return path.resolve(decoded); } catch { return null; } } export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { const blocked = assertLocalRequest(req); if (blocked) return blocked; const src = req.nextUrl.searchParams.get("src") ?? ""; if (!src) { return NextResponse.json({ error: "Missing src" }, { status: 400 }); } if (src.startsWith("emb:")) { return handleEmbedded(req, ctx, src); } const abs = decodeSide(src); if (!abs) { return NextResponse.json({ error: "Invalid src" }, { status: 400 }); } if (!isAllowedSubtitlePath(abs)) { return NextResponse.json({ error: "Subtitle path not allowed" }, { status: 403 }); } const ext = path.extname(abs).toLowerCase(); if (!(SUBTITLE_EXTS as readonly string[]).includes(ext)) { return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 }); } let stat; try { stat = await fs.stat(abs); } catch { return NextResponse.json({ error: "Subtitle file not found" }, { status: 404 }); } if (ext === ".vtt") { // VTT spec mandates UTF-8 but real-world files occasionally ship // as UTF-16 BOM or a legacy Asian encoding. Run through the same // decoder as .srt so the output is consistent UTF-8. let buf: Buffer; try { buf = await fs.readFile(abs); } catch { return NextResponse.json({ error: "Read failed" }, { status: 500 }); } const text = decodeSubtitleBuffer(buf); return new NextResponse(text, { headers: VTT_HEADERS }); } if (ext === ".srt") { const file = cachePath({ abs, size: stat.size, mtimeMs: stat.mtimeMs, kind: "srt", streamOrExt: "srt", }); const cached = await readCache(file); if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS }); let buf: Buffer; try { buf = await fs.readFile(abs); } catch { return NextResponse.json({ error: "Read failed" }, { status: 500 }); } // decodeSubtitleBuffer auto-detects UTF-8 / UTF-16 / shift_jis / // gb18030 / big5 — a bare `toString("utf8")` mojibakes legacy CN // and JP fansub SRTs. const raw = decodeSubtitleBuffer(buf); const vtt = srtToVtt(raw); try { await writeCache(file, vtt); } catch { // Cache miss + failed write isn't fatal; still serve the conversion. } return new NextResponse(vtt, { headers: VTT_HEADERS }); } if (ext === ".ass" || ext === ".ssa") { const file = cachePath({ abs, size: stat.size, mtimeMs: stat.mtimeMs, kind: ext === ".ass" ? "ass" : "ssa", streamOrExt: ext.slice(1), }); const cached = await readCache(file); if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS }); let buf; try { buf = await ffmpegToVtt(["-i", abs, "-map", "0:s:0", "-c:s", "webvtt", "-f", "webvtt", "pipe:1"], req.signal); } catch { return NextResponse.json({ error: "Subtitle conversion failed" }, { status: 500 }); } if (buf.length === 0) return new NextResponse(null, { status: 204 }); try { await writeCache(file, buf); } catch { /* ignore */ } return new NextResponse(new Uint8Array(buf), { headers: VTT_HEADERS }); } return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 }); } async function handleEmbedded( req: NextRequest, ctx: { params: Promise<{ code: string }> }, src: string, ): Promise { const streamIdx = Number.parseInt(src.slice("emb:".length), 10); if (!Number.isFinite(streamIdx) || streamIdx < 0) { return NextResponse.json({ error: "Invalid stream index" }, { status: 400 }); } const partParam = req.nextUrl.searchParams.get("part"); const partIdx = partParam == null ? 0 : Number.parseInt(partParam, 10); if (!Number.isFinite(partIdx) || partIdx < 0) { return NextResponse.json({ error: "Invalid part index" }, { status: 400 }); } const { code } = await ctx.params; const decoded = decodeURIComponent(code); const variant = findVideosForCode(decoded)[partIdx]; if (!variant) { return NextResponse.json({ error: "Video not found" }, { status: 404 }); } // Re-probe to validate the requested stream is real and text-based. // Cheap (sub-100ms) and avoids serving image-based subtitles that // would render as garbled text or hang ffmpeg. const streams = await runFfprobeSubtitles(variant.abs); const target = streams.find((s) => s.index === streamIdx); if (!target) { return NextResponse.json({ error: "Stream not found" }, { status: 404 }); } if (target.isImageBased) { return NextResponse.json({ error: "Image-based subtitles not supported" }, { status: 415 }); } if (!target.isTextBased) { return NextResponse.json({ error: "Subtitle codec not supported" }, { status: 415 }); } let stat; try { stat = await fs.stat(variant.abs); } catch { return NextResponse.json({ error: "Video not readable" }, { status: 404 }); } const file = cachePath({ abs: variant.abs, size: stat.size, mtimeMs: stat.mtimeMs, kind: "embedded", streamOrExt: streamIdx, }); const cached = await readCache(file); if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS }); let buf: Buffer; try { buf = await ffmpegToVtt([ "-i", variant.abs, "-map", `0:s:${streamIdx}`, "-c:s", "webvtt", "-f", "webvtt", "pipe:1", ], req.signal); } catch { return NextResponse.json({ error: "Subtitle extraction failed" }, { status: 500 }); } if (buf.length === 0) return new NextResponse(null, { status: 204 }); try { await writeCache(file, buf); } catch { /* ignore */ } return new NextResponse(new Uint8Array(buf), { headers: VTT_HEADERS }); } const FFMPEG_TIMEOUT_MS = 15_000; function ffmpegToVtt(args: string[], signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const proc = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", ...args]); const chunks: Buffer[] = []; let err = ""; let settled = false; const settle = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(t); if (signal && onAbort) signal.removeEventListener("abort", onAbort); fn(); }; const t = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} settle(() => reject(new Error("ffmpeg timed out"))); }, FFMPEG_TIMEOUT_MS); // Tear down the subprocess on client disconnect so a 15-second // ghost ffmpeg doesn't keep CPU after the user closes the modal. const onAbort = signal ? () => { try { proc.kill("SIGKILL"); } catch {} settle(() => reject(new Error("client aborted"))); } : null; if (signal && onAbort) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); } proc.stdout?.on("data", (d: Buffer) => { chunks.push(d); }); proc.stderr?.on("data", (d) => { err += d.toString(); }); proc.on("error", (e) => settle(() => reject(e))); proc.on("close", (code) => { settle(() => { if (code !== 0) { reject(new Error(err.trim() || `ffmpeg exited ${code}`)); return; } resolve(Buffer.concat(chunks)); }); }); }); }