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:
2026-07-26 23:13:17 +00:00
co-authored by Claude Opus 4.8
parent d1e19d1473
commit bff11bc86d
2 changed files with 68 additions and 13 deletions
+51 -6
View File
@@ -295,6 +295,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
currentPath: '',
error: null,
});
console.log('[music] resync started');
const prev = await loadManifest();
const next: Manifest = { version: 1, generatedAt: status.startedAt!, albums: {} };
@@ -320,10 +321,52 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
status.finishedAt = Date.now();
status.currentPath = '';
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();
}
// ── 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> {
status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
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 cacheDir = join(CACHE_ROOT, rel);
// 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 coverJpg = join(cacheDir, 'cover.jpg');
// 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 = [
audio.length ? 'meta.json' : null,
coverName ? 'cover.jpg' : null,
prev.albums[rel]?.cover ? 'cover.jpg' : null,
hasDisco ? 'discography.json' : null,
].filter((f): f is string => f !== null);
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 });
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;
}
@@ -389,7 +434,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
emitProgress();
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));
}
@@ -407,7 +452,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
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);