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(); 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// 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 = { 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> = []; 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" } }, ); }