music: fresh-on-refresh reindex, resync logs, and stop the junk-cover rebuild loop
- POST /reindex now runs the build to completion before responding (via a coalescing reindexNow), and GET /manifest ensures a fresh (debounced 3s) index first — so on-disk changes show up on a plain app refresh, not only via the explicit reindex sheet. - Log each resync in the officer-music sidecar (start + one-line summary, or a failure line). - Fix albums whose cover file isn't a decodable image (junk .jpg): the manifest cover flag and the skip check now reflect whether a cover was actually cached, so they settle to cover:false instead of rebuilding every run (and the app no longer 404s fetching a cover that was never there). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
|
|||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
import {
|
import {
|
||||||
buildMusicIndex,
|
reindexNow,
|
||||||
|
ensureIndexFresh,
|
||||||
getIndexStatus,
|
getIndexStatus,
|
||||||
getManifest,
|
getManifest,
|
||||||
albumVersion,
|
albumVersion,
|
||||||
@@ -31,7 +32,8 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|||||||
//
|
//
|
||||||
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
||||||
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
||||||
// GET /manifest { version, generatedAt, albums: { "<rel>": { v, cover, tracks, disco? } } }
|
// GET /manifest ensures a fresh index (debounced rebuild) then returns
|
||||||
|
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, disco? } } }
|
||||||
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
||||||
// 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 /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
|
||||||
@@ -39,7 +41,7 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|||||||
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
|
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
|
||||||
// ETag: <v>; If-None-Match → 304. Source: each artist folder's
|
// ETag: <v>; If-None-Match → 304. Source: each artist folder's
|
||||||
// _discography.md (normalized; the md itself is never modified).
|
// _discography.md (normalized; the md itself is never modified).
|
||||||
// POST /reindex start an async build; returns IndexStatus (running: true).
|
// POST /reindex run the build to COMPLETION, then return the final IndexStatus.
|
||||||
// GET /reindex/status IndexStatus snapshot.
|
// GET /reindex/status IndexStatus snapshot.
|
||||||
// GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only).
|
// GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only).
|
||||||
// `event: progress` (IndexStatus) throttled ~200ms, then one
|
// `event: progress` (IndexStatus) throttled ~200ms, then one
|
||||||
@@ -85,8 +87,11 @@ const server = Bun.serve({
|
|||||||
// ── Index build ──
|
// ── Index build ──
|
||||||
if (url.pathname === '/reindex') {
|
if (url.pathname === '/reindex') {
|
||||||
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
||||||
void buildMusicIndex(); // fire-and-forget; sets running=true synchronously before the first await
|
// Run the resync to completion, THEN respond — so the caller's manifest read right after is fresh.
|
||||||
return json(getIndexStatus());
|
// Incremental builds are near-instant (unchanged albums skip by version stamp). reindexNow joins
|
||||||
|
// an in-flight build rather than starting a second.
|
||||||
|
const result = await reindexNow();
|
||||||
|
return json(result);
|
||||||
}
|
}
|
||||||
if (url.pathname === '/reindex/status') return json(getIndexStatus());
|
if (url.pathname === '/reindex/status') return json(getIndexStatus());
|
||||||
|
|
||||||
@@ -94,7 +99,7 @@ const server = Bun.serve({
|
|||||||
// streams `progress` events until the build finishes, ending with a `done` event carrying the report.
|
// streams `progress` events until the build finishes, ending with a `done` event carrying the report.
|
||||||
if (url.pathname === '/reindex/stream') {
|
if (url.pathname === '/reindex/stream') {
|
||||||
const trigger = url.searchParams.get('trigger') !== '0';
|
const trigger = url.searchParams.get('trigger') !== '0';
|
||||||
if (trigger && !getIndexStatus().running) void buildMusicIndex();
|
if (trigger) void reindexNow();
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -139,7 +144,12 @@ const server = Bun.serve({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync surface ──
|
// ── Sync surface ──
|
||||||
if (url.pathname === '/manifest') return json(await getManifest());
|
if (url.pathname === '/manifest') {
|
||||||
|
// Every app refresh funnels through here, so rebuild the index first (debounced) — this is what
|
||||||
|
// makes on-disk changes show up on a plain refresh, not only via the explicit reindex sheet.
|
||||||
|
await ensureIndexFresh();
|
||||||
|
return json(await getManifest());
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
|||||||
currentPath: '',
|
currentPath: '',
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
console.log('[music] resync started');
|
||||||
|
|
||||||
const prev = await loadManifest();
|
const prev = await loadManifest();
|
||||||
const next: Manifest = { version: 1, generatedAt: status.startedAt!, albums: {} };
|
const next: Manifest = { version: 1, generatedAt: status.startedAt!, albums: {} };
|
||||||
@@ -320,10 +321,52 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
|||||||
status.finishedAt = Date.now();
|
status.finishedAt = Date.now();
|
||||||
status.currentPath = '';
|
status.currentPath = '';
|
||||||
emitProgress(true); // final push — signals 'done' to SSE subscribers
|
emitProgress(true); // final push — signals 'done' to SSE subscribers
|
||||||
|
const r = buildReport();
|
||||||
|
if (r.error) {
|
||||||
|
console.error(`[music] resync failed after ${r.elapsedSec}s: ${r.error}`);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[music] resync done — ${r.built} built, ${r.skipped} skipped, ${r.coversSaved} covers, ${r.tracksIndexed} tracks, ${r.foldersScanned} folders in ${r.elapsedSec}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return getIndexStatus();
|
return getIndexStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Coalesced / debounced build entry points ──
|
||||||
|
// So reads (the manifest fetch that every app refresh funnels through) can ensure freshness without
|
||||||
|
// stampeding builds: concurrent callers join one in-flight build, and back-to-back reads within the
|
||||||
|
// debounce window skip rebuilding. Route ALL build triggers through reindexNow so there's one tracker.
|
||||||
|
|
||||||
|
let inflightBuild: Promise<IndexStatus> | null = null;
|
||||||
|
let lastBuildFinishedAt = 0;
|
||||||
|
|
||||||
|
/** Run a build, joining an in-flight one instead of starting a second; resolves when it completes. */
|
||||||
|
export function reindexNow(): Promise<IndexStatus> {
|
||||||
|
if (inflightBuild) return inflightBuild;
|
||||||
|
inflightBuild = buildMusicIndex()
|
||||||
|
.then((s) => {
|
||||||
|
lastBuildFinishedAt = Date.now();
|
||||||
|
return s;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightBuild = null;
|
||||||
|
});
|
||||||
|
return inflightBuild;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the index reflects recent on-disk changes before a read: await any in-flight build, else
|
||||||
|
* rebuild unless one finished within `debounceMs` (so a refresh that reads the manifest twice in a
|
||||||
|
* row rebuilds once, not twice). */
|
||||||
|
export async function ensureIndexFresh(debounceMs = 3000): Promise<void> {
|
||||||
|
if (inflightBuild) {
|
||||||
|
await inflightBuild;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - lastBuildFinishedAt < debounceMs) return;
|
||||||
|
await reindexNow();
|
||||||
|
}
|
||||||
|
|
||||||
async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<void> {
|
async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<void> {
|
||||||
status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
|
status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
|
||||||
status.foldersScanned += 1;
|
status.foldersScanned += 1;
|
||||||
@@ -363,11 +406,13 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
|||||||
const v = Bun.hash(sigParts.join('|')).toString(16);
|
const v = Bun.hash(sigParts.join('|')).toString(16);
|
||||||
const cacheDir = join(CACHE_ROOT, rel);
|
const cacheDir = join(CACHE_ROOT, rel);
|
||||||
|
|
||||||
// Skip only if v matches AND every expected output already exists (fixes cover-only/artist folders
|
const coverJpg = join(cacheDir, 'cover.jpg');
|
||||||
// that have no meta.json from rebuilding every run).
|
// Skip only if v matches AND every expected output already exists. Expect a cached cover ONLY if one
|
||||||
|
// 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 = [
|
const expected = [
|
||||||
audio.length ? 'meta.json' : null,
|
audio.length ? 'meta.json' : null,
|
||||||
coverName ? 'cover.jpg' : null,
|
prev.albums[rel]?.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);
|
||||||
const outputsExist = expected.every((f) => existsSync(join(cacheDir, f)));
|
const outputsExist = expected.every((f) => existsSync(join(cacheDir, f)));
|
||||||
@@ -378,7 +423,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
|||||||
await mkdir(cacheDir, { recursive: true });
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
|
||||||
if (coverName) {
|
if (coverName) {
|
||||||
const ok = await compressCover(join(dirAbs, coverName), join(cacheDir, 'cover.jpg'));
|
const ok = await compressCover(join(dirAbs, coverName), coverJpg);
|
||||||
if (ok) status.coversSaved += 1;
|
if (ok) status.coversSaved += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +434,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
|||||||
emitProgress();
|
emitProgress();
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
const meta: IndexMeta = { path: rel, cover: coverName ? 'cover.jpg' : undefined, tracks };
|
const meta: IndexMeta = { path: rel, cover: existsSync(coverJpg) ? 'cover.jpg' : undefined, tracks };
|
||||||
await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
|
await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +452,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
|||||||
status.albumsBuilt += 1;
|
status.albumsBuilt += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
next.albums[rel] = { v, cover: !!coverName, tracks: audio.length, ...(hasDisco ? { disco: true } : {}) };
|
next.albums[rel] = { v, cover: existsSync(coverJpg), tracks: audio.length, ...(hasDisco ? { disco: true } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const d of subdirs) await walk(join(dirAbs, d.name), prev, next);
|
for (const d of subdirs) await walk(join(dirAbs, d.name), prev, next);
|
||||||
|
|||||||
Reference in New Issue
Block a user