From a961d8a34fd60e869e944b207fb8b6337bfaf5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 10:47:13 +0000 Subject: [PATCH] music: report what the nightly full actually found The nightly full rebuilds every album by definition, so "it completed" says nothing about whether it is still needed. Diff the from-scratch build against the index that was already live, just before the slot swaps in, and log the delta: albums added, removed, and entries whose contents changed. A run reporting NONE did no useful work. A string of those is the evidence for retiring the nightly; a delta that keeps coming back names the albums to go and look at instead of guessing. Entries are compared field by field rather than by JSON.stringify: the optional fields are spread conditionally, so two entries built by the same code can serialise with different key order, and stringify would report every album as changed every night. A cache-format upgrade is labelled, since that rebuilds everything legitimately and would otherwise read as total rot. Co-Authored-By: Claude Opus 5 (1M context) --- src/servers/sidecar/music/indexer.ts | 54 ++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index ab398eb3..93655ec1 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -645,6 +645,57 @@ export async function reindexNow(): Promise { }); } +type ManifestDelta = { added: string[]; removed: string[]; changed: string[] }; + +// Compare a from-scratch build against the index that was already live. This exists to answer one +// question with evidence rather than opinion: is the nightly full still FINDING anything, or is it +// spending 40 minutes of disk rebuilding what the incremental had already got right? +// +// A run that reports no differences did no useful work. A run of those is the case for retiring the +// nightly; a delta that keeps reappearing names the albums to go and look at. +// +// Compared field-by-field rather than by JSON.stringify, because two entries built by the same code +// can serialise with different key order (the optional fields are spread conditionally) and that would +// report drift where there is none. +function diffManifest(prev: Manifest, next: Manifest): ManifestDelta { + const canon = (e: ManifestEntry) => + [e.v, e.cover, e.tracks, e.videos ?? 0, e.images ?? 0, e.disco ?? false, e.lyrics ?? 0, e.posters ?? 0].join('|'); + + const added: string[] = []; + const changed: string[] = []; + for (const [rel, entry] of Object.entries(next.albums)) { + const before = prev.albums[rel]; + if (!before) added.push(rel); + else if (canon(before) !== canon(entry)) changed.push(rel); + } + const removed = Object.keys(prev.albums).filter((rel) => !next.albums[rel]); + return { added, removed, changed }; +} + +function logManifestDelta(prev: Manifest, next: Manifest): void { + // A cache-format upgrade rebuilds every album by definition, so the delta is expected and says + // nothing about drift. Label it rather than let it read as 6k albums of rot. + if (prev.version !== next.version) { + console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`); + } + + const { added, removed, changed } = diffManifest(prev, next); + const total = added.length + removed.length + changed.length; + if (total === 0) { + console.log('[music] full reindex delta: NONE — the incremental index was already identical'); + return; + } + + console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`); + const sample = (label: string, rels: string[]) => { + for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`); + if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`); + }; + sample('+', added); + sample('-', removed); + sample('~', changed); +} + /** * Full, from scratch: build a complete index into a FRESH slot without touching the live one, then swap * it in atomically only on success — a failed rebuild leaves the live index untouched. For the nightly cron. @@ -652,6 +703,8 @@ export async function reindexNow(): Promise { export function reindexFull(): Promise { return withIndexLock(async () => { await ensureCacheSetup(); + // Read the live index BEFORE the swap — it is what the delta below is measured against. + const live = await loadManifest(); const slot = slotPath(String(Date.now())); await rm(slot, { recursive: true, force: true }).catch(() => {}); // Empty prev ⇒ everything rebuilds into the fresh slot. @@ -660,6 +713,7 @@ export function reindexFull(): Promise { await rm(slot, { recursive: true, force: true }).catch(() => {}); console.error('[music] full reindex failed — live index left untouched'); } else { + logManifestDelta(live, next); await activateSlot(slot); manifest = next; manifestLoaded = true;