import { readdir, stat, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, relative, basename } 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, year, durationSec, lyrics?:'lrc'|'txt' }], // videos?: [{ file, title, durationSec, width, height, poster? }] } // (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). const HOME = process.env.HOME_DIR ?? homedir(); 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'); 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']; const DISCO_FILE = '_discography.md'; const TRACK_CONCURRENCY = 6; const COVER_MAX_PX = 600; // ── Types (meta.json matches the app's IndexMeta/IndexTrack) ── export type IndexTrack = { file: string; title?: string; artist?: string; albumArtist?: string; album?: string; track?: string; 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 IndexMeta = { path: string; cover?: string; tracks: IndexTrack[]; videos?: IndexVideo[] }; type ManifestEntry = { v: string; cover: boolean; tracks: number; videos?: number; disco?: boolean }; 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; 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(); 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()); } /** 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}/); // 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, 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; } } /** Grab a representative frame (~10% in, capped at 30s to skip intros) and compress it like a cover. */ 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, '-frames:v', '1', '-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; } } // ── 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/.. Precedence: * external ".lrc" > external ".txt" > embedded (from the audio tags). Returns the cached * format, or null if none. `.txt`/embedded content that actually contains [mm:ss] lines is stored as .lrc. */ async function resolveTrackLyrics( dirAbs: string, lyricsDir: string, trackFile: string, folderFiles: Set, embedded: string | undefined, ): Promise<'lrc' | 'txt' | null> { const base = trackFile.replace(/\.[^.]+$/, ''); const write = async (fmt: 'lrc' | 'txt', text: string) => { await mkdir(lyricsDir, { recursive: true }); await writeFile(join(lyricsDir, `${trackFile}.${fmt}`), text); return fmt; }; if (folderFiles.has(`${base}.lrc`)) { const text = await readFile(join(dirAbs, `${base}.lrc`), 'utf8').catch(() => null); if (text?.trim()) return write('lrc', text); } if (folderFiles.has(`${base}.txt`)) { const text = await readFile(join(dirAbs, `${base}.txt`), 'utf8').catch(() => null); if (text?.trim()) return write(isLrc(text) ? 'lrc' : 'txt', text); } if (embedded?.trim()) return write(isLrc(embedded) ? 'lrc' : 'txt', embedded); return null; } // ── Build ── export async function buildMusicIndex(): Promise { if (status.running) return getIndexStatus(); 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 prev = await loadManifest(); const next: Manifest = { version: 1, generatedAt: status.startedAt!, albums: {} }; try { await mkdir(CACHE_ROOT, { recursive: true }); await walk(MUSIC_ROOT, prev, next); // Prune cache dirs for albums that vanished from the library. for (const rel of Object.keys(prev.albums)) { if (!next.albums[rel]) { await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {}); } } await writeFile(MANIFEST_PATH, JSON.stringify(next)); manifest = next; manifestLoaded = true; } 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 getIndexStatus(); } // ── Coalesced / debounced build entry points ── // So reads (the manifest fetch that every app refresh funnels through) can ensure freshness without // stampeding builds: concurrent callers join one in-flight build, and back-to-back reads within the // debounce window skip rebuilding. Route ALL build triggers through reindexNow so there's one tracker. let inflightBuild: Promise | null = null; let lastBuildFinishedAt = 0; /** Run a build, joining an in-flight one instead of starting a second; resolves when it completes. */ export function reindexNow(): Promise { if (inflightBuild) return inflightBuild; inflightBuild = buildMusicIndex() .then((s) => { lastBuildFinishedAt = Date.now(); return s; }) .finally(() => { inflightBuild = null; }); return inflightBuild; } /** Ensure the index reflects recent on-disk changes before a read: await any in-flight build, else * rebuild unless one finished within `debounceMs` (so a refresh that reads the manifest twice in a * row rebuilds once, not twice). */ export async function ensureIndexFresh(debounceMs = 3000): Promise { if (inflightBuild) { await inflightBuild; return; } if (Date.now() - lastBuildFinishedAt < debounceMs) return; await reindexNow(); } async function walk(dirAbs: string, prev: Manifest, next: Manifest): 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 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. 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). 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 || coverName || hasDisco) { // 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)}`); } 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(CACHE_ROOT, 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 ? 'meta.json' : null, prev.albums[rel]?.cover ? 'cover.jpg' : null, hasDisco ? 'discography.json' : null, ].filter((f): f is string => f !== null); const outputsExist = expected.every((f) => existsSync(join(cacheDir, f))); if (prev.albums[rel]?.v === v && outputsExist) { status.albumsSkipped += 1; } else { await mkdir(cacheDir, { recursive: true }); if (coverName) { const ok = await compressCover(join(dirAbs, coverName), coverJpg); if (ok) status.coversSaved += 1; } if (audio.length || video.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; }); const meta: IndexMeta = { path: rel, cover: existsSync(coverJpg) ? 'cover.jpg' : undefined, tracks, ...(videos.length ? { videos } : {}), }; await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta)); } if (hasDisco) { try { const md = await readFile(join(dirAbs, DISCO_FILE), 'utf8'); const disco = { artist: basename(dirAbs), albums: parseDiscography(md) }; await writeFile(join(cacheDir, 'discography.json'), JSON.stringify(disco)); status.discographies += 1; } catch { /* skip discography on failure */ } } status.albumsBuilt += 1; } next.albums[rel] = { v, cover: existsSync(coverJpg), tracks: audio.length, ...(video.length ? { videos: video.length } : {}), ...(hasDisco ? { disco: true } : {}), }; } for (const d of subdirs) await walk(join(dirAbs, d.name), prev, next); } // ── Serving helpers (path-safe within CACHE_ROOT) ── function resolveCachePath(rel: string, file: string): string | null { const clean = rel.replace(/^\/+/, ''); const abs = join(CACHE_ROOT, clean, file); if (abs !== CACHE_ROOT && !abs.startsWith(CACHE_ROOT + '/')) return null; return abs; } export function metaFilePath(rel: string): string | null { return resolveCachePath(rel, 'meta.json'); } export function coverFilePath(rel: string): string | null { return resolveCachePath(rel, 'cover.jpg'); } export function discographyFilePath(rel: string): string | null { return resolveCachePath(rel, 'discography.json'); } /** A video's poster: cache//posters/.jpg. `file` is basename'd to keep it within the folder. */ export function posterFilePath(rel: string, file: string): string | null { return resolveCachePath(rel, join('posters', `${basename(file)}.jpg`)); } /** A track's cached lyrics: cache//lyrics/.. `file` basename'd for path-safety. */ export function lyricsFilePath(rel: string, file: string, fmt: 'lrc' | 'txt'): string | null { return resolveCachePath(rel, join('lyrics', `${basename(file)}.${fmt}`)); }