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) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 10:47:13 +00:00
co-authored by Claude Opus 5
parent 3770d7c647
commit a961d8a34f
+54
View File
@@ -645,6 +645,57 @@ export async function reindexNow(): Promise<IndexStatus> {
});
}
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<IndexStatus> {
export function reindexFull(): Promise<IndexStatus> {
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<IndexStatus> {
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;