Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
import {
walkSubtitles,
detectLanguageFromName,
normalizeLanguageTag,
languageDisplay,
stemOf,
type LangIso,
} from "@/lib/video/subtitles";
import { runFfprobeSubtitles } from "@/lib/video/metadata";
import { getAppSetting } from "@/lib/db/appSettings";
import { listManualSubtitlesForVariant } from "@/lib/video/manualSubtitles";
import fs from "node:fs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Sidecar (external file) subtitle source. */
interface SidecarOut {
/** Stable client-side id; encodes the abs path so the track endpoint
* can resolve it. */
id: string;
abs: string;
filename: string;
ext: string; // ".srt" | ".vtt" | ".ass" | ".ssa"
language: LangIso | null;
label: string;
origin: "same-folder" | "library" | "manual";
}
/** Embedded-stream subtitle source (filled in once ffprobe is wired up
* in phase 2). */
interface EmbeddedOut {
id: string;
streamIndex: number;
codec: string;
language: LangIso | null;
label: string;
renderable: boolean;
}
function formatCodecLabel(codec: string): string | null {
switch (codec) {
case "subrip": return "SRT";
case "ass": return "ASS";
case "ssa": return "SSA";
case "mov_text": return "mov_text";
case "webvtt": return "VTT";
case "hdmv_pgs_subtitle": return "PGS";
case "dvd_subtitle": return "DVDSub";
case "dvb_subtitle": return "DVBSub";
default: return codec ? codec.toUpperCase() : null;
}
}
function encodeSideId(abs: string): string {
return `side:${Buffer.from(abs, "utf8").toString("base64url")}`;
}
/** Filter walkSubtitles results to entries that look like they belong
* to this specific video — stem prefix is the strong signal; code
* substring is the fallback. Both case-insensitive. */
function matchesVideo(filename: string, stem: string, code: string): boolean {
const lowerName = filename.toLowerCase();
const lowerStem = stem.toLowerCase();
const lowerCode = code.toLowerCase();
if (lowerName.startsWith(lowerStem + ".")) return true;
if (lowerName === lowerStem) return true;
return lowerName.includes(lowerCode);
}
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 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 });
}
let files = findVideosForCode(decoded);
if (files.length === 0) {
// Cold-boot path: VideoIndexProvider may not have triggered the
// initial scan yet. Build it once so the picker doesn't appear
// empty on first modal open after server start.
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);
}
}
const variant = files[partIdx];
if (!variant) {
return NextResponse.json({ embedded: [], sidecar: [] });
}
const variantStem = stemOf(variant.filename);
const dir = path.dirname(variant.abs);
// Phase 1: same-folder sidecars only. Embedded streams + library scan
// are added in later phases via additive concat into these arrays.
const sidecar: SidecarOut[] = [];
const seen = new Set<string>();
const pushEntry = (
entry: { abs: string; filename: string },
origin: "same-folder" | "library",
) => {
if (seen.has(entry.abs)) return;
if (!matchesVideo(entry.filename, variantStem, decoded)) return;
seen.add(entry.abs);
const detected = detectLanguageFromName(entry.filename);
const ext = path.extname(entry.filename).toLowerCase();
sidecar.push({
id: encodeSideId(entry.abs),
abs: entry.abs,
filename: entry.filename,
ext,
language: detected.lang,
label: detected.label,
origin,
});
};
try {
for (const entry of await walkSubtitles(dir, 1)) pushEntry(entry, "same-folder");
} catch { /* ignore */ }
// Library scan: persistent extra paths from settings. Slightly deeper
// walk because users typically point these at organized hierarchies.
const extraPaths = (getAppSetting("subtitleExtraPaths") ?? []).filter(Boolean);
for (const root of extraPaths) {
try {
for (const entry of await walkSubtitles(root, 3)) pushEntry(entry, "library");
} catch { /* missing or unreadable root */ }
}
// Implicit always-on root: data/generated-subtitles/<code>/ catches
// WhisperJAV-produced .srt when the video folder isn't writable.
const generatedDir = path.join(process.cwd(), "data", "generated-subtitles", decoded);
try {
for (const entry of await walkSubtitles(generatedDir, 1)) pushEntry(entry, "library");
} catch { /* nothing generated yet */ }
// Manually attached files via Browse... in the player. Persisted
// across sessions; only included when the file still exists on disk.
for (const m of listManualSubtitlesForVariant(decoded, partIdx)) {
if (seen.has(m.absPath)) continue;
if (!fs.existsSync(m.absPath)) continue;
const filename = path.basename(m.absPath);
if (!filename) continue;
const detected = detectLanguageFromName(filename);
const ext = path.extname(filename).toLowerCase();
seen.add(m.absPath);
sidecar.push({
id: encodeSideId(m.absPath),
abs: m.absPath,
filename,
ext,
language: detected.lang,
label: detected.label,
origin: "manual",
});
}
// Stable order: same-folder before library, then by language priority
// (EN, CN, JP, Unknown), then by filename.
const langRank: Record<string, number> = { eng: 0, zho: 1, jpn: 2 };
sidecar.sort((a, b) => {
if (a.origin !== b.origin) return a.origin === "same-folder" ? -1 : 1;
const ra = a.language ? (langRank[a.language] ?? 9) : 9;
const rb = b.language ? (langRank[b.language] ?? 9) : 9;
if (ra !== rb) return ra - rb;
return a.filename.localeCompare(b.filename);
});
const embedded: EmbeddedOut[] = [];
let streams: Awaited<ReturnType<typeof runFfprobeSubtitles>> = [];
try {
streams = await runFfprobeSubtitles(variant.abs);
} catch {
streams = [];
}
for (const s of streams) {
const iso = normalizeLanguageTag(s.language);
const codecLabel = formatCodecLabel(s.codec);
const trailing: string[] = [];
if (s.title) trailing.push(s.title);
if (codecLabel) trailing.push(codecLabel);
const base = iso ? languageDisplay(iso) : (s.title ?? "Unknown");
const label = trailing.length > 0 && !iso
? `${base}${codecLabel ? ` (${codecLabel})` : ""}`
: codecLabel
? `${base} (${codecLabel})`
: base;
embedded.push({
id: `emb:${s.index}`,
streamIndex: s.index,
codec: s.codec,
language: iso,
label,
renderable: s.isTextBased,
});
}
return NextResponse.json(
{ embedded, sidecar },
{ headers: { "Cache-Control": "no-store" } },
);
}
@@ -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));
});
});
});
}