Initial commit
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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<NextResponse> {
|
||||
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<Buffer> {
|
||||
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));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user