Files
platform/plugins/music/sidecar/indexer.ts
T
pastilhas de3340398c music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.

Three things stayed, each on purpose.

cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.

The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.

api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.

Two bugs found on the way, neither visible from reading.

The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.

[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.

registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.

music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.

bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.

Not yet verified on the live server — that is next.
2026-08-15 01:46:43 +00:00

1080 lines
44 KiB
TypeScript

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 <rel>
// (relative to the Music root):
//
// DATA_PATH/music/cache/<rel>/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/<rel>/cover.jpg compressed (<=600px, jpeg q5)
// DATA_PATH/music/cache/<rel>/posters/<videofile>.jpg video frame-grab thumbnails
// DATA_PATH/music/cache/<rel>/lyrics/<trackfile>.<lrc|txt> 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<void> {
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<void> {
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 <rel>: "posters/<file>.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 <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> };
// ── 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<string, string> = {
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<string, string> {
const albums: Record<string, string> = {};
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<Manifest> {
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<Manifest> {
return loadManifest();
}
/** The version stamp for an album, for ETag/diff use (null if unknown). */
export async function albumVersion(rel: string): Promise<string | null> {
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<ProgressListener>();
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<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
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-<lang>, 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<string, string> } };
const fmt = data.format ?? {};
// Tag keys vary in case across containers — normalize to lowercase.
const tags: Record<string, string> = {};
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-<lang>`, `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<IndexVideo> {
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<string, string> };
streams?: Array<{ codec_type?: string; width?: number; height?: number }>;
};
const fmt = data.format ?? {};
const tags: Record<string, string> = {};
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<boolean> {
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<boolean> {
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/<rel>/lyrics/<trackfile>.<lrc|txt>. 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<string>,
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<Manifest> {
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<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> {
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 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<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 () => {
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<IndexStatus> {
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<void> {
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<boolean> {
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<boolean> {
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<ManifestEntry | null> {
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 → `<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 = {
path: rel,
cover: existsSync(coverJpg) ? 'cover.jpg' : undefined,
tracks,
...(videos.length ? { videos } : {}),
...(image.length ? { images: image.map((file) => ({ file })) } : {}),
};
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;
}
return {
v,
cover: existsSync(coverJpg),
tracks: audio.length,
...(video.length ? { videos: video.length } : {}),
...(image.length ? { images: image.length } : {}),
...(hasDisco ? { disco: true } : {}),
...(lyricsWritten ? { lyrics: lyricsWritten } : {}),
...(postersWritten ? { posters: postersWritten } : {}),
};
}
}
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 (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;
}
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);
}
// ── 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/<rel>/posters/<file>.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/<rel>/lyrics/<file>.<lrc|txt>. `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}`));
}