make the localized music reindex recursive + prune stale descendants

The watcher's localized reindex was non-recursive and only ever touched the
exact folders it was handed, so renaming/moving a *container* dir (an artist
folder, or a whole subtree) left the manifest inconsistent: the renamed-in
album children were never indexed, and the old path's album keys lingered
forever (only the nightly full reindex healed it).

reindexFolder now takes { recursive } and, for any folder, also prunes any
manifest descendant whose top-level child dir has vanished from disk. The
watcher enqueues the containing folder SHALLOW (rebuild-this-album + prune a
renamed/removed-away child) and the event path itself RECURSIVE (index a
new/renamed-in container's album children). A recursive reindex of a plain
file or leaf album stays a cheap no-op / single rebuild, and a shallow reindex
of a big container (e.g. Albums) is just a readdir + key scan — no deep walk.

Verified in an isolated temp library: a case-only artist rename prunes the old
keys and indexes the 3 renamed albums while leaving unrelated albums alone; a
leaf file edit rebuilds just that album; deleting an album folder prunes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:12:15 +00:00
co-authored by Claude Opus 4.8
parent 8980cfe717
commit e559c6c884
2 changed files with 97 additions and 42 deletions
+13 -7
View File
@@ -15,7 +15,11 @@ const DEBOUNCE_MS = 3000;
let watcher: FSWatcher | null = null;
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const pending = new Set<string>(); // music-relative folders to reindex
const pending = new Map<string, boolean>(); // music-relative folder reindex recursively?
function enqueue(rel: string, recursive: boolean): void {
pending.set(rel, (pending.get(rel) ?? false) || recursive);
}
function scheduleFlush(): void {
if (flushTimer) clearTimeout(flushTimer);
@@ -27,9 +31,9 @@ async function flush(): Promise<void> {
const folders = [...pending];
pending.clear();
console.log(`[music] watch: reindexing ${folders.length} changed folder(s)`);
for (const rel of folders) {
for (const [rel, recursive] of folders) {
try {
await reindexFolder(rel);
await reindexFolder(rel, { recursive });
} catch (err) {
console.error(`[music] watch: reindex "${rel}" failed:`, err instanceof Error ? err.message : err);
}
@@ -42,11 +46,13 @@ export function startMusicWatcher(): void {
if (!filename) return;
const rel = filename.toString(); // path relative to MUSIC_ROOT (posix)
console.log(`[music] watch: ${eventType}${rel}`);
// Reindex the containing folder (for a file change) AND the path itself (in case it's a new/removed
// dir). File paths handed to reindexFolder are cheap no-ops; container folders build a null entry.
// Two enqueues: the containing folder SHALLOW — rebuilds that album on a file change, and prunes any
// renamed/removed-away child it no longer sees (e.g. an old-cased artist dir); and the path itself
// RECURSIVE — so if it's a new or renamed-in container, its album children get indexed. A recursive
// reindex of a plain file or leaf album is a cheap no-op / single-folder rebuild.
const folder = dirname(rel);
pending.add(folder === '.' ? '' : folder);
pending.add(rel);
enqueue(folder === '.' ? '' : folder, false);
enqueue(rel, true);
scheduleFlush();
});
console.log(`[music] watching ${MUSIC_ROOT} (recursive) for changes`);