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:
@@ -639,42 +639,89 @@ export function reindexFull(): Promise<IndexStatus> {
|
||||
* entry in the LIVE cache and patch the manifest; a vanished folder is pruned. Serialized with the
|
||||
* full/incremental builds so it never races the atomic swap.
|
||||
*/
|
||||
export function reindexFolder(rel: string): Promise<void> {
|
||||
export function reindexFolder(rel: string, opts?: { recursive?: boolean }): Promise<void> {
|
||||
return withIndexLock(async () => {
|
||||
await loadManifest();
|
||||
const dirAbs = join(MUSIC_ROOT, rel);
|
||||
let entries: import('node:fs').Dirent[] | null = null;
|
||||
try {
|
||||
entries = await readdir(dirAbs, { withFileTypes: true });
|
||||
} catch {
|
||||
entries = null;
|
||||
}
|
||||
|
||||
if (!entries) {
|
||||
if (manifest.albums[rel]) {
|
||||
delete manifest.albums[rel];
|
||||
await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {});
|
||||
await persistManifest();
|
||||
console.log(`[music] watch: removed ${rel}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const files = entries.filter((e) => e.isFile());
|
||||
const entry = await buildFolder(dirAbs, rel, files, manifest.albums[rel], manifest.version, CACHE_ROOT);
|
||||
if (entry) {
|
||||
manifest.albums[rel] = entry;
|
||||
await persistManifest();
|
||||
console.log(`[music] watch: reindexed ${rel}`);
|
||||
} else if (manifest.albums[rel]) {
|
||||
delete manifest.albums[rel];
|
||||
await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {});
|
||||
await persistManifest();
|
||||
console.log(`[music] watch: cleared ${rel}`);
|
||||
}
|
||||
const changed = await reindexInto(rel, opts?.recursive ?? false);
|
||||
if (changed) await persistManifest();
|
||||
});
|
||||
}
|
||||
|
||||
// Reindex `rel` against the live manifest/cache. Always: rebuild rel's own entry and prune any manifest
|
||||
// descendants whose top-level child dir has vanished from disk (catches a renamed/removed subdir when the
|
||||
// watcher only saw the new-name or the parent event). When `recursive`, also descends into each subdir —
|
||||
// so a newly-appeared or renamed-in container's album children get indexed (a shallow reindex of a
|
||||
// container is a no-op, since containers hold no audio of their own). Returns whether the manifest changed
|
||||
// so the caller persists exactly once. Runs inside `withIndexLock` via reindexFolder.
|
||||
async function reindexInto(rel: string, recursive: boolean): Promise<boolean> {
|
||||
const dirAbs = join(MUSIC_ROOT, rel);
|
||||
let entries: import('node:fs').Dirent[] | null = null;
|
||||
try {
|
||||
entries = await readdir(dirAbs, { withFileTypes: true });
|
||||
} catch {
|
||||
entries = null;
|
||||
}
|
||||
|
||||
// Dir gone (or `rel` is a now-deleted file) → prune it and everything the manifest still has under it.
|
||||
if (!entries) return pruneSubtree(rel);
|
||||
|
||||
let changed = false;
|
||||
const files = entries.filter((e) => e.isFile());
|
||||
const subdirs = entries.filter((e) => e.isDirectory());
|
||||
|
||||
const entry = await buildFolder(dirAbs, rel, files, manifest.albums[rel], manifest.version, CACHE_ROOT);
|
||||
if (entry) {
|
||||
manifest.albums[rel] = entry;
|
||||
changed = true;
|
||||
console.log(`[music] watch: reindexed ${rel || '.'}`);
|
||||
} else if (manifest.albums[rel]) {
|
||||
delete manifest.albums[rel];
|
||||
await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {});
|
||||
changed = true;
|
||||
console.log(`[music] watch: cleared ${rel || '.'}`);
|
||||
}
|
||||
|
||||
// Prune manifest keys under `rel/` whose immediate child dir no longer exists on disk.
|
||||
const names = new Set(subdirs.map((d) => d.name));
|
||||
const prefix = rel ? rel + '/' : '';
|
||||
for (const key of Object.keys(manifest.albums)) {
|
||||
if (key === rel || !key.startsWith(prefix)) continue;
|
||||
const seg = key.slice(prefix.length).split('/')[0]!;
|
||||
if (!names.has(seg)) {
|
||||
delete manifest.albums[key];
|
||||
await rm(join(CACHE_ROOT, key), { recursive: true, force: true }).catch(() => {});
|
||||
changed = true;
|
||||
console.log(`[music] watch: pruned ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (recursive) {
|
||||
for (const d of subdirs) {
|
||||
if (await reindexInto(prefix + d.name, true)) changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Remove `rel` and every manifest entry beneath it, dropping the cache subtree in one recursive rm.
|
||||
// A no-op (returns false) when `rel` covers nothing indexed — e.g. a deleted loose file.
|
||||
async function pruneSubtree(rel: string): Promise<boolean> {
|
||||
let changed = false;
|
||||
const prefix = rel ? rel + '/' : '';
|
||||
for (const key of Object.keys(manifest.albums)) {
|
||||
if (key === rel || key.startsWith(prefix)) {
|
||||
delete manifest.albums[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {});
|
||||
console.log(`[music] watch: removed ${rel || '.'}`);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Build ONE folder's cache entry (meta / cover / posters / lyrics / discography) into `outRoot`,
|
||||
// honoring the per-folder version skip. Returns its manifest entry, or null if the folder holds nothing
|
||||
// indexable. This is the single-folder unit shared by the full/incremental walk AND the watcher's
|
||||
@@ -703,10 +750,12 @@ async function buildFolder(
|
||||
// External lyrics sidecars (.lrc/.txt) that match a track basename (a stray notes.txt won't count).
|
||||
const fileSet = new Set(files.map((e) => e.name));
|
||||
const audioBases = new Set(audio.map((n) => n.replace(/\.[^.]+$/, '')));
|
||||
const lyricsSidecars = files.map((e) => e.name).filter((n) => {
|
||||
const m = /^(.*)\.(lrc|txt)$/i.exec(n);
|
||||
return m !== null && audioBases.has(m[1]!);
|
||||
});
|
||||
const lyricsSidecars = files
|
||||
.map((e) => e.name)
|
||||
.filter((n) => {
|
||||
const m = /^(.*)\.(lrc|txt)$/i.exec(n);
|
||||
return m !== null && audioBases.has(m[1]!);
|
||||
});
|
||||
|
||||
if (!(audio.length || video.length || image.length || coverName || hasDisco)) return null;
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user