From 946da85e4c6e69b4fb46482dbfdeb71f084ba037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 26 Jul 2026 09:39:07 +0000 Subject: [PATCH] =?UTF-8?q?music:=20index=20artist=20discographies=20(albu?= =?UTF-8?q?m=20=E2=86=92=20release=20type)=20for=20the=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Albums//_discography.md (author-maintained source of truth, never modified) is compiled into a per-artist discography.json in the cache = album folder → normalized release type (Studio/Live/Compilation/Single/EP/…), so the player can split an artist's album list into sections. - indexer.ts: parse the md table, normalize the Type (EP?→EP, Compilation (VA)→ Compilation, …), write discography.json. The artist folder's `v` now includes _discography.md so regenerating it re-syncs just that small JSON (isolated from the albums' meta/cover). Manifest gains `disco: true` on such entries. Also fixed the skip check to require all expected outputs to exist, so artist/ cover-only folders no longer rebuild every run. New `discographies` counter. - sidecar: GET /discography?path= (ETag/304), documented in the contract header. - MUSIC_API.md: §2.4 + manifest disco flag + resync algorithm updated. Co-Authored-By: Claude Opus 4.8 --- MUSIC_API.md | 43 ++++++++++++-- src/servers/sidecar/music/index.ts | 28 +++++---- src/servers/sidecar/music/indexer.ts | 86 +++++++++++++++++++++++++--- 3 files changed, 134 insertions(+), 23 deletions(-) diff --git a/MUSIC_API.md b/MUSIC_API.md index cd1e19be..e9f70c32 100644 --- a/MUSIC_API.md +++ b/MUSIC_API.md @@ -64,12 +64,15 @@ GET /api/music/manifest "generatedAt": 1785034701973, // ms; when the index was last built "albums": { "Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 }, - "DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 } + "DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 }, + "Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true } // … } } ``` -`404` if the index has never been built (see §3). +`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an +**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its +grouping via `/discography` (§2.4). ### 2.2 Album metadata @@ -107,6 +110,36 @@ GET /api/music/cover?path= Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: `; `If-None-Match: ` → `304`. Only meaningful when the manifest entry has `"cover": true`. +### 2.4 Discography (artist album grouping) + +For artist folders (manifest entry with `"disco": true`), this returns a map of **album folder → release +type**, so the player can split an artist's album list into sections (Studio, Live, Compilation, Single, EP…). + +``` +GET /api/music/discography?path= e.g. path=Albums/AC-DC +``` +Sends `ETag: `; `If-None-Match: ` → `304`. +```jsonc +{ + "artist": "Anthrax", + "albums": { + "[1984] Fistful Of Metal": "Studio", + "[1985] Armed And Dangerous": "EP", + "[1994] The Island Years": "Live", + "[1991] Attack Of The Killer B's": "Compilation" + // … + } +} +``` +- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the + last path segment of that album's manifest ``. Group the artist's albums by looking each up here. +- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`, + `DJ-Mix`, `Demo`, `Mixtape`, `Bootleg`, `Other` (unknown values pass through as-is). The player defines + section order. +- An album folder **not present** here has no classification → put it in an "Other"/uncategorized section. +- Source of truth is each artist's `_discography.md` (author-maintained); this JSON is derived from it and + re-generated whenever that file changes (its `v` bumps independently of the albums' `meta`/`cover`). + --- ## 3. Building / refreshing the index @@ -127,7 +160,7 @@ GET /api/music/reindex/status → IndexStatus snapshot "running": true, "startedAt": 1785034701973, "finishedAt": null, "foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3, - "tracksIndexed": 320, "coversSaved": 12, + "tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "currentPath": "Albums/AC-DC/[1980] Back in Black", "error": null } @@ -153,7 +186,7 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32 `IndexReport` (the `done` payload): ```jsonc { "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45, - "tracksIndexed": 320, "coversSaved": 12, "elapsedSec": 37.2, "error": null } + "tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null } ``` > First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`). @@ -167,7 +200,7 @@ Keep the last `manifest.albums` you synced. On resync: 1. `GET /api/music/manifest`. 2. For each `` in the new manifest: - **new**, or **`v` differs** from your stored copy → fetch `GET /meta?path=` (+ `GET /cover?path=` - if `cover:true`); store them under your local `/`. + if `cover:true`, + `GET /discography?path=` if `disco:true`); store them under your local `/`. - **`v` unchanged** → **skip** (no download). 3. For each `` you have locally that's **absent** from the new manifest → delete it. 4. Save the new manifest as your baseline. diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts index f053d6c4..409f76d7 100644 --- a/src/servers/sidecar/music/index.ts +++ b/src/servers/sidecar/music/index.ts @@ -10,6 +10,7 @@ import { albumVersion, metaFilePath, coverFilePath, + discographyFilePath, onIndexProgress, buildReport, } from './indexer'; @@ -30,9 +31,14 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); // // GET /stream?path= audio with Range→206 (Content-Range/Length/Accept-Ranges) // + `X-Audio-Duration` (seconds, ffprobe). 400/404/416. -// GET /manifest { version, generatedAt, albums: { "": { v, cover, tracks } } } +// GET /manifest { version, generatedAt, albums: { "": { v, cover, tracks, disco? } } } // GET /meta?path= album meta.json (IndexMeta). ETag: ; If-None-Match → 304. // GET /cover?path= compressed cover.jpg. ETag: ; If-None-Match → 304. +// GET /discography?path= artist discography.json (only where manifest entry has +// disco:true) = { artist, albums: { "<[year] album folder>": +// "" } }, Type ∈ Studio/Live/Compilation/Single/EP/… +// ETag: ; If-None-Match → 304. Source: each artist folder's +// _discography.md (normalized; the md itself is never modified). // POST /reindex start an async build; returns IndexStatus (running: true). // GET /reindex/status IndexStatus snapshot. // GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only). @@ -135,21 +141,21 @@ const server = Bun.serve({ // ── Sync surface ── if (url.pathname === '/manifest') return json(await getManifest()); - if (url.pathname === '/meta' || url.pathname === '/cover') { + 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 }); - const isMeta = url.pathname === '/meta'; - const file = isMeta ? metaFilePath(rel) : coverFilePath(rel); - if (!file) return new Response('Invalid path', { status: 400 }); - if (!(await Bun.file(file).exists())) return new Response('Not found', { status: 404 }); + const spec = { + '/meta': { file: metaFilePath(rel), type: 'application/json' }, + '/cover': { file: coverFilePath(rel), type: 'image/jpeg' }, + '/discography': { file: discographyFilePath(rel), type: 'application/json' }, + }[url.pathname]!; + if (!spec.file) return new Response('Invalid path', { status: 400 }); + if (!(await Bun.file(spec.file).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(file), { - headers: { - 'Content-Type': isMeta ? 'application/json' : 'image/jpeg', - ...(v ? { ETag: v } : {}), - }, + return new Response(Bun.file(spec.file), { + headers: { 'Content-Type': spec.type, ...(v ? { ETag: v } : {}) }, }); } diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index ec437a8a..6f6d611c 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -1,6 +1,6 @@ import { readdir, stat, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, basename } from 'node:path'; import { homedir } from 'node:os'; // Server-side music library indexer — the platform counterpart of the app's music-index.ts. Walks the @@ -23,6 +23,7 @@ const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json'); const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']); const COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png']; +const DISCO_FILE = '_discography.md'; const TRACK_CONCURRENCY = 6; const COVER_MAX_PX = 600; @@ -40,9 +41,45 @@ export type IndexTrack = { }; export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[] }; -type ManifestEntry = { v: string; cover: boolean; tracks: number }; +type ManifestEntry = { v: string; cover: boolean; tracks: number; disco?: boolean }; type Manifest = { version: number; generatedAt: number; albums: Record }; +// ── Discography (artist-level _discography.md → album folder → normalized type) ── + +/** Canonical section for a Type cell. The source _discography.md is NEVER modified — this only cleans + * the derived JSON so the phone groups into tidy sections. Unknown types pass through trimmed. */ +function normalizeType(raw: string): string { + const t = raw.trim().replace(/\?+$/, '').trim(); // "EP?" → "EP", "Single?" → "Single" + const lower = t.toLowerCase(); + if (lower.startsWith('compilation')) return 'Compilation'; // "Compilation (VA)" → "Compilation" + const map: Record = { + studio: 'Studio', + live: 'Live', + single: 'Single', + ep: 'EP', + soundtrack: 'Soundtrack', + remix: 'Remix', + 'dj-mix': 'DJ-Mix', + djmix: 'DJ-Mix', + demo: 'Demo', + mixtape: 'Mixtape', + bootleg: 'Bootleg', + other: 'Other', + }; + return map[lower] ?? t; +} + +/** Parse the md table into { "[year] album" (folder name) → normalized type }. */ +function parseDiscography(mdText: string): Record { + const albums: Record = {}; + for (const line of mdText.split('\n')) { + const m = line.match(/^\|\s*(\d{4})\s*\|\s*(.+?)\s*\|\s*([^|]+?)\s*\|/); + if (!m) continue; // header/separator/non-rows don't match (Year isn't 4 digits) + albums[`[${m[1]}] ${m[2]}`] = normalizeType(m[3]!); + } + return albums; +} + export type IndexStatus = { running: boolean; startedAt: number | null; @@ -52,6 +89,7 @@ export type IndexStatus = { albumsSkipped: number; // unchanged (v matched) tracksIndexed: number; coversSaved: number; + discographies: number; // artist discography.json files written currentPath: string; error: string | null; }; @@ -93,6 +131,7 @@ const status: IndexStatus = { albumsSkipped: 0, tracksIndexed: 0, coversSaved: 0, + discographies: 0, currentPath: '', error: null, }; @@ -108,6 +147,7 @@ export type IndexReport = { foldersScanned: number; tracksIndexed: number; coversSaved: number; + discographies: number; elapsedSec: number; error: string | null; }; @@ -121,6 +161,7 @@ export function buildReport(s: IndexStatus = status): IndexReport { foldersScanned: s.foldersScanned, tracksIndexed: s.tracksIndexed, coversSaved: s.coversSaved, + discographies: s.discographies, elapsedSec: Math.round(elapsed * 10) / 10, error: s.error, }; @@ -250,6 +291,7 @@ export async function buildMusicIndex(): Promise { albumsSkipped: 0, tracksIndexed: 0, coversSaved: 0, + discographies: 0, currentPath: '', error: null, }); @@ -300,8 +342,11 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise files.some((e) => e.name === c)); const rel = relative(MUSIC_ROOT, dirAbs); // '' at root - if (audio.length || coverName) { - // Source signature → version. + const hasDisco = files.some((e) => e.name === DISCO_FILE); + + if (audio.length || coverName || hasDisco) { + // Source signature → version (includes _discography.md so regenerating it bumps v — isolated from + // the album meta/cover of the tracks underneath). const sigParts: string[] = []; for (const name of [...audio].sort()) { const st = await stat(join(dirAbs, name)).catch(() => null); @@ -311,11 +356,23 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise null); if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`); } + if (hasDisco) { + const st = await stat(join(dirAbs, DISCO_FILE)).catch(() => null); + if (st) sigParts.push(`disco:${st.size}:${Math.round(st.mtimeMs)}`); + } const v = Bun.hash(sigParts.join('|')).toString(16); const cacheDir = join(CACHE_ROOT, rel); - const metaExists = existsSync(join(cacheDir, 'meta.json')); - if (prev.albums[rel]?.v === v && metaExists) { + // Skip only if v matches AND every expected output already exists (fixes cover-only/artist folders + // that have no meta.json from rebuilding every run). + const expected = [ + audio.length ? 'meta.json' : null, + coverName ? 'cover.jpg' : null, + hasDisco ? 'discography.json' : null, + ].filter((f): f is string => f !== null); + const outputsExist = expected.every((f) => existsSync(join(cacheDir, f))); + + if (prev.albums[rel]?.v === v && outputsExist) { status.albumsSkipped += 1; } else { await mkdir(cacheDir, { recursive: true }); @@ -335,10 +392,22 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise