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:
@@ -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 {
|
||||
|
||||
@@ -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<Manifest> {
|
||||
// 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<IndexStatus> | 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<unknown> = Promise.resolve();
|
||||
function withIndexLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
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<IndexStatus> {
|
||||
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<IndexStatus> {
|
||||
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<IndexStatus> {
|
||||
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<void> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
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<ManifestEntry | null> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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