Initial commit
This commit is contained in:
@@ -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" } },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user