diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts index b93d1f8e..f29bbb4d 100644 --- a/src/servers/sidecar/music/index.ts +++ b/src/servers/sidecar/music/index.ts @@ -1,6 +1,14 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { streamAudioFile } from './stream-audio'; +import { + buildMusicIndex, + getIndexStatus, + getManifest, + albumVersion, + metaFilePath, + coverFilePath, +} from './indexer'; // The music sidecar (officer-music). Same philosophy as the other officer-* sidecars: a singleton // process that registers with the API server. It OWNS an audio-streaming HTTP server (all path @@ -28,12 +36,47 @@ const server = Bun.serve({ hostname: '127.0.0.1', async fetch(req) { const url = new URL(req.url); + const json = (data: unknown, init?: ResponseInit) => + new Response(JSON.stringify(data), { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers } }); + if (url.pathname === '/health') return new Response('ok'); + + // ── Streaming ── if (url.pathname === '/stream') { const path = url.searchParams.get('path'); if (!path) return new Response('path is required', { status: 400 }); return streamAudioFile(path, req.headers.get('range')); } + + // ── Index build ── + if (url.pathname === '/reindex') { + if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 }); + void buildMusicIndex(); // fire-and-forget; sets running=true synchronously before the first await + return json(getIndexStatus()); + } + if (url.pathname === '/reindex/status') return json(getIndexStatus()); + + // ── Sync surface ── + if (url.pathname === '/manifest') return json(await getManifest()); + + if (url.pathname === '/meta' || url.pathname === '/cover') { + const rel = url.searchParams.get('path'); + if (rel === null) return new Response('path is required', { status: 400 }); + const isMeta = url.pathname === '/meta'; + const file = isMeta ? metaFilePath(rel) : coverFilePath(rel); + if (!file) return new Response('Invalid path', { status: 400 }); + if (!(await Bun.file(file).exists())) return new Response('Not found', { status: 404 }); + + const v = await albumVersion(rel); + if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 }); + return new Response(Bun.file(file), { + headers: { + 'Content-Type': isMeta ? 'application/json' : 'image/jpeg', + ...(v ? { ETag: v } : {}), + }, + }); + } + return new Response('Not found', { status: 404 }); }, }); diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts new file mode 100644 index 00000000..9362b519 --- /dev/null +++ b/src/servers/sidecar/music/indexer.ts @@ -0,0 +1,307 @@ +import { readdir, stat, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, relative } 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 }] } (phone IndexMeta schema) +// DATA_PATH/music/cache//cover.jpg compressed (<=600px, jpeg q5) +// +// 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', 'mp4']); +const COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png']; +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; +}; +export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[] }; + +type ManifestEntry = { v: string; cover: boolean; tracks: number }; +type Manifest = { version: number; generatedAt: number; albums: Record }; + +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; + coversSaved: number; + 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, + coversSaved: 0, + currentPath: '', + error: null, +}; + +export function getIndexStatus(): IndexStatus { + return { ...status }; +} + +// ── Helpers ── + +function isAudio(name: string): boolean { + const dot = name.lastIndexOf('.'); + return dot >= 0 && AUDIO_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 { + try { + const proc = Bun.spawn( + [ + 'ffprobe', + '-v', + 'error', + '-print_format', + 'json', + '-show_entries', + 'format=duration:format_tags=title,artist,album,album_artist,track,date', + 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}/); + return { + file, + title: tags.title || undefined, + artist: tags.artist || undefined, + albumArtist: tags.album_artist || undefined, + album: tags.album || undefined, + track: tags.track || undefined, + year: yearMatch ? yearMatch[0] : undefined, + durationSec: Number.isFinite(durNum) ? durNum : undefined, + }; + } 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; + } +} + +// ── 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, + coversSaved: 0, + currentPath: '', + error: null, + }); + + 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 = ''; + } + return getIndexStatus(); +} + +async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise { + status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.'; + status.foldersScanned += 1; + + 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); + const coverName = COVER_FILES.find((c) => files.some((e) => e.name === c)); + const rel = relative(MUSIC_ROOT, dirAbs); // '' at root + + if (audio.length || coverName) { + // Source signature → version. + 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)}`); + } + if (coverName) { + const st = await stat(join(dirAbs, coverName)).catch(() => null); + if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`); + } + const v = Bun.hash(sigParts.join('|')).toString(16); + const cacheDir = join(CACHE_ROOT, rel); + const metaExists = existsSync(join(cacheDir, 'meta.json')); + + if (prev.albums[rel]?.v === v && metaExists) { + status.albumsSkipped += 1; + } else { + await mkdir(cacheDir, { recursive: true }); + + if (coverName) { + const ok = await compressCover(join(dirAbs, coverName), join(cacheDir, 'cover.jpg')); + if (ok) status.coversSaved += 1; + } + + if (audio.length) { + const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => { + const t = await ffprobeTrack(join(dirAbs, name), name); + status.tracksIndexed += 1; + return t; + }); + const meta: IndexMeta = { path: rel, cover: coverName ? 'cover.jpg' : undefined, tracks }; + await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta)); + } + status.albumsBuilt += 1; + } + + next.albums[rel] = { v, cover: !!coverName, tracks: audio.length }; + } + + 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'); +}