144 lines
4.7 KiB
TypeScript
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,
|
|
})),
|
|
});
|
|
}
|