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:
+12
-2
@@ -106,7 +106,8 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
|
|||||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||||
"durationSec": 8130,
|
"durationSec": 8130,
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 720
|
"height": 720,
|
||||||
|
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
|
||||||
}
|
}
|
||||||
// …
|
// …
|
||||||
]
|
]
|
||||||
@@ -124,6 +125,15 @@ GET /api/music/cover?path=<rel>
|
|||||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
Only meaningful when the manifest entry has `"cover": true`.
|
Only meaningful when the manifest entry has `"cover": true`.
|
||||||
|
|
||||||
|
#### 2.3.1 Video poster
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||||
|
```
|
||||||
|
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||||
|
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||||
|
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||||
|
|
||||||
### 2.4 Discography (artist album grouping)
|
### 2.4 Discography (artist album grouping)
|
||||||
|
|
||||||
For artist folders (manifest entry with `"disco": true`), this returns a map of **album folder → release
|
For artist folders (manifest entry with `"disco": true`), this returns a map of **album folder → release
|
||||||
@@ -174,7 +184,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
"running": true,
|
"running": true,
|
||||||
"startedAt": 1785034701973, "finishedAt": null,
|
"startedAt": 1785034701973, "finishedAt": null,
|
||||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
||||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "discographies": 3,
|
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "discographies": 3,
|
||||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||||
"error": null
|
"error": null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
metaFilePath,
|
metaFilePath,
|
||||||
coverFilePath,
|
coverFilePath,
|
||||||
discographyFilePath,
|
discographyFilePath,
|
||||||
|
posterFilePath,
|
||||||
onIndexProgress,
|
onIndexProgress,
|
||||||
buildReport,
|
buildReport,
|
||||||
} from './indexer';
|
} 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? } } }
|
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, disco? } } }
|
||||||
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
// 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 /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
|
// GET /discography?path=<artist rel> artist discography.json (only where manifest entry has
|
||||||
// disco:true) = { artist, albums: { "<[year] album folder>":
|
// disco:true) = { artist, albums: { "<[year] album folder>":
|
||||||
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
|
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
|
||||||
@@ -151,6 +153,19 @@ const server = Bun.serve({
|
|||||||
return json(await getManifest());
|
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') {
|
if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
|
||||||
const rel = url.searchParams.get('path');
|
const rel = url.searchParams.get('path');
|
||||||
if (rel === null) return new Response('path is required', { status: 400 });
|
if (rel === null) return new Response('path is required', { status: 400 });
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type IndexVideo = {
|
|||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
poster?: string; // relative to <rel>: "posters/<file>.jpg" — a compressed frame grab, when generated
|
||||||
};
|
};
|
||||||
export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[]; videos?: IndexVideo[] };
|
export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[]; videos?: IndexVideo[] };
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ export type IndexStatus = {
|
|||||||
tracksIndexed: number;
|
tracksIndexed: number;
|
||||||
videosIndexed: number;
|
videosIndexed: number;
|
||||||
coversSaved: number;
|
coversSaved: number;
|
||||||
|
postersSaved: number;
|
||||||
discographies: number; // artist discography.json files written
|
discographies: number; // artist discography.json files written
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -146,6 +148,7 @@ const status: IndexStatus = {
|
|||||||
tracksIndexed: 0,
|
tracksIndexed: 0,
|
||||||
videosIndexed: 0,
|
videosIndexed: 0,
|
||||||
coversSaved: 0,
|
coversSaved: 0,
|
||||||
|
postersSaved: 0,
|
||||||
discographies: 0,
|
discographies: 0,
|
||||||
currentPath: '',
|
currentPath: '',
|
||||||
error: null,
|
error: null,
|
||||||
@@ -338,6 +341,35 @@ async function compressCover(srcAbs: string, destAbs: string): Promise<boolean>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grab a representative frame (~10% in, capped at 30s to skip intros) and compress it like a cover. */
|
||||||
|
async function generateVideoPoster(srcAbs: string, destAbs: string, durationSec?: number): Promise<boolean> {
|
||||||
|
const ts = durationSec && durationSec > 0 ? Math.min(30, Math.max(1, Math.floor(durationSec * 0.1))) : 5;
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
'ffmpeg',
|
||||||
|
'-y',
|
||||||
|
'-ss',
|
||||||
|
String(ts),
|
||||||
|
'-i',
|
||||||
|
srcAbs,
|
||||||
|
'-frames:v',
|
||||||
|
'1',
|
||||||
|
'-vf',
|
||||||
|
`scale='min(iw,${COVER_MAX_PX})':'min(ih,${COVER_MAX_PX})':force_original_aspect_ratio=decrease`,
|
||||||
|
'-q:v',
|
||||||
|
'5',
|
||||||
|
destAbs,
|
||||||
|
],
|
||||||
|
{ stdout: 'ignore', stderr: 'ignore' },
|
||||||
|
);
|
||||||
|
await proc.exited;
|
||||||
|
return proc.exitCode === 0 && existsSync(destAbs);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Build ──
|
// ── Build ──
|
||||||
|
|
||||||
export async function buildMusicIndex(): Promise<IndexStatus> {
|
export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||||
@@ -352,6 +384,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
|||||||
tracksIndexed: 0,
|
tracksIndexed: 0,
|
||||||
videosIndexed: 0,
|
videosIndexed: 0,
|
||||||
coversSaved: 0,
|
coversSaved: 0,
|
||||||
|
postersSaved: 0,
|
||||||
discographies: 0,
|
discographies: 0,
|
||||||
currentPath: '',
|
currentPath: '',
|
||||||
error: null,
|
error: null,
|
||||||
@@ -502,8 +535,16 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
|||||||
emitProgress();
|
emitProgress();
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
|
// Regenerate posters from scratch (drop any orphaned by removed videos).
|
||||||
|
const postersDir = join(cacheDir, 'posters');
|
||||||
|
await rm(postersDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
if (video.length) await mkdir(postersDir, { recursive: true });
|
||||||
const videos = await mapPool(video, TRACK_CONCURRENCY, async (name) => {
|
const videos = await mapPool(video, TRACK_CONCURRENCY, async (name) => {
|
||||||
const vmeta = await ffprobeVideo(join(dirAbs, name), name);
|
const vmeta = await ffprobeVideo(join(dirAbs, name), name);
|
||||||
|
if (await generateVideoPoster(join(dirAbs, name), join(postersDir, `${name}.jpg`), vmeta.durationSec)) {
|
||||||
|
vmeta.poster = `posters/${name}.jpg`;
|
||||||
|
status.postersSaved += 1;
|
||||||
|
}
|
||||||
status.videosIndexed += 1;
|
status.videosIndexed += 1;
|
||||||
emitProgress();
|
emitProgress();
|
||||||
return vmeta;
|
return vmeta;
|
||||||
@@ -561,3 +602,7 @@ export function coverFilePath(rel: string): string | null {
|
|||||||
export function discographyFilePath(rel: string): string | null {
|
export function discographyFilePath(rel: string): string | null {
|
||||||
return resolveCachePath(rel, 'discography.json');
|
return resolveCachePath(rel, 'discography.json');
|
||||||
}
|
}
|
||||||
|
/** A video's poster: cache/<rel>/posters/<file>.jpg. `file` is basename'd to keep it within the folder. */
|
||||||
|
export function posterFilePath(rel: string, file: string): string | null {
|
||||||
|
return resolveCachePath(rel, join('posters', `${basename(file)}.jpg`));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user