diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index 213f1540..534e628d 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -639,42 +639,89 @@ export function reindexFull(): Promise { * 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 { +export function reindexFolder(rel: string, opts?: { recursive?: boolean }): Promise { 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 { + 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 { + 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; diff --git a/src/servers/sidecar/music/watcher.ts b/src/servers/sidecar/music/watcher.ts index 28bbf47d..ee982d67 100644 --- a/src/servers/sidecar/music/watcher.ts +++ b/src/servers/sidecar/music/watcher.ts @@ -15,7 +15,11 @@ const DEBOUNCE_MS = 3000; let watcher: FSWatcher | null = null; let flushTimer: ReturnType | null = null; -const pending = new Set(); // music-relative folders to reindex +const pending = new Map(); // 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 { 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`);