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:
@@ -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 });
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user