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"); }