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 { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join, basename } from 'node:path';
|
||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
@@ -8,6 +8,7 @@ import { startMusicWatcher, stopMusicWatcher } from './watcher';
|
|||||||
import {
|
import {
|
||||||
reindexNow,
|
reindexNow,
|
||||||
ensureCacheSetup,
|
ensureCacheSetup,
|
||||||
|
MUSIC_ROOT,
|
||||||
getIndexStatus,
|
getIndexStatus,
|
||||||
getManifest,
|
getManifest,
|
||||||
albumVersion,
|
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 /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 /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 /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
|
// 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/…
|
||||||
@@ -204,6 +206,23 @@ const server = Bun.serve({
|
|||||||
return new Response('Not found', { status: 404 });
|
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') {
|
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 });
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { homedir } from 'node:os';
|
|||||||
//
|
//
|
||||||
// DATA_PATH/music/cache/<rel>/meta.json { path, cover?, tracks: [{ file, title, artist, albumArtist,
|
// DATA_PATH/music/cache/<rel>/meta.json { path, cover?, tracks: [{ file, title, artist, albumArtist,
|
||||||
// album, track, year, durationSec, lyrics?:'lrc'|'txt' }],
|
// album, track, year, durationSec, lyrics?:'lrc'|'txt' }],
|
||||||
// videos?: [{ file, title, durationSec, width, height, poster? }] }
|
// videos?: [{ file, title, durationSec, width, height, poster? }],
|
||||||
// (phone IndexMeta schema)
|
// images?: [{ file }] } (phone IndexMeta schema)
|
||||||
// DATA_PATH/music/cache/<rel>/cover.jpg compressed (<=600px, jpeg q5)
|
// 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>/posters/<videofile>.jpg video frame-grab thumbnails
|
||||||
// DATA_PATH/music/cache/<rel>/lyrics/<trackfile>.<lrc|txt> external-sidecar or embedded lyrics
|
// 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.
|
// belongs in .m4a.
|
||||||
const VIDEO_EXT = new Set(['mp4', 'm4v', 'mkv', 'mov', 'webm', 'avi']);
|
const VIDEO_EXT = new Set(['mp4', 'm4v', 'mkv', 'mov', 'webm', 'avi']);
|
||||||
const COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png'];
|
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 DISCO_FILE = '_discography.md';
|
||||||
const TRACK_CONCURRENCY = 6;
|
const TRACK_CONCURRENCY = 6;
|
||||||
const COVER_MAX_PX = 600;
|
const COVER_MAX_PX = 600;
|
||||||
@@ -99,9 +101,23 @@ export type IndexVideo = {
|
|||||||
height?: number;
|
height?: number;
|
||||||
poster?: string; // relative to <rel>: "posters/<file>.jpg" — a compressed frame grab, when generated
|
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> };
|
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestEntry> };
|
||||||
|
|
||||||
// ── Discography (artist-level _discography.md → album folder → normalized type) ──
|
// ── 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());
|
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. */
|
/** Bounded-concurrency map preserving order. */
|
||||||
async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
const out: R[] = new Array(items.length);
|
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
|
// 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.
|
// location. A folder with only videos still gets a meta.json.
|
||||||
const video = files.filter((e) => isVideo(e.name)).map((e) => e.name);
|
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 coverName = COVER_FILES.find((c) => files.some((e) => e.name === c));
|
||||||
const hasDisco = files.some((e) => e.name === DISCO_FILE);
|
const hasDisco = files.some((e) => e.name === DISCO_FILE);
|
||||||
|
|
||||||
@@ -678,7 +704,7 @@ async function buildFolder(
|
|||||||
return m !== null && audioBases.has(m[1]!);
|
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
|
// 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);
|
const st = await stat(join(dirAbs, name)).catch(() => null);
|
||||||
if (st) sigParts.push(`lyr:${name}:${st.size}:${Math.round(st.mtimeMs)}`);
|
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) {
|
if (coverName) {
|
||||||
const st = await stat(join(dirAbs, coverName)).catch(() => null);
|
const st = await stat(join(dirAbs, coverName)).catch(() => null);
|
||||||
if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`);
|
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)
|
// 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.
|
// ends up cover:false and is never re-expected, so it doesn't rebuild every run.
|
||||||
const expected = [
|
const expected = [
|
||||||
audio.length || video.length ? 'meta.json' : null,
|
audio.length || video.length || image.length ? 'meta.json' : null,
|
||||||
prevEntry?.cover ? 'cover.jpg' : null,
|
prevEntry?.cover ? 'cover.jpg' : null,
|
||||||
hasDisco ? 'discography.json' : null,
|
hasDisco ? 'discography.json' : null,
|
||||||
].filter((f): f is string => f !== null);
|
].filter((f): f is string => f !== null);
|
||||||
@@ -731,7 +763,7 @@ async function buildFolder(
|
|||||||
if (ok) status.coversSaved += 1;
|
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).
|
// Regenerate lyrics cache from scratch (drop any orphaned by removed tracks/sidecars).
|
||||||
const lyricsDir = join(cacheDir, 'lyrics');
|
const lyricsDir = join(cacheDir, 'lyrics');
|
||||||
await rm(lyricsDir, { recursive: true, force: true }).catch(() => {});
|
await rm(lyricsDir, { recursive: true, force: true }).catch(() => {});
|
||||||
@@ -765,6 +797,7 @@ async function buildFolder(
|
|||||||
cover: existsSync(coverJpg) ? 'cover.jpg' : undefined,
|
cover: existsSync(coverJpg) ? 'cover.jpg' : undefined,
|
||||||
tracks,
|
tracks,
|
||||||
...(videos.length ? { videos } : {}),
|
...(videos.length ? { videos } : {}),
|
||||||
|
...(image.length ? { images: image.map((file) => ({ file })) } : {}),
|
||||||
};
|
};
|
||||||
await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
|
await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
|
||||||
}
|
}
|
||||||
@@ -788,6 +821,7 @@ async function buildFolder(
|
|||||||
cover: existsSync(coverJpg),
|
cover: existsSync(coverJpg),
|
||||||
tracks: audio.length,
|
tracks: audio.length,
|
||||||
...(video.length ? { videos: video.length } : {}),
|
...(video.length ? { videos: video.length } : {}),
|
||||||
|
...(image.length ? { images: image.length } : {}),
|
||||||
...(hasDisco ? { disco: true } : {}),
|
...(hasDisco ? { disco: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user