import { readdir, stat, mkdir, writeFile, readFile, rm, symlink, rename, lstat } from 'node:fs/promises'; import { existsSync, readdirSync } from 'node:fs'; import { join, relative, basename, dirname } from 'node:path'; import { homedir } from 'node:os'; // Server-side music library indexer — the platform counterpart of the app's music-index.ts. Walks the // library and builds a cache tree that MIRRORS the library layout (so it syncs cleanly), using ffprobe // for tags + duration and ffmpeg to compress covers for the phone. Output, per album folder at // (relative to the Music root): // // DATA_PATH/music/cache//meta.json { path, cover?, tracks: [{ file, title, artist, albumArtist, // album, track, disc?, year, durationSec, lyrics?:'lrc'|'txt' }], // videos?: [{ file, title, durationSec, width, height, poster? }], // images?: [{ file }] } (phone IndexMeta schema) // DATA_PATH/music/cache//cover.jpg compressed (<=600px, jpeg q5) // DATA_PATH/music/cache//posters/.jpg video frame-grab thumbnails // DATA_PATH/music/cache//lyrics/. external-sidecar or embedded lyrics // // A manifest (cache root) carries a per-album version `v` = hash of the album's source signature (track // name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the // phone's resync diff (fetch only changed `v`s). import { DATA_PATH } from '@@/data-path'; const HOME = homedir(); export const MUSIC_ROOT = join(HOME, 'Music'); const CACHE_ROOT = join(DATA_PATH, 'music', 'cache'); const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json'); const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma']); // Videos (concerts, clips) that live in an artist/album dir — all converted to phone-compatible .mp4, // but index the common containers too. NOTE: 'mp4' is a VIDEO here (moved out of AUDIO_EXT); audio-in-mp4 // belongs in .m4a. const VIDEO_EXT = new Set(['mp4', 'm4v', 'mkv', 'mov', 'webm', 'avi']); const COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png']; // Loose folder images (band photos, booklet scans) — indexed + served as-is, EXCLUDING the cover files. const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif']); const DISCO_FILE = '_discography.md'; const TRACK_CONCURRENCY = 6; const COVER_MAX_PX = 600; // 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 // per-album `v` skip only applies once the cache is already at the current format. // v1 → initial (meta + cover) v2 → + posters/ + lyrics/ v3 → + lyrics/poster COUNTS in the manifest const CACHE_VERSION = 3; // ── Staging slots + atomic symlink swap ── // The live cache path (CACHE_ROOT) is a SYMLINK to a slot dir; all readers + incremental writes follow // it. A full rebuild is built into a FRESH slot and swapped in atomically (rename over the symlink) only // on success — so the live index is never half-built and a failed rebuild leaves it untouched. const MUSIC_DIR = dirname(CACHE_ROOT); const SLOT_PREFIX = 'cache.store-'; const slotPath = (id: string) => join(MUSIC_DIR, `${SLOT_PREFIX}${id}`); /** Ensure CACHE_ROOT is a symlink to a slot dir, migrating an existing real cache dir once. Idempotent. */ export async function ensureCacheSetup(): Promise { const st = await lstat(CACHE_ROOT).catch(() => null); if (st?.isSymbolicLink()) return; // already set up const slot = slotPath('initial'); if (st?.isDirectory()) { await rm(slot, { recursive: true, force: true }).catch(() => {}); await rename(CACHE_ROOT, slot); // move the existing cache into a slot (a rename, not a copy — fast) console.log('[music] migrated cache/ into a slot for symlink swapping'); } else { await mkdir(slot, { recursive: true }); } await symlink(basename(slot), CACHE_ROOT); // relative symlink: cache -> cache.store-initial console.log(`[music] cache -> ${basename(slot)}`); } /** Atomically repoint the cache symlink to `slot`, then remove every other slot. */ async function activateSlot(slot: string): Promise { const tmp = join(MUSIC_DIR, `.cache.swap-${Date.now()}`); await rm(tmp, { force: true }).catch(() => {}); await symlink(basename(slot), tmp); // temp relative symlink → new slot await rename(tmp, CACHE_ROOT); // atomic replace of the live cache symlink for (const name of await readdir(MUSIC_DIR).catch(() => [] as string[])) { if (name.startsWith(SLOT_PREFIX) && name !== basename(slot)) { await rm(join(MUSIC_DIR, name), { recursive: true, force: true }).catch(() => {}); } } } // ── Types (meta.json matches the app's IndexMeta/IndexTrack) ── export type IndexTrack = { file: string; title?: string; artist?: string; albumArtist?: string; album?: string; track?: string; disc?: number; // disc / part-of-set number, from ID3 TPOS (the `disc` tag "n" or "n/total") year?: string; durationSec?: number; lyrics?: 'lrc' | 'txt'; // available lyrics format ('lrc' = synced/timestamped); text via /lyrics. Absent = none. }; export type IndexVideo = { file: string; title?: string; durationSec?: number; width?: number; height?: number; poster?: string; // relative to : "posters/.jpg" — a compressed frame grab, when generated }; export type IndexImage = { file: string; width?: number; height?: number }; // dims optional (v1: just file) export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[]; videos?: IndexVideo[]; images?: IndexImage[]; }; type ManifestEntry = { v: string; cover: boolean; tracks: number; videos?: number; images?: number; disco?: boolean; // How many files the last build wrote into /lyrics and /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 }; // ── Discography (artist-level _discography.md → album folder → normalized type) ── /** Canonical section for a Type cell. The source _discography.md is NEVER modified — this only cleans * the derived JSON so the phone groups into tidy sections. Unknown types pass through trimmed. */ function normalizeType(raw: string): string { const t = raw.trim().replace(/\?+$/, '').trim(); // "EP?" → "EP", "Single?" → "Single" const lower = t.toLowerCase(); if (lower.startsWith('compilation')) return 'Compilation'; // "Compilation (VA)" → "Compilation" const map: Record = { studio: 'Studio', live: 'Live', single: 'Single', ep: 'EP', soundtrack: 'Soundtrack', remix: 'Remix', 'dj-mix': 'DJ-Mix', djmix: 'DJ-Mix', demo: 'Demo', mixtape: 'Mixtape', bootleg: 'Bootleg', other: 'Other', }; return map[lower] ?? t; } /** Parse the md table into { "[year] album" (folder name) → normalized type }. */ function parseDiscography(mdText: string): Record { const albums: Record = {}; for (const line of mdText.split('\n')) { const m = line.match(/^\|\s*(\d{4})\s*\|\s*(.+?)\s*\|\s*([^|]+?)\s*\|/); if (!m) continue; // header/separator/non-rows don't match (Year isn't 4 digits) albums[`[${m[1]}] ${m[2]}`] = normalizeType(m[3]!); } return albums; } export type IndexStatus = { running: boolean; startedAt: number | null; finishedAt: number | null; foldersScanned: number; albumsBuilt: number; // rebuilt this run (changed) albumsSkipped: number; // unchanged (v matched) tracksIndexed: number; videosIndexed: number; coversSaved: number; postersSaved: number; lyricsIndexed: number; discographies: number; // artist discography.json files written currentPath: string; error: string | null; }; // ── In-memory manifest (loaded from disk on first access, updated on build) ── let manifest: Manifest = { version: 1, generatedAt: 0, albums: {} }; let manifestLoaded = false; async function loadManifest(): Promise { if (manifestLoaded) return manifest; try { manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8')) as Manifest; } catch { manifest = { version: 1, generatedAt: 0, albums: {} }; } manifestLoaded = true; return manifest; } export async function getManifest(): Promise { return loadManifest(); } /** The version stamp for an album, for ETag/diff use (null if unknown). */ export async function albumVersion(rel: string): Promise { const m = await loadManifest(); return m.albums[rel]?.v ?? null; } // ── Status ── const status: IndexStatus = { running: false, startedAt: null, finishedAt: null, foldersScanned: 0, albumsBuilt: 0, albumsSkipped: 0, tracksIndexed: 0, videosIndexed: 0, coversSaved: 0, postersSaved: 0, lyricsIndexed: 0, discographies: 0, currentPath: '', error: null, }; export function getIndexStatus(): IndexStatus { return { ...status }; } export type IndexReport = { albums: number; // albums with content (built + skipped) built: number; skipped: number; foldersScanned: number; tracksIndexed: number; videosIndexed: number; coversSaved: number; discographies: number; elapsedSec: number; error: string | null; }; export function buildReport(s: IndexStatus = status): IndexReport { const elapsed = s.startedAt && s.finishedAt ? (s.finishedAt - s.startedAt) / 1000 : 0; return { albums: s.albumsBuilt + s.albumsSkipped, built: s.albumsBuilt, skipped: s.albumsSkipped, foldersScanned: s.foldersScanned, tracksIndexed: s.tracksIndexed, videosIndexed: s.videosIndexed, coversSaved: s.coversSaved, discographies: s.discographies, elapsedSec: Math.round(elapsed * 10) / 10, error: s.error, }; } // ── Progress subscribers (for SSE) ── type ProgressListener = (s: IndexStatus) => void; const listeners = new Set(); let lastEmit = 0; let lastLog = 0; export function onIndexProgress(cb: ProgressListener): () => void { listeners.add(cb); return () => listeners.delete(cb); } /** Notify subscribers of progress; throttled to ~200ms unless `force` (e.g. terminal 'done'). */ function emitProgress(force = false): void { const now = Date.now(); // Console heartbeat (throttled to 3s, independent of the SSE cadence) so a long build shows life. if (now - lastLog >= 3000) { lastLog = now; console.log( `[music] … ${status.foldersScanned} folders · ${status.albumsBuilt} built/${status.albumsSkipped} skipped · ` + `${status.tracksIndexed} tracks · ${status.videosIndexed} videos · ${status.postersSaved} posters · ` + `${status.lyricsIndexed} lyrics — ${status.currentPath}`, ); } if (!force && now - lastEmit < 200) return; lastEmit = now; const snap = getIndexStatus(); for (const cb of listeners) { try { cb(snap); } catch { /* ignore listener errors */ } } } // ── Helpers ── function isAudio(name: string): boolean { const dot = name.lastIndexOf('.'); return dot >= 0 && AUDIO_EXT.has(name.slice(dot + 1).toLowerCase()); } function isVideo(name: string): boolean { const dot = name.lastIndexOf('.'); return dot >= 0 && VIDEO_EXT.has(name.slice(dot + 1).toLowerCase()); } function isImage(name: string): boolean { const dot = name.lastIndexOf('.'); return dot >= 0 && IMAGE_EXT.has(name.slice(dot + 1).toLowerCase()); } /** Bounded-concurrency map preserving order. */ async function mapPool(items: T[], limit: number, fn: (item: T) => Promise): Promise { const out: R[] = new Array(items.length); let next = 0; const worker = async () => { while (true) { const i = next++; if (i >= items.length) break; out[i] = await fn(items[i]!); } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) || 0 }, worker)); return out; } async function ffprobeTrack(absPath: string, file: string): Promise<{ track: IndexTrack; lyricsText?: string }> { try { const proc = Bun.spawn( [ 'ffprobe', '-v', 'error', '-print_format', 'json', // All format tags — so embedded lyrics (key varies: lyrics, lyrics-, unsyncedlyrics) come through. '-show_entries', 'format=duration:format_tags', absPath, ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = await new Response(proc.stdout).text(); await proc.exited; const data = JSON.parse(out) as { format?: { duration?: string; tags?: Record } }; const fmt = data.format ?? {}; // Tag keys vary in case across containers — normalize to lowercase. const tags: Record = {}; for (const [k, val] of Object.entries(fmt.tags ?? {})) tags[k.toLowerCase()] = val; const durNum = fmt.duration ? Math.round(parseFloat(fmt.duration)) : undefined; const yearMatch = tags.date?.match(/\d{4}/); // Disc / part-of-set from ID3 TPOS (ffprobe surfaces it as `disc`, "n" or "n/total"). const discNum = tags.disc ? parseInt(tags.disc.split('/')[0]!.trim(), 10) : NaN; // Embedded lyrics: USLT/foobar keys — `lyrics`, `lyrics-`, `unsyncedlyrics`. let lyricsText: string | undefined; for (const [k, val] of Object.entries(tags)) { if (/^(lyrics|unsyncedlyrics)($|[-_])/.test(k) && val && val.trim()) { lyricsText = val; break; } } return { track: { file, title: tags.title || undefined, artist: tags.artist || undefined, albumArtist: tags.album_artist || tags.albumartist || undefined, album: tags.album || undefined, track: tags.track || undefined, disc: Number.isFinite(discNum) ? discNum : undefined, year: yearMatch ? yearMatch[0] : undefined, durationSec: Number.isFinite(durNum) ? durNum : undefined, }, lyricsText, }; } catch { return { track: { file } }; } } async function ffprobeVideo(absPath: string, file: string): Promise { try { const proc = Bun.spawn( [ 'ffprobe', '-v', 'error', '-print_format', 'json', '-show_entries', 'format=duration:format_tags=title:stream=codec_type,width,height', absPath, ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = await new Response(proc.stdout).text(); await proc.exited; const data = JSON.parse(out) as { format?: { duration?: string; tags?: Record }; streams?: Array<{ codec_type?: string; width?: number; height?: number }>; }; const fmt = data.format ?? {}; const tags: Record = {}; for (const [k, val] of Object.entries(fmt.tags ?? {})) tags[k.toLowerCase()] = val; const vstream = (data.streams ?? []).find((s) => s.codec_type === 'video'); const durNum = fmt.duration ? Math.round(parseFloat(fmt.duration)) : undefined; return { file, title: tags.title || undefined, durationSec: Number.isFinite(durNum) ? durNum : undefined, width: vstream?.width, height: vstream?.height, }; } catch { return { file }; } } /** Compress a cover to <=COVER_MAX_PX (no upscaling), jpeg q5. Returns true on success. */ async function compressCover(srcAbs: string, destAbs: string): Promise { try { const proc = Bun.spawn( [ 'ffmpeg', '-y', '-i', srcAbs, '-vf', `scale='min(iw,${COVER_MAX_PX})':'min(ih,${COVER_MAX_PX})':force_original_aspect_ratio=decrease`, '-q:v', '5', destAbs, ], { stdout: 'ignore', stderr: 'ignore' }, ); await proc.exited; return proc.exitCode === 0 && existsSync(destAbs); } catch { return false; } } /** * Poster = a representative frame, compressed like a cover. Seek ~10% in (capped at 30s) to skip * intros/title cards, then let ffmpeg's `thumbnail` filter pick the most representative frame from the * next ~300 (avoids black/uniform frames — e.g. a fade-in from black). */ async function generateVideoPoster(srcAbs: string, destAbs: string, durationSec?: number): Promise { const ts = durationSec && durationSec > 0 ? Math.min(30, Math.max(1, Math.floor(durationSec * 0.1))) : 5; try { const proc = Bun.spawn( [ 'ffmpeg', '-y', '-ss', String(ts), '-i', srcAbs, '-vf', `thumbnail=n=300,scale='min(iw,${COVER_MAX_PX})':'min(ih,${COVER_MAX_PX})':force_original_aspect_ratio=decrease`, '-frames:v', '1', '-q:v', '5', destAbs, ], { stdout: 'ignore', stderr: 'ignore' }, ); await proc.exited; return proc.exitCode === 0 && existsSync(destAbs); } catch { return false; } } // ── Lyrics ── const LRC_TIMESTAMP_RE = /^\s*\[\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]/m; const isLrc = (text: string): boolean => LRC_TIMESTAMP_RE.test(text); /** * Resolve a track's lyrics and cache them at cache//lyrics/.. Returns the * cached format, or null if none. Content (embedded or a .txt) that actually contains [mm:ss] lines is * treated as synced (`lrc`). * * Precedence — the file's OWN embedded lyrics are canonical, and SYNCED always beats plain: * synced-embedded > synced-sidecar(.lrc) > plain-embedded > plain-sidecar(.txt) * So an mp3 whose synced lyrics are embedded (as LRC text in the USLT/`lyrics` tag) wins over leftover * sidecars, but a track that only has a *plain* embed still serves a synced `.lrc` until it's re-embedded * — no silent downgrade. (Binary SYLT frames aren't readable here; sync must live in the text tag.) */ async function resolveTrackLyrics( dirAbs: string, lyricsDir: string, trackFile: string, folderFiles: Set, embedded: string | undefined, ): Promise<'lrc' | 'txt' | null> { const base = trackFile.replace(/\.[^.]+$/, ''); type Candidate = { text: string; fmt: 'lrc' | 'txt'; embedded: boolean }; const candidates: Candidate[] = []; if (embedded?.trim()) candidates.push({ text: embedded, fmt: isLrc(embedded) ? 'lrc' : 'txt', embedded: true }); for (const name of [`${base}.lrc`, `${base}.txt`]) { if (!folderFiles.has(name)) continue; const text = await readFile(join(dirAbs, name), 'utf8').catch(() => null); if (text?.trim()) candidates.push({ text, fmt: isLrc(text) ? 'lrc' : 'txt', embedded: false }); } if (!candidates.length) return null; // Rank: synced (0) before plain (2); within a tier, the file's own embedded copy (0) before a sidecar (1). const rank = (c: Candidate) => (c.fmt === 'lrc' ? 0 : 2) + (c.embedded ? 0 : 1); candidates.sort((a, b) => rank(a) - rank(b)); const best = candidates[0]!; await mkdir(lyricsDir, { recursive: true }); await writeFile(join(lyricsDir, `${trackFile}.${best.fmt}`), best.text); return best.fmt; } // ── Build ── // Core build: writes a full or incremental index into `outRoot` (+ its manifest.json), then returns the // built manifest. Does NOT touch the live serving state — the caller decides when/if it goes live. // `prev` drives the incremental skip: a populated prev skips unchanged albums (in-place live build); an // empty prev rebuilds everything (into a fresh staging slot for a full reindex). async function runBuild(outRoot: string, prev: Manifest): Promise { Object.assign(status, { running: true, startedAt: Date.now(), finishedAt: null, foldersScanned: 0, albumsBuilt: 0, albumsSkipped: 0, tracksIndexed: 0, videosIndexed: 0, coversSaved: 0, postersSaved: 0, lyricsIndexed: 0, discographies: 0, currentPath: '', error: null, }); console.log('[music] resync started'); const next: Manifest = { version: CACHE_VERSION, generatedAt: status.startedAt!, albums: {} }; try { await mkdir(outRoot, { recursive: true }); walkFailures = 0; 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). for (const rel of Object.keys(prev.albums)) { if (!next.albums[rel]) { await rm(join(outRoot, rel), { recursive: true, force: true }).catch(() => {}); } } await writeFile(join(outRoot, 'manifest.json'), JSON.stringify(next)); } catch (err) { status.error = err instanceof Error ? err.message : String(err); } finally { status.running = false; status.finishedAt = Date.now(); status.currentPath = ''; emitProgress(true); // final push — signals 'done' to SSE subscribers const r = buildReport(); if (r.error) { console.error(`[music] resync failed after ${r.elapsedSec}s: ${r.error}`); } else { console.log( `[music] resync done — ${r.built} built, ${r.skipped} skipped, ${r.coversSaved} covers, ${r.tracksIndexed} tracks, ${r.videosIndexed} videos, ${status.lyricsIndexed} lyrics, ${r.foldersScanned} folders in ${r.elapsedSec}s`, ); } } return next; } // ── Coalesced build entry point ── // Builds are EXPLICIT only (POST /reindex, the SSE stream). Reads (/manifest, /meta, /cover, …) never // 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. // 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(); // 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(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 button uses this. The one exception is a * cache-format upgrade, which is delegated to the staged full rebuild below. */ export async function reindexNow(): Promise { // 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 () => { const next = await runBuild(CACHE_ROOT, await loadManifest()); if (!status.error) { manifest = next; manifestLoaded = true; } return getIndexStatus(); }); } type ManifestDelta = { added: string[]; removed: string[]; changed: string[] }; // Compare a from-scratch build against the index that was already live. This exists to answer one // question with evidence rather than opinion: is the nightly full still FINDING anything, or is it // spending 40 minutes of disk rebuilding what the incremental had already got right? // // A run that reports no differences did no useful work. A run of those is the case for retiring the // nightly; a delta that keeps reappearing names the albums to go and look at. // // Compared field-by-field rather than by JSON.stringify, because two entries built by the same code // can serialise with different key order (the optional fields are spread conditionally) and that would // report drift where there is none. function diffManifest(prev: Manifest, next: Manifest): ManifestDelta { const canon = (e: ManifestEntry) => [e.v, e.cover, e.tracks, e.videos ?? 0, e.images ?? 0, e.disco ?? false, e.lyrics ?? 0, e.posters ?? 0].join('|'); const added: string[] = []; const changed: string[] = []; for (const [rel, entry] of Object.entries(next.albums)) { const before = prev.albums[rel]; if (!before) added.push(rel); else if (canon(before) !== canon(entry)) changed.push(rel); } const removed = Object.keys(prev.albums).filter((rel) => !next.albums[rel]); return { added, removed, changed }; } function logManifestDelta(prev: Manifest, next: Manifest): void { // A cache-format upgrade rebuilds every album by definition, so the delta is expected and says // nothing about drift. Label it rather than let it read as 6k albums of rot. if (prev.version !== next.version) { console.log( `[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`, ); } const { added, removed, changed } = diffManifest(prev, next); const total = added.length + removed.length + changed.length; if (total === 0) { console.log('[music] full reindex delta: NONE — the incremental index was already identical'); return; } console.log( `[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`, ); const sample = (label: string, rels: string[]) => { for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`); if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`); }; sample('+', added); sample('-', removed); sample('~', changed); } /** * 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. */ export function reindexFull(): Promise { return withIndexLock(async () => { await ensureCacheSetup(); // Read the live index BEFORE the swap — it is what the delta below is measured against. const live = await loadManifest(); const slot = slotPath(String(Date.now())); await rm(slot, { recursive: true, force: true }).catch(() => {}); // Empty prev ⇒ everything rebuilds into the fresh slot. const next = await runBuild(slot, { version: CACHE_VERSION, generatedAt: 0, albums: {} }); if (status.error) { await rm(slot, { recursive: true, force: true }).catch(() => {}); console.error('[music] full reindex failed — live index left untouched'); } else { logManifestDelta(live, next); await activateSlot(slot); manifest = next; manifestLoaded = true; console.log('[music] full reindex swapped in as the live index'); } return getIndexStatus(); }); } /** * Localized reindex of a single folder: 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. * * 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 { return withIndexLock(async () => { await loadManifest(); const changed = await reindexInto(rel, opts?.recursive ?? false); if (changed) await persistManifest(); }); } // 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 // 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 // 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. async function reindexInto(rel: string, recursive: boolean): Promise { const dirAbs = join(MUSIC_ROOT, rel); let entries: import('node:fs').Dirent[] | null = null; try { entries = await readdir(dirAbs, { withFileTypes: true }); } catch { entries = null; } // Dir gone (or `rel` is a now-deleted file) → prune it and everything the manifest still has under it. if (!entries) return pruneSubtree(rel); let changed = false; const files = entries.filter((e) => e.isFile()); const subdirs = entries.filter((e) => e.isDirectory()); const entry = await buildFolder(dirAbs, rel, files, manifest.albums[rel], manifest.version, CACHE_ROOT); if (entry) { manifest.albums[rel] = entry; changed = true; 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(() => {}); changed = true; console.log(`[music] watch: cleared ${rel || '.'}`); } // Prune manifest keys under `rel/` whose immediate child dir no longer exists on disk. const names = new Set(subdirs.map((d) => d.name)); const prefix = rel ? rel + '/' : ''; for (const key of Object.keys(manifest.albums)) { if (key === rel || !key.startsWith(prefix)) continue; const seg = key.slice(prefix.length).split('/')[0]!; if (!names.has(seg)) { delete manifest.albums[key]; await rm(join(CACHE_ROOT, key), { recursive: true, force: true }).catch(() => {}); changed = true; console.log(`[music] watch: pruned ${key}`); } } if (recursive) { for (const d of subdirs) { if (await reindexInto(prefix + d.name, true)) changed = true; } } return changed; } // Remove `rel` and every manifest entry beneath it, dropping the cache subtree in one recursive rm. // A no-op (returns false) when `rel` covers nothing indexed — e.g. a deleted loose file. async function pruneSubtree(rel: string): Promise { let changed = false; const prefix = rel ? rel + '/' : ''; for (const key of Object.keys(manifest.albums)) { if (key === rel || key.startsWith(prefix)) { delete manifest.albums[key]; changed = true; } } if (changed) { await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {}); console.log(`[music] watch: removed ${rel || '.'}`); } return changed; } // 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 `reindexFolder`'s // localized rebuild. `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 (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); // Loose folder images (band photos, booklet scans) — everything but the cover file(s), served as-is. const image = files .filter((e) => isImage(e.name) && !COVER_FILES.includes(e.name)) .map((e) => e.name) .sort(); const coverName = COVER_FILES.find((c) => files.some((e) => e.name === c)); const hasDisco = files.some((e) => e.name === DISCO_FILE); // 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) => { const m = /^(.*)\.(lrc|txt)$/i.exec(n); return m !== null && audioBases.has(m[1]!); }); if (!(audio.length || video.length || image.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[] = []; for (const name of [...audio].sort()) { const st = await stat(join(dirAbs, name)).catch(() => null); if (st) sigParts.push(`${name}:${st.size}:${Math.round(st.mtimeMs)}`); } for (const name of [...video].sort()) { const st = await stat(join(dirAbs, name)).catch(() => null); if (st) sigParts.push(`vid:${name}:${st.size}:${Math.round(st.mtimeMs)}`); } for (const name of [...lyricsSidecars].sort()) { const st = await stat(join(dirAbs, name)).catch(() => null); if (st) sigParts.push(`lyr:${name}:${st.size}:${Math.round(st.mtimeMs)}`); } // Images are source files, so this makes v change when one is added/removed (client re-syncs via the // normal v-diff — no re-sync hack needed, unlike derived posters/embedded lyrics). for (const name of image) { const st = await stat(join(dirAbs, name)).catch(() => null); if (st) sigParts.push(`img:${name}:${st.size}:${Math.round(st.mtimeMs)}`); } if (coverName) { const st = await stat(join(dirAbs, coverName)).catch(() => null); if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`); } if (hasDisco) { const st = await stat(join(dirAbs, DISCO_FILE)).catch(() => null); if (st) sigParts.push(`disco:${st.size}:${Math.round(st.mtimeMs)}`); } const v = Bun.hash(sigParts.join('|')).toString(16); const cacheDir = join(outRoot, rel); const coverJpg = join(cacheDir, 'cover.jpg'); // Skip only if v matches AND every expected output already exists. Expect a cached cover ONLY if one // was successfully cached before (prev.cover) — a source cover that can't be decoded (a junk .jpg) // ends up cover:false and is never re-expected, so it doesn't rebuild every run. const expected = [ audio.length || video.length || image.length ? 'meta.json' : null, prevEntry?.cover ? 'cover.jpg' : null, hasDisco ? 'discography.json' : null, ].filter((f): f is string => f !== null); // 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 // CACHE_VERSION means new outputs (posters/lyrics) may be missing, so rebuild every folder once. 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) { status.albumsSkipped += 1; } else { await mkdir(cacheDir, { recursive: true }); // Clear any prior cached cover first, then regenerate from the current source (if any). Otherwise a // removed OR now-undecodable source cover leaves the old cover.jpg behind — which keeps being served // and reported as cover:true (the "deleted the folder image but it still shows" bug). await rm(coverJpg, { force: true }).catch(() => {}); if (coverName) { const ok = await compressCover(join(dirAbs, coverName), coverJpg); if (ok) status.coversSaved += 1; } if (audio.length || video.length || image.length) { // Regenerate lyrics cache from scratch (drop any orphaned by removed tracks/sidecars). const lyricsDir = join(cacheDir, 'lyrics'); await rm(lyricsDir, { recursive: true, force: true }).catch(() => {}); const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => { const { track: t, lyricsText } = await ffprobeTrack(join(dirAbs, name), name); const fmt = await resolveTrackLyrics(dirAbs, lyricsDir, name, fileSet, lyricsText); if (fmt) { t.lyrics = fmt; status.lyricsIndexed += 1; } status.tracksIndexed += 1; emitProgress(); return t; }); // Regenerate posters from scratch (drop any orphaned by removed videos). const postersDir = join(cacheDir, 'posters'); await rm(postersDir, { recursive: true, force: true }).catch(() => {}); if (video.length) await mkdir(postersDir, { recursive: true }); const videos = await mapPool(video, TRACK_CONCURRENCY, async (name) => { const vmeta = await ffprobeVideo(join(dirAbs, name), name); if (await generateVideoPoster(join(dirAbs, name), join(postersDir, `${name}.jpg`), vmeta.durationSec)) { vmeta.poster = `posters/${name}.jpg`; status.postersSaved += 1; } status.videosIndexed += 1; emitProgress(); return vmeta; }); // Both dirs were just rm'd and rebuilt, and each writer emits exactly one file per item it // reports (resolveTrackLyrics → `.`, the poster pass → `posters/