Files
2026-05-26 22:46:00 +02:00

144 lines
4.7 KiB
TypeScript

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<typeof serializeVideoMetadata>;
}
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<string, VariantOut[]>();
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<VariantOut & { __sort: number }>).__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,
})),
});
}