music: drop the fs watcher, and let the incremental reindex self-heal
Bun's recursive fs.watch takes one inotify watch per ENTRY, files included — ~92k for this library against a 65536 ceiling — so the watch could never be established. The ENOSPC came back asynchronously as an FSWatcher 'error' event with no listener, which rethrew and killed the sidecar 17k times, draining the per-UID watch pool for every other process on the machine along the way. Reindexing is triggered instead (the browser button, the phone's pull-to-refresh, the nightly full); an incremental over 6273 folders measures 1.8s. Three index defects the nightly full had been papering over: - outputsExist verified meta.json/cover.jpg/discography.json but neither lyrics/ nor posters/, so a lost lyrics file kept a matching v and a passing check and the album was skipped on every incremental forever — only a full restored it. Record both counts in the manifest and compare them (CACHE_VERSION 2 -> 3). - walk() read a failed readdir as "the folder is gone", and runBuild prunes whatever is missing from next — so one transient EIO on the library disk deleted that folder and its whole subtree from the index. Carry the previous entries forward for every error but ENOENT/ENOTDIR. - a from-scratch build has no previous entries to carry, so it now refuses to publish a slot when any folder was unreadable, leaving the live index alone. A disk that hiccups during the nightly costs a skipped night, not a hole. reindexNow builds in place, so a cache-format upgrade is handed to the staged path rather than rewriting 6k albums underneath live readers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import { streamAudioFile } from './stream-audio';
|
|||||||
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
||||||
import { ensurePulseAudio } from './pulse-audio';
|
import { ensurePulseAudio } from './pulse-audio';
|
||||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||||
import { startMusicWatcher, stopMusicWatcher } from './watcher';
|
|
||||||
import {
|
import {
|
||||||
reindexNow,
|
reindexNow,
|
||||||
reindexFull,
|
reindexFull,
|
||||||
@@ -136,8 +135,14 @@ await ensureCacheSetup();
|
|||||||
// Nightly full reindex at 3am (staged + atomic swap).
|
// Nightly full reindex at 3am (staged + atomic swap).
|
||||||
startNightlyReindex();
|
startNightlyReindex();
|
||||||
|
|
||||||
// Recursive watcher on ~/Music → localized reindex on any change.
|
// No filesystem watcher on ~/Music. Bun's recursive fs.watch costs one inotify watch per ENTRY, files
|
||||||
startMusicWatcher();
|
// included — ~92k for this library against a 65536 ceiling — so it could never establish, and the
|
||||||
|
// ENOSPC came back asynchronously as an unhandled 'error' event that killed this whole sidecar 17k
|
||||||
|
// times over. It also drained the per-UID watch pool, starving every other watcher on the machine.
|
||||||
|
// Reindexing is triggered instead: the ↻ button in the music browser (POST /reindex, incremental) and
|
||||||
|
// the nightly full rebuild above. `reindexFolder` in the indexer is retained and currently unused — it
|
||||||
|
// is the targeted hook for whatever writes to ~/Music (slskd, transmission, download-media) to declare
|
||||||
|
// the one folder it just wrote, which is the cheap version of what the watcher was guessing at.
|
||||||
|
|
||||||
// PulseAudio daemon + the `virtual_out` null sink both cliamp halves depend on. Officer used to do this at
|
// PulseAudio daemon + the `virtual_out` null sink both cliamp halves depend on. Officer used to do this at
|
||||||
// its own boot, which meant every restart of a process with no audio responsibilities re-checked the sink.
|
// its own boot, which meant every restart of a process with no audio responsibilities re-checked the sink.
|
||||||
@@ -487,7 +492,6 @@ const connection = createSidecarConnector({
|
|||||||
function shutdown(signal: string) {
|
function shutdown(signal: string) {
|
||||||
console.log(`[music] ${signal} received, shutting down...`);
|
console.log(`[music] ${signal} received, shutting down...`);
|
||||||
stopNightlyReindex();
|
stopNightlyReindex();
|
||||||
stopMusicWatcher();
|
|
||||||
try {
|
try {
|
||||||
server.stop(true);
|
server.stop(true);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readdir, stat, mkdir, writeFile, readFile, rm, symlink, rename, lstat } from 'node:fs/promises';
|
import { readdir, stat, mkdir, writeFile, readFile, rm, symlink, rename, lstat } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
import { join, relative, basename, dirname } from 'node:path';
|
import { join, relative, basename, dirname } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ const COVER_MAX_PX = 600;
|
|||||||
// Cache-format version. Bump when the build produces NEW per-album outputs (so far: v2 added video
|
// Cache-format version. Bump when the build produces NEW per-album outputs (so far: v2 added video
|
||||||
// posters + lyrics). A manifest written by an older CACHE_VERSION forces a one-time FULL rebuild — the
|
// posters + lyrics). A manifest written by an older CACHE_VERSION forces a one-time FULL rebuild — the
|
||||||
// per-album `v` skip only applies once the cache is already at the current format.
|
// per-album `v` skip only applies once the cache is already at the current format.
|
||||||
// v1 → initial (meta + cover) v2 → + posters/ + lyrics/
|
// v1 → initial (meta + cover) v2 → + posters/ + lyrics/ v3 → + lyrics/poster COUNTS in the manifest
|
||||||
const CACHE_VERSION = 2;
|
const CACHE_VERSION = 3;
|
||||||
|
|
||||||
// ── Staging slots + atomic symlink swap ──
|
// ── Staging slots + atomic symlink swap ──
|
||||||
// The live cache path (CACHE_ROOT) is a SYMLINK to a slot dir; all readers + incremental writes follow
|
// The live cache path (CACHE_ROOT) is a SYMLINK to a slot dir; all readers + incremental writes follow
|
||||||
@@ -118,6 +118,12 @@ type ManifestEntry = {
|
|||||||
videos?: number;
|
videos?: number;
|
||||||
images?: number;
|
images?: number;
|
||||||
disco?: boolean;
|
disco?: boolean;
|
||||||
|
// How many files the last build wrote into <rel>/lyrics and <rel>/posters. Recorded ONLY so the
|
||||||
|
// incremental skip can notice one has gone missing — see `outputsExist` in buildFolder. Without them
|
||||||
|
// the integrity check could verify meta.json/cover.jpg/discography.json and nothing else, so a lost
|
||||||
|
// lyrics file or poster left the album skipped forever and only a full rebuild restored it.
|
||||||
|
lyrics?: number;
|
||||||
|
posters?: number;
|
||||||
};
|
};
|
||||||
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestEntry> };
|
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestEntry> };
|
||||||
|
|
||||||
@@ -543,8 +549,20 @@ async function runBuild(outRoot: string, prev: Manifest): Promise<Manifest> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await mkdir(outRoot, { recursive: true });
|
await mkdir(outRoot, { recursive: true });
|
||||||
|
walkFailures = 0;
|
||||||
await walk(MUSIC_ROOT, prev, next, outRoot);
|
await walk(MUSIC_ROOT, prev, next, outRoot);
|
||||||
|
|
||||||
|
// An incremental build survives an unreadable folder by carrying the previous entries forward (see
|
||||||
|
// walk). A from-scratch build has no previous entries to carry — every unreadable folder is simply
|
||||||
|
// absent from `next`, and publishing that slot would delete those albums from the live index for
|
||||||
|
// real. Refuse instead: reindexFull discards a slot whose build errored and leaves the live index
|
||||||
|
// alone, so a disk that hiccups during the nightly costs one skipped night, not a hole in the library.
|
||||||
|
if (walkFailures > 0 && !Object.keys(prev.albums).length) {
|
||||||
|
throw new Error(
|
||||||
|
`${walkFailures} folder(s) unreadable during a from-scratch build — refusing to publish a partial index`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prune cache dirs for albums that vanished from the library (only meaningful when prev is populated).
|
// Prune cache dirs for albums that vanished from the library (only meaningful when prev is populated).
|
||||||
for (const rel of Object.keys(prev.albums)) {
|
for (const rel of Object.keys(prev.albums)) {
|
||||||
if (!next.albums[rel]) {
|
if (!next.albums[rel]) {
|
||||||
@@ -580,6 +598,10 @@ async function runBuild(outRoot: string, prev: Manifest): Promise<Manifest> {
|
|||||||
// All index-mutating ops (full, incremental, localized) run one-at-a-time through this lock, so a
|
// 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).
|
// localized reindex can never race a full reindex's atomic swap (which would corrupt the manifest).
|
||||||
let indexLock: Promise<unknown> = Promise.resolve();
|
let indexLock: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
// Folders the current build could not read for a reason other than "it is gone". Reset at the start of
|
||||||
|
// every runBuild; consulted at the end to decide whether a from-scratch index is safe to publish.
|
||||||
|
let walkFailures = 0;
|
||||||
function withIndexLock<T>(fn: () => Promise<T>): Promise<T> {
|
function withIndexLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
const result = indexLock.then(fn, fn);
|
const result = indexLock.then(fn, fn);
|
||||||
indexLock = result.then(
|
indexLock = result.then(
|
||||||
@@ -597,9 +619,22 @@ async function persistManifest(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Incremental, live: rebuild only changed albums (unchanged ones skip by version stamp), updating the
|
* Incremental, live: rebuild only changed albums (unchanged ones skip by version stamp), updating the
|
||||||
* live cache + manifest in place. Fast — the manual reindex button uses this.
|
* live cache + manifest in place. Fast — the manual reindex button uses this. The one exception is a
|
||||||
|
* cache-format upgrade, which is delegated to the staged full rebuild below.
|
||||||
*/
|
*/
|
||||||
export function reindexNow(): Promise<IndexStatus> {
|
export async function reindexNow(): Promise<IndexStatus> {
|
||||||
|
// A cache written by an older CACHE_VERSION forces every album to rebuild (see `formatCurrent` in
|
||||||
|
// buildFolder). Doing that through THIS path would rewrite all ~6k albums inside the LIVE cache, each
|
||||||
|
// one rm'd and then regenerated, so for the ~40 minutes it runs a reader can hit an album whose files
|
||||||
|
// are momentarily missing. The staged path does identical work into a fresh slot and swaps atomically,
|
||||||
|
// so hand the format upgrade to it. Checked before withIndexLock — that lock is a plain promise chain
|
||||||
|
// and is not reentrant, so calling reindexFull() inside it would deadlock.
|
||||||
|
const current = await loadManifest();
|
||||||
|
if (current.version !== CACHE_VERSION) {
|
||||||
|
console.log(`[music] cache format v${current.version} → v${CACHE_VERSION}: routing to a staged rebuild`);
|
||||||
|
return reindexFull();
|
||||||
|
}
|
||||||
|
|
||||||
return withIndexLock(async () => {
|
return withIndexLock(async () => {
|
||||||
const next = await runBuild(CACHE_ROOT, await loadManifest());
|
const next = await runBuild(CACHE_ROOT, await loadManifest());
|
||||||
if (!status.error) {
|
if (!status.error) {
|
||||||
@@ -635,9 +670,15 @@ export function reindexFull(): Promise<IndexStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Localized reindex of a single folder (the watcher's unit): rebuild just this album/artist's cache
|
* Localized reindex of a single folder: rebuild just this album/artist's cache entry in the LIVE cache
|
||||||
* entry in the LIVE cache and patch the manifest; a vanished folder is pruned. Serialized with the
|
* and patch the manifest; a vanished folder is pruned. Serialized with the full/incremental builds so
|
||||||
* full/incremental builds so it never races the atomic swap.
|
* it never races the atomic swap.
|
||||||
|
*
|
||||||
|
* CURRENTLY UNUSED. It was the filesystem watcher's unit of work until that was removed (see the note
|
||||||
|
* in index.ts — the recursive watch could not fit in the inotify budget). Kept because it is the right
|
||||||
|
* hook for a writer that already knows what it wrote: slskd, transmission or the download-media task
|
||||||
|
* calling this with the one folder it just created is strictly cheaper and more accurate than either
|
||||||
|
* watching 92k inodes or walking the whole tree.
|
||||||
*/
|
*/
|
||||||
export function reindexFolder(rel: string, opts?: { recursive?: boolean }): Promise<void> {
|
export function reindexFolder(rel: string, opts?: { recursive?: boolean }): Promise<void> {
|
||||||
return withIndexLock(async () => {
|
return withIndexLock(async () => {
|
||||||
@@ -649,7 +690,7 @@ export function reindexFolder(rel: string, opts?: { recursive?: boolean }): Prom
|
|||||||
|
|
||||||
// Reindex `rel` against the live manifest/cache. Always: rebuild rel's own entry and prune any manifest
|
// 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
|
// 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 —
|
// caller only knows the new name, or only the parent). 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
|
// 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
|
// 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.
|
// so the caller persists exactly once. Runs inside `withIndexLock` via reindexFolder.
|
||||||
@@ -724,8 +765,8 @@ async function pruneSubtree(rel: string): Promise<boolean> {
|
|||||||
|
|
||||||
// Build ONE folder's cache entry (meta / cover / posters / lyrics / discography) into `outRoot`,
|
// 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
|
// 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
|
// indexable. This is the single-folder unit shared by the full/incremental walk AND `reindexFolder`'s
|
||||||
// localized reindex. `files` are the folder's file entries (dirs excluded); `prevEntry`/`prevVersion`
|
// localized rebuild. `files` are the folder's file entries (dirs excluded); `prevEntry`/`prevVersion`
|
||||||
// drive the skip check.
|
// drive the skip check.
|
||||||
async function buildFolder(
|
async function buildFolder(
|
||||||
dirAbs: string,
|
dirAbs: string,
|
||||||
@@ -801,11 +842,30 @@ async function buildFolder(
|
|||||||
prevEntry?.cover ? 'cover.jpg' : null,
|
prevEntry?.cover ? 'cover.jpg' : null,
|
||||||
hasDisco ? 'discography.json' : null,
|
hasDisco ? 'discography.json' : null,
|
||||||
].filter((f): f is string => f !== null);
|
].filter((f): f is string => f !== null);
|
||||||
const outputsExist = expected.every((f) => existsSync(join(cacheDir, f)));
|
// A build writes FIVE kinds of output; the three above are single files, lyrics/ and posters/ are
|
||||||
|
// directories of them. Checking only the files meant a lost lyrics file or poster kept a matching `v`
|
||||||
|
// and a passing existence check, so the album was skipped on every incremental forever and only the
|
||||||
|
// nightly full restored it — the "inconsistencies the normal reindex misses". Compare counts instead.
|
||||||
|
// `>=` deliberately: a missing output must rebuild, a stray extra one need not.
|
||||||
|
const cachedFileCount = (name: string): number => {
|
||||||
|
try {
|
||||||
|
return readdirSync(join(cacheDir, name)).length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const outputsExist =
|
||||||
|
expected.every((f) => existsSync(join(cacheDir, f))) &&
|
||||||
|
cachedFileCount('lyrics') >= (prevEntry?.lyrics ?? 0) &&
|
||||||
|
cachedFileCount('posters') >= (prevEntry?.posters ?? 0);
|
||||||
|
|
||||||
// Only trust the per-album `v` skip once the cache is already at the current format — an older
|
// 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.
|
// CACHE_VERSION means new outputs (posters/lyrics) may be missing, so rebuild every folder once.
|
||||||
const formatCurrent = prevVersion === CACHE_VERSION;
|
const formatCurrent = prevVersion === CACHE_VERSION;
|
||||||
|
// Carried forward untouched on a skip (nothing was rewritten, so the previous counts still describe
|
||||||
|
// what is on disk); recomputed from what the build actually wrote below.
|
||||||
|
let lyricsWritten = prevEntry?.lyrics ?? 0;
|
||||||
|
let postersWritten = prevEntry?.posters ?? 0;
|
||||||
if (formatCurrent && prevEntry?.v === v && outputsExist) {
|
if (formatCurrent && prevEntry?.v === v && outputsExist) {
|
||||||
status.albumsSkipped += 1;
|
status.albumsSkipped += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -849,6 +909,11 @@ async function buildFolder(
|
|||||||
emitProgress();
|
emitProgress();
|
||||||
return vmeta;
|
return vmeta;
|
||||||
});
|
});
|
||||||
|
// Both dirs were just rm'd and rebuilt, and each writer emits exactly one file per item it
|
||||||
|
// reports (resolveTrackLyrics → `<track>.<fmt>`, the poster pass → `posters/<video>.jpg`), so
|
||||||
|
// these counts are the on-disk file counts `outputsExist` will check against next run.
|
||||||
|
lyricsWritten = tracks.filter((t) => t.lyrics).length;
|
||||||
|
postersWritten = videos.filter((vm) => vm.poster).length;
|
||||||
const meta: IndexMeta = {
|
const meta: IndexMeta = {
|
||||||
path: rel,
|
path: rel,
|
||||||
cover: existsSync(coverJpg) ? 'cover.jpg' : undefined,
|
cover: existsSync(coverJpg) ? 'cover.jpg' : undefined,
|
||||||
@@ -880,6 +945,8 @@ async function buildFolder(
|
|||||||
...(video.length ? { videos: video.length } : {}),
|
...(video.length ? { videos: video.length } : {}),
|
||||||
...(image.length ? { images: image.length } : {}),
|
...(image.length ? { images: image.length } : {}),
|
||||||
...(hasDisco ? { disco: true } : {}),
|
...(hasDisco ? { disco: true } : {}),
|
||||||
|
...(lyricsWritten ? { lyrics: lyricsWritten } : {}),
|
||||||
|
...(postersWritten ? { posters: postersWritten } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -892,7 +959,28 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest, outRoot: str
|
|||||||
let entries: import('node:fs').Dirent[];
|
let entries: import('node:fs').Dirent[];
|
||||||
try {
|
try {
|
||||||
entries = await readdir(dirAbs, { withFileTypes: true });
|
entries = await readdir(dirAbs, { withFileTypes: true });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// "Gone" and "could not look" are NOT the same thing, and conflating them lost albums. Anything that
|
||||||
|
// never enters `next` is pruned by runBuild — cache dir deleted, manifest key dropped — so a single
|
||||||
|
// transient EIO on the library disk silently removed that folder AND its whole subtree from the
|
||||||
|
// index. ENOENT/ENOTDIR really do mean deleted, so those still fall through to the prune. For every
|
||||||
|
// other error keep what the last good build knew: an unreadable folder is left exactly as it was.
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'ENOENT' || code === 'ENOTDIR') return;
|
||||||
|
|
||||||
|
walkFailures += 1;
|
||||||
|
const relFailed = relative(MUSIC_ROOT, dirAbs);
|
||||||
|
// '' at the root, whose prefix matches every key — if the Music root itself is unreadable the whole
|
||||||
|
// previous manifest carries over, which is the only safe reading of "the disk did not answer".
|
||||||
|
const prefix = relFailed ? `${relFailed}/` : '';
|
||||||
|
let kept = 0;
|
||||||
|
for (const [key, entry] of Object.entries(prev.albums)) {
|
||||||
|
if (key === relFailed || key.startsWith(prefix)) {
|
||||||
|
next.albums[key] = entry;
|
||||||
|
kept += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(`[music] could not read ${relFailed || '.'} (${code}) — kept ${kept} previous entrie(s)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const files = entries.filter((e) => e.isFile());
|
const files = entries.filter((e) => e.isFile());
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user