Each track's lyrics are resolved and cached at cache/<rel>/lyrics/<file>.<lrc|txt>, with the format recorded as `lyrics: 'lrc'|'txt'` on the meta.tracks entry. Precedence: external "<base>.lrc" > external "<base>.txt" > embedded tag (lyrics / lyrics-<lang> / unsyncedlyrics — ffprobe now reads all format tags). Content that contains [mm:ss] lines is stored as lrc even from a .txt/embedded source. Only track-matching sidecars affect the version signature (a stray notes.txt is ignored). Lyrics dir is wiped+regenerated per rebuild; new `lyricsIndexed` counter. Served by GET /api/music/lyrics?path=<rel>&file=<track> (text/plain + X-Lyrics-Format header, ETag=<v>, 304, 404 when none). Verified end-to-end: external .lrc wins over embedded; embedded → plain txt; unmatched .txt ignored. MUSIC_API.md documents the field + endpoint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
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 <rel>
|
|
// (relative to the Music root):
|
|
//
|
|
// DATA_PATH/music/cache/<rel>/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/<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).
|
|
|
|
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 <rel>: "posters/<file>.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<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;
|
|
|
|
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<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}/);
|
|
// 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,
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** 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<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,
|
|
'-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/<rel>/lyrics/<trackfile>.<lrc|txt>. Precedence:
|
|
* external "<base>.lrc" > external "<base>.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<string>,
|
|
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<IndexStatus> {
|
|
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<IndexStatus> | 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<IndexStatus> {
|
|
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<void> {
|
|
if (inflightBuild) {
|
|
await inflightBuild;
|
|
return;
|
|
}
|
|
if (Date.now() - lastBuildFinishedAt < debounceMs) return;
|
|
await reindexNow();
|
|
}
|
|
|
|
async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<void> {
|
|
status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
|
|
status.foldersScanned += 1;
|
|
emitProgress();
|
|
|
|
let entries: import('node:fs').Dirent[];
|
|
try {
|
|
entries = await readdir(dirAbs, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const files = entries.filter((e) => e.isFile());
|
|
const subdirs = entries.filter((e) => e.isDirectory());
|
|
const 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/<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}`));
|
|
}
|