music indexer: generate video poster thumbnails

Each indexed video gets a compressed poster (a frame grab ~10% in, capped at
30s, scaled ≤600px q5 like covers), written to cache/<rel>/posters/<file>.jpg
and recorded as `poster` on the meta.videos entry. The posters dir is wiped and
regenerated on each rebuild so orphans (removed videos) don't linger. New
`postersSaved` status counter.

Served by a new sidecar route GET /api/music/poster?path=<rel>&file=<video>
(image/jpeg, ETag=<v>, 304, 404 when none) — path-safe via basename.

Verified end-to-end on a real .mp4: video-only album → manifest {tracks:0,
videos:1}, meta.poster set, 14 KB poster on disk. MUSIC_API.md documents the
poster field + endpoint + postersSaved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:38:15 +00:00
co-authored by Claude Opus 4.8
parent d5b3dbb473
commit 10d7b6a425
3 changed files with 72 additions and 2 deletions
+15
View File
@@ -12,6 +12,7 @@ import {
metaFilePath,
coverFilePath,
discographyFilePath,
posterFilePath,
onIndexProgress,
buildReport,
} from './indexer';
@@ -36,6 +37,7 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, disco? } } }
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
// GET /poster?path=<rel>&file=<video> compressed video poster (frame grab). ETag: <v>; 304. 404 if none.
// GET /discography?path=<artist rel> artist discography.json (only where manifest entry has
// disco:true) = { artist, albums: { "<[year] album folder>":
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
@@ -151,6 +153,19 @@ const server = Bun.serve({
return json(await getManifest());
}
// Video poster (a compressed frame grab). Keyed by the video's folder rel + its filename.
if (url.pathname === '/poster') {
const rel = url.searchParams.get('path');
const file = url.searchParams.get('file');
if (rel === null || !file) return new Response('path and file are required', { status: 400 });
const posterPath = posterFilePath(rel, file);
if (!posterPath) return new Response('Invalid path', { status: 400 });
if (!(await Bun.file(posterPath).exists())) return new Response('Not found', { status: 404 });
const v = await albumVersion(rel);
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
return new Response(Bun.file(posterPath), { headers: { 'Content-Type': 'image/jpeg', ...(v ? { ETag: v } : {}) } });
}
if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
const rel = url.searchParams.get('path');
if (rel === null) return new Response('path is required', { status: 400 });