diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts index 3e832e00..ac6fb13b 100644 --- a/src/servers/sidecar/music/index.ts +++ b/src/servers/sidecar/music/index.ts @@ -4,6 +4,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { streamAudioFile } from './stream-audio'; import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex'; +import { startMusicWatcher, stopMusicWatcher } from './watcher'; import { reindexNow, ensureCacheSetup, @@ -78,6 +79,9 @@ await ensureCacheSetup(); // Nightly full reindex at 3am (staged + atomic swap). startNightlyReindex(); +// Recursive watcher on ~/Music → localized reindex on any change. +startMusicWatcher(); + const server = Bun.serve({ port, hostname: '127.0.0.1', @@ -272,6 +276,7 @@ const connection = createSidecarConnector({ function shutdown(signal: string) { console.log(`[music] ${signal} received, shutting down...`); stopNightlyReindex(); + stopMusicWatcher(); try { server.stop(true); } catch { diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index 3c22eb95..7bd49ddf 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -21,7 +21,7 @@ import { homedir } from 'node:os'; // phone's resync diff (fetch only changed `v`s). const HOME = process.env.HOME_DIR ?? homedir(); -const MUSIC_ROOT = join(HOME, 'Music'); +export const MUSIC_ROOT = join(HOME, 'Music'); const CACHE_ROOT = join(process.env.DATA_PATH ?? join(process.cwd(), 'data'), 'music', 'cache'); const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json'); @@ -542,36 +542,45 @@ async function runBuild(outRoot: string, prev: Manifest): Promise { // trigger a build — a manifest fetch just returns the last completed index. Route all build triggers // through reindexNow so concurrent callers join one in-flight build instead of stampeding. -let inflightBuild: Promise | null = null; +// All index-mutating ops (full, incremental, localized) run one-at-a-time through this lock, so a +// localized reindex can never race a full reindex's atomic swap (which would corrupt the manifest). +let indexLock: Promise = Promise.resolve(); +function withIndexLock(fn: () => Promise): Promise { + const result = indexLock.then(fn, fn); + indexLock = result.then( + () => {}, + () => {}, + ); + return result; +} + +/** Persist the live in-memory manifest to disk (via the cache symlink → live slot). */ +async function persistManifest(): Promise { + manifest.generatedAt = Date.now(); + await writeFile(MANIFEST_PATH, JSON.stringify(manifest)); +} /** * Incremental, live: rebuild only changed albums (unchanged ones skip by version stamp), updating the - * live cache + manifest in place. Fast — the manual reindex + localized watcher updates use this. - * Joins an in-flight build rather than starting a second. + * live cache + manifest in place. Fast — the manual reindex button uses this. */ export function reindexNow(): Promise { - if (inflightBuild) return inflightBuild; - inflightBuild = (async () => { + return withIndexLock(async () => { const next = await runBuild(CACHE_ROOT, await loadManifest()); if (!status.error) { manifest = next; manifestLoaded = true; } return getIndexStatus(); - })().finally(() => { - inflightBuild = null; }); - return inflightBuild; } /** * Full, from scratch: build a complete index into a FRESH slot without touching the live one, then swap - * it in atomically only on success — a failed rebuild leaves the live index untouched. For the nightly - * cron. Joins an in-flight build (so it won't stampede a running one). + * it in atomically only on success — a failed rebuild leaves the live index untouched. For the nightly cron. */ export function reindexFull(): Promise { - if (inflightBuild) return inflightBuild; - inflightBuild = (async () => { + return withIndexLock(async () => { await ensureCacheSetup(); const slot = slotPath(String(Date.now())); await rm(slot, { recursive: true, force: true }).catch(() => {}); @@ -587,37 +596,71 @@ export function reindexFull(): Promise { console.log('[music] full reindex swapped in as the live index'); } return getIndexStatus(); - })().finally(() => { - inflightBuild = null; }); - return inflightBuild; } -async function walk(dirAbs: string, prev: Manifest, next: Manifest, outRoot: string): Promise { - status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.'; - status.foldersScanned += 1; - emitProgress(); +/** + * Localized reindex of a single folder (the watcher's unit): rebuild just this album/artist's cache + * 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 { + 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; + } - let entries: import('node:fs').Dirent[]; - try { - entries = await readdir(dirAbs, { withFileTypes: true }); - } catch { - return; - } + 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 subdirs = entries.filter((e) => e.isDirectory()); + 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}`); + } + }); +} + +// 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 +// localized reindex. `files` are the folder's file entries (dirs excluded); `prevEntry`/`prevVersion` +// drive the skip check. +async function buildFolder( + dirAbs: string, + rel: string, + files: import('node:fs').Dirent[], + prevEntry: ManifestEntry | undefined, + prevVersion: number, + outRoot: string, +): Promise { const audio = files.filter((e) => isAudio(e.name)).map((e) => e.name); - // Videos in this folder (concerts/clips) — the folder is always an artist or album dir, so its rel is - // the video's location. Indexed alongside tracks; a folder with only videos still gets a meta.json. + // Videos (concerts/clips): the folder is always an artist or album dir, so its rel is the video's + // location. A folder with only videos still gets a meta.json. const video = files.filter((e) => isVideo(e.name)).map((e) => e.name); const coverName = COVER_FILES.find((c) => files.some((e) => e.name === c)); - const rel = relative(MUSIC_ROOT, dirAbs); // '' at root - const hasDisco = files.some((e) => e.name === DISCO_FILE); - // External lyrics sidecars (.lrc/.txt) that match a track's basename — matched to tracks in the build - // below. Only track-matching ones count (a stray notes.txt won't affect the version). + // 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) => { @@ -625,7 +668,9 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest, outRoot: str return m !== null && audioBases.has(m[1]!); }); - if (audio.length || video.length || coverName || hasDisco) { + if (!(audio.length || video.length || coverName || hasDisco)) return null; + + { // Source signature → version (includes _discography.md so regenerating it bumps v — isolated from // the album meta/cover of the tracks underneath). const sigParts: string[] = []; @@ -658,15 +703,15 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest, outRoot: str // ends up cover:false and is never re-expected, so it doesn't rebuild every run. const expected = [ audio.length || video.length ? 'meta.json' : null, - prev.albums[rel]?.cover ? 'cover.jpg' : null, + prevEntry?.cover ? 'cover.jpg' : null, hasDisco ? 'discography.json' : null, ].filter((f): f is string => f !== null); const outputsExist = expected.every((f) => existsSync(join(cacheDir, f))); // Only trust the per-album `v` skip once the cache is already at the current format — an older // CACHE_VERSION means new outputs (posters/lyrics) may be missing, so rebuild every folder once. - const formatCurrent = prev.version === CACHE_VERSION; - if (formatCurrent && prev.albums[rel]?.v === v && outputsExist) { + const formatCurrent = prevVersion === CACHE_VERSION; + if (formatCurrent && prevEntry?.v === v && outputsExist) { status.albumsSkipped += 1; } else { await mkdir(cacheDir, { recursive: true }); @@ -728,7 +773,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest, outRoot: str status.albumsBuilt += 1; } - next.albums[rel] = { + return { v, cover: existsSync(coverJpg), tracks: audio.length, @@ -736,7 +781,24 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest, outRoot: str ...(hasDisco ? { disco: true } : {}), }; } +} +async function walk(dirAbs: string, prev: Manifest, next: Manifest, outRoot: string): Promise { + status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.'; + status.foldersScanned += 1; + emitProgress(); + + let entries: import('node:fs').Dirent[]; + try { + entries = await readdir(dirAbs, { withFileTypes: true }); + } catch { + return; + } + const files = entries.filter((e) => e.isFile()); + const subdirs = entries.filter((e) => e.isDirectory()); + const rel = relative(MUSIC_ROOT, dirAbs); // '' at root + const entry = await buildFolder(dirAbs, rel, files, prev.albums[rel], prev.version, outRoot); + if (entry) next.albums[rel] = entry; for (const d of subdirs) await walk(join(dirAbs, d.name), prev, next, outRoot); } diff --git a/src/servers/sidecar/music/watcher.ts b/src/servers/sidecar/music/watcher.ts new file mode 100644 index 00000000..28bbf47d --- /dev/null +++ b/src/servers/sidecar/music/watcher.ts @@ -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 | null = null; +const pending = new Set(); // music-relative folders to reindex + +function scheduleFlush(): void { + if (flushTimer) clearTimeout(flushTimer); + flushTimer = setTimeout(() => void flush(), DEBOUNCE_MS); +} + +async function flush(): Promise { + 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(); +}