import { readFile } from "node:fs/promises";
import path from "node:path";
import { SONG_REPORTS } from "@/lib/paths";
import { readSong, resolveRendition, type Song } from "@/lib/browse";
import { posterFrame } from "@/lib/poster";
import { projectRef, summariseProject } from "@/lib/projects";
import { resolveInRoots } from "@/lib/paths";
export const dynamic = "force-dynamic";
// A frame for a song, a cut or a variant.
//
// This route takes NAMES, never a path: `?song=` is a directory segment and
// `?rel=` must be a member of that song's current scan. It is the pattern every
// new browse route follows -- the only routes here that accept a free-form
// `path=` are the existing /api/mix/* ones, and none of those renames anything.
//
// Generation is lazy, on this route, rather than during the scan. Twenty
// extractions at ~300ms each would put six seconds in front of the index; as
//
the browser parallelises them and the page paints immediately.
const TYPES: Record = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
function send(buf: Buffer, file: string, source: string) {
return new Response(new Uint8Array(buf), {
headers: {
"content-type": TYPES[path.extname(file).toLowerCase()] ?? "image/jpeg",
"content-length": String(buf.length),
"cache-control": "no-store",
// Both an assertion surface for the e2e suite and the quickest way to
// see, in devtools, whether the cache is doing anything.
"x-poster": source,
},
});
}
export async function GET(request: Request) {
const url = new URL(request.url);
const project = url.searchParams.get("project");
const id = url.searchParams.get("song") ?? "";
const rel = url.searchParams.get("rel");
const width = Math.min(1280, Math.max(120, Number(url.searchParams.get("w")) || 480));
// `?project=` is the general form; `?song=` stays because every existing song
// link and every existing spec uses it.
//
// A project poster takes NO client-supplied rel. The frame it draws is the
// one its own summariser chose -- the deliverable, else a built segment (which
// already carries the chrome, so the card looks like the video mid-build),
// else a raw clip. That is a server-derived path, which is why this route can
// widen to every kind without widening what a caller can ask it to open.
if (project) {
const ref = await projectRef(project);
if (!ref) return new Response("no such project", { status: 404 });
const summary = await summariseProject(ref);
if (!summary.posterRel) return new Response("nothing to draw", { status: 404 });
const abs = resolveInRoots(path.join(ref.dir, summary.posterRel));
if (!abs) return new Response("outside the roots", { status: 400 });
if (TYPES[path.extname(abs).toLowerCase()]) {
try {
return send(await readFile(abs), abs, "file");
} catch {
return new Response("nothing to draw", { status: 404 });
}
}
const poster = await posterFrame(abs, width);
if (!poster) return new Response(null, { status: 404 });
return send(await readFile(poster.file), poster.file, poster.cached ? "cached" : "fresh");
}
const song = await readSong(id);
if (!song) return new Response("no such song", { status: 404 });
// Song level: the hand-composed cover, when there is one for this name.
if (!rel) {
if (song.thumb) {
const abs = path.join(SONG_REPORTS, song.thumb);
try {
return send(await readFile(abs), abs, "thumb");
} catch {
/* fall through to an extracted frame */
}
}
if (!song.posterRel) return new Response("nothing to draw", { status: 404 });
return frame(song, song.posterRel, width);
}
return frame(song, rel, width);
}
async function frame(song: Song, rel: string, width: number) {
// Membership in the current scan, not just a well-formed name: a plausible
// in-tree name that is not actually there gets a 404, not an ffmpeg attempt.
const found = [...song.cuts.flatMap((c) => [c.shipped, ...c.variants]), ...song.unattributed].some(
(r) => r && r.rel === rel,
);
if (!found) return new Response("not in this song", { status: 404 });
const abs = resolveRendition(song.id, rel);
if (!abs) return new Response("outside the roots", { status: 400 });
const poster = await posterFrame(abs, width);
// A blank tile beats a stack trace: a song with one broken mp4 should still
// render every other tile on the page.
if (!poster) return new Response(null, { status: 404 });
return send(await readFile(poster.file), poster.file, poster.cached ? "cached" : "fresh");
}