music: index + serve loose folder images (band photos, booklet scans)

Analog of the videos feature — exposes per-folder images (excluding the album
cover files) under /api/music so the app can show an "Images" section.

Indexer: IMAGE_EXT + isImage; collect loose images (minus COVER_FILES); add them
to the version signature (img: parts, so v changes when one is added/removed —
no re-sync hack needed, source files); write meta.images: [{ file }] and a
manifest images count. Folders with only images now index too.

Serving: GET /image?path=<rel>&file=<img> streams the ORIGINAL image bytes from
the library folder (image/*, ETag=<v>, 304), basename + prefix-guarded against
traversal. Documented in the contract comment.

Verified: meta.images lists loose images with the cover excluded, manifest count
correct, /image path resolution + traversal guard. App side (music-api, Images
section) is the app's to add. No CACHE_VERSION bump — the sig change reindexes
exactly the folders that have images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 19:25:19 +00:00
co-authored by Claude Opus 4.8
parent c99b9c6d77
commit 0e711d281c
2 changed files with 61 additions and 8 deletions
+20 -1
View File
@@ -1,5 +1,5 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { streamAudioFile } from './stream-audio';
@@ -8,6 +8,7 @@ import { startMusicWatcher, stopMusicWatcher } from './watcher';
import {
reindexNow,
ensureCacheSetup,
MUSIC_ROOT,
getIndexStatus,
getManifest,
albumVersion,
@@ -42,6 +43,7 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// 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 /lyrics?path=<rel>&file=<track> track lyrics text (X-Lyrics-Format: lrc|txt). ETag: <v>; 304. 404 if none.
// GET /image?path=<rel>&file=<img> loose folder image bytes (image/*, the ORIGINAL). 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/…
@@ -204,6 +206,23 @@ const server = Bun.serve({
return new Response('Not found', { status: 404 });
}
// Folder image (band photo / booklet scan) — served as the ORIGINAL file from the library folder
// (no cached artifact). `path` = music-relative folder, `file` = image name (basename'd for safety).
if (url.pathname === '/image') {
const rel = url.searchParams.get('path') ?? '';
const file = basename(url.searchParams.get('file') ?? '');
if (!file) return new Response('file is required', { status: 400 });
const abs = join(MUSIC_ROOT, rel, file);
if (abs !== MUSIC_ROOT && !abs.startsWith(MUSIC_ROOT + '/')) return new Response('Invalid path', { status: 400 });
if (!(await Bun.file(abs).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 });
const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
const type =
ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
return new Response(Bun.file(abs), { headers: { 'Content-Type': type, ...(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 });
+41 -7
View File
@@ -10,8 +10,8 @@ import { homedir } from 'node:os';
//
// DATA_PATH/music/cache/<rel>/meta.json { path, cover?, tracks: [{ file, title, artist, albumArtist,
// album, track, year, durationSec, lyrics?:'lrc'|'txt' }],
// videos?: [{ file, title, durationSec, width, height, poster? }] }
// (phone IndexMeta schema)
// videos?: [{ file, title, durationSec, width, height, poster? }],
// images?: [{ file }] } (phone IndexMeta schema)
// DATA_PATH/music/cache/<rel>/cover.jpg compressed (<=600px, jpeg q5)
// DATA_PATH/music/cache/<rel>/posters/<videofile>.jpg video frame-grab thumbnails
// DATA_PATH/music/cache/<rel>/lyrics/<trackfile>.<lrc|txt> external-sidecar or embedded lyrics
@@ -31,6 +31,8 @@ const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'w
// belongs in .m4a.
const VIDEO_EXT = new Set(['mp4', 'm4v', 'mkv', 'mov', 'webm', 'avi']);
const COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png'];
// Loose folder images (band photos, booklet scans) — indexed + served as-is, EXCLUDING the cover files.
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif']);
const DISCO_FILE = '_discography.md';
const TRACK_CONCURRENCY = 6;
const COVER_MAX_PX = 600;
@@ -99,9 +101,23 @@ export type IndexVideo = {
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 IndexImage = { file: string; width?: number; height?: number }; // dims optional (v1: just file)
export type IndexMeta = {
path: string;
cover?: string;
tracks: IndexTrack[];
videos?: IndexVideo[];
images?: IndexImage[];
};
type ManifestEntry = { v: string; cover: boolean; tracks: number; videos?: number; disco?: boolean };
type ManifestEntry = {
v: string;
cover: boolean;
tracks: number;
videos?: number;
images?: number;
disco?: boolean;
};
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestEntry> };
// ── Discography (artist-level _discography.md → album folder → normalized type) ──
@@ -283,6 +299,11 @@ function isVideo(name: string): boolean {
return dot >= 0 && VIDEO_EXT.has(name.slice(dot + 1).toLowerCase());
}
function isImage(name: string): boolean {
const dot = name.lastIndexOf('.');
return dot >= 0 && IMAGE_EXT.has(name.slice(dot + 1).toLowerCase());
}
/** Bounded-concurrency map preserving order. */
async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
@@ -667,6 +688,11 @@ async function buildFolder(
// Videos (concerts/clips): the folder is always an artist or album dir, so its rel is the video's
// location. A folder with only videos still gets a meta.json.
const video = files.filter((e) => isVideo(e.name)).map((e) => e.name);
// Loose folder images (band photos, booklet scans) — everything but the cover file(s), served as-is.
const image = files
.filter((e) => isImage(e.name) && !COVER_FILES.includes(e.name))
.map((e) => e.name)
.sort();
const coverName = COVER_FILES.find((c) => files.some((e) => e.name === c));
const hasDisco = files.some((e) => e.name === DISCO_FILE);
@@ -678,7 +704,7 @@ async function buildFolder(
return m !== null && audioBases.has(m[1]!);
});
if (!(audio.length || video.length || coverName || hasDisco)) return null;
if (!(audio.length || video.length || image.length || coverName || hasDisco)) return null;
{
// Source signature → version (includes _discography.md so regenerating it bumps v — isolated from
@@ -696,6 +722,12 @@ async function buildFolder(
const st = await stat(join(dirAbs, name)).catch(() => null);
if (st) sigParts.push(`lyr:${name}:${st.size}:${Math.round(st.mtimeMs)}`);
}
// Images are source files, so this makes v change when one is added/removed (client re-syncs via the
// normal v-diff — no re-sync hack needed, unlike derived posters/embedded lyrics).
for (const name of image) {
const st = await stat(join(dirAbs, name)).catch(() => null);
if (st) sigParts.push(`img:${name}:${st.size}:${Math.round(st.mtimeMs)}`);
}
if (coverName) {
const st = await stat(join(dirAbs, coverName)).catch(() => null);
if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`);
@@ -712,7 +744,7 @@ async function buildFolder(
// was successfully cached before (prev.cover) — a source cover that can't be decoded (a junk .jpg)
// ends up cover:false and is never re-expected, so it doesn't rebuild every run.
const expected = [
audio.length || video.length ? 'meta.json' : null,
audio.length || video.length || image.length ? 'meta.json' : null,
prevEntry?.cover ? 'cover.jpg' : null,
hasDisco ? 'discography.json' : null,
].filter((f): f is string => f !== null);
@@ -731,7 +763,7 @@ async function buildFolder(
if (ok) status.coversSaved += 1;
}
if (audio.length || video.length) {
if (audio.length || video.length || image.length) {
// Regenerate lyrics cache from scratch (drop any orphaned by removed tracks/sidecars).
const lyricsDir = join(cacheDir, 'lyrics');
await rm(lyricsDir, { recursive: true, force: true }).catch(() => {});
@@ -765,6 +797,7 @@ async function buildFolder(
cover: existsSync(coverJpg) ? 'cover.jpg' : undefined,
tracks,
...(videos.length ? { videos } : {}),
...(image.length ? { images: image.map((file) => ({ file })) } : {}),
};
await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
}
@@ -788,6 +821,7 @@ async function buildFolder(
cover: existsSync(coverJpg),
tracks: audio.length,
...(video.length ? { videos: video.length } : {}),
...(image.length ? { images: image.length } : {}),
...(hasDisco ? { disco: true } : {}),
};
}