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