music: recursive watcher → localized reindex on any change
Extract walk()'s per-folder logic into a reusable buildFolder(), so a single
album/artist can be (re)indexed on its own. Add:
- reindexFolder(rel): rebuild just one folder's live cache entry + patch the
manifest (prune if the folder vanished). Its mtime-based signature means any
change — add / re-tag / delete — is picked up, and irrelevant touches no-op.
- withIndexLock: serialize ALL index mutations (full / incremental / localized)
so a localized reindex can never race the full reindex's atomic swap.
- watcher.ts: fs.watch(~/Music, { recursive }) → log every change → debounce 3s →
reindexFolder the affected folder(s). Verified: Bun's recursive watch fires
through the ~/Music symlink and on new-dir creation (so a new album's contents
are read by reindexFolder); inotify max_user_watches (~483k) >> folder count.
Started at boot, stopped on shutdown. The nightly full reindex backstops any
change that lands after a new folder's debounce.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
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 Set<string>(); // music-relative folders to reindex
|
||||
|
||||
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 of folders) {
|
||||
try {
|
||||
await reindexFolder(rel);
|
||||
} 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}`);
|
||||
// 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.
|
||||
const folder = dirname(rel);
|
||||
pending.add(folder === '.' ? '' : folder);
|
||||
pending.add(rel);
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user