import { NextRequest, NextResponse } from "next/server"; import path from "node:path"; import { findVideosForCode } from "@/lib/video"; import { assertLocalRequest } from "@/lib/api/localOnly"; import { getStoredVideoMetadata, serializeVideoMetadata } from "@/lib/video/metadata"; import { variantLabel } from "@/lib/video/partClassify"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; interface VariantOut { /** Absolute 0-based index into the original findVideosForCode result. * Used as the `?part=` query value for stream/HLS endpoints. */ partIdx: number; abs: string; rel: string; filename: string; size: number; label: string; metadata: ReturnType; } interface PartOut { /** 1-based display index for the parts strip. */ partIndex: number; /** Index into `variants[]` to use when no user pick has been made. */ defaultIdx: number; variants: VariantOut[]; } function stemOf(filename: string): string { const ext = path.extname(filename); return ext ? filename.slice(0, -ext.length) : filename; } /** * Group raw video files into parts (sequential CDs/discs) with * variants (alt encodes of the same part). Uses classification from * the metadata table; falls back to "every file is its own part" when * classification hasn't run yet. */ 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 files = findVideosForCode(decoded); // Build per-part groups. const partMap = new Map(); const orderedKeys: string[] = []; files.forEach((f, i) => { const meta = getStoredVideoMetadata(f.abs); const stem = stemOf(f.filename); const kind = meta?.partKind; const idx = meta?.partIndex ?? null; const group = meta?.variantGroup ?? null; // Group key strategy: // - "part" → group by the part's variantGroup (variants attach via dot-prefix) // - "variant" → group by their attached variantGroup // - "single" / unclassified → a singleton group keyed by abs path let key: string; if ((kind === "part" || kind === "variant") && group != null) { key = `g:${group}`; } else { key = `s:${f.abs}`; } const variant: VariantOut = { partIdx: i, abs: f.abs, rel: f.rel, filename: f.filename, size: f.size, label: group ? variantLabel(stem, group) : "original", metadata: serializeVideoMetadata(meta), }; // Stash the underlying part index for sorting; non-parts get +Infinity. (variant as VariantOut & { __sort: number }).__sort = idx ?? (kind === "variant" ? -1 : Number.MAX_SAFE_INTEGER); let arr = partMap.get(key); if (!arr) { arr = []; partMap.set(key, arr); orderedKeys.push(key); } arr.push(variant); }); // Build the ordered parts list. Sort parts by their lowest known // partIndex (singles fall to the end), preserving insertion order // as a tiebreak. const partEntries = orderedKeys.map((k) => { const variants = partMap.get(k)!; const minSort = Math.min(...variants.map((v) => (v as VariantOut & { __sort: number }).__sort)); return { key: k, variants, sort: minSort }; }); partEntries.sort((a, b) => { if (a.sort !== b.sort) return a.sort - b.sort; return a.variants[0]!.partIdx - b.variants[0]!.partIdx; }); const parts: PartOut[] = partEntries.map((entry, i) => { const variants = entry.variants; // Strip the sort helper field. for (const v of variants) delete (v as Partial).__sort; // Default = the variant whose stem == group (the "base" file). If // none, alphabetically first by filename. const groupKey = entry.key.startsWith("g:") ? entry.key.slice(2) : null; let defaultIdx = 0; if (groupKey != null) { const exact = variants.findIndex((v) => stemOf(v.filename) === groupKey); if (exact >= 0) defaultIdx = exact; else { const sortedAlpha = [...variants].sort((a, b) => a.filename.localeCompare(b.filename)); defaultIdx = variants.indexOf(sortedAlpha[0]!); } } return { partIndex: i + 1, defaultIdx, variants, }; }); // Backwards-compatible flat list — the default variant of each part // in display order. Existing consumers that only need one entry per // part keep working without changes. const flat = parts.map((p) => p.variants[p.defaultIdx]!); return NextResponse.json({ parts, files: flat.map((v) => ({ abs: v.abs, rel: v.rel, filename: v.filename, size: v.size, metadata: v.metadata, })), }); }