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>
75 lines
3.0 KiB
TypeScript
75 lines
3.0 KiB
TypeScript
import { watch, type FSWatcher } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
import { MUSIC_ROOT, reindexFolder } from './indexer';
|
|
|
|
// Recursive file watcher on ~/Music (chokidar-style). Any change — add / modify (mtime delta) / delete —
|
|
// is logged and, after a short debounce, triggers a LOCALIZED reindex of just the affected folder(s)
|
|
// (reindexFolder recomputes that album/artist's cache entry; its mtime-based signature decides whether a
|
|
// rebuild is actually needed, so irrelevant touches are cheap no-ops). Full correctness is backstopped by
|
|
// the nightly full reindex — a new folder whose files land after the debounce is caught by that.
|
|
//
|
|
// Notes: max_user_watches is ~483k here (>> the folder count), and Bun's recursive watch fires through
|
|
// the ~/Music symlink and for new-dir creation (so reindexFolder reads the new folder's contents).
|
|
|
|
const DEBOUNCE_MS = 3000;
|
|
|
|
let watcher: FSWatcher | null = null;
|
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
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);
|
|
flushTimer = setTimeout(() => void flush(), DEBOUNCE_MS);
|
|
}
|
|
|
|
async function flush(): Promise<void> {
|
|
flushTimer = null;
|
|
const folders = [...pending];
|
|
pending.clear();
|
|
console.log(`[music] watch: reindexing ${folders.length} changed folder(s)`);
|
|
for (const [rel, recursive] of folders) {
|
|
try {
|
|
await reindexFolder(rel, { recursive });
|
|
} catch (err) {
|
|
console.error(`[music] watch: reindex "${rel}" failed:`, err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function startMusicWatcher(): void {
|
|
try {
|
|
watcher = watch(MUSIC_ROOT, { recursive: true, persistent: true }, (eventType, filename) => {
|
|
if (!filename) return;
|
|
const rel = filename.toString(); // path relative to MUSIC_ROOT (posix)
|
|
console.log(`[music] watch: ${eventType} — ${rel}`);
|
|
// 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);
|
|
enqueue(folder === '.' ? '' : folder, false);
|
|
enqueue(rel, true);
|
|
scheduleFlush();
|
|
});
|
|
console.log(`[music] watching ${MUSIC_ROOT} (recursive) for changes`);
|
|
} catch (err) {
|
|
console.error('[music] failed to start file watcher:', err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
export function stopMusicWatcher(): void {
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
if (watcher) {
|
|
watcher.close();
|
|
watcher = null;
|
|
}
|
|
pending.clear();
|
|
}
|