import { stat } from 'node:fs/promises'; import { basename, dirname, join, resolve, sep } from 'node:path'; import { homedir } from 'node:os'; import type { IndexTrack } from './indexer'; import { metaFilePath } from './indexer'; // All processing lives here (the platform is just a proxy). Files live under the owner's home — single // super user, so HOME_DIR. Paths from the app are home-relative (e.g. "Music/Artist/Album/track.mp3"), // exactly like file-browser /raw. const ROOT_DIR = homedir(); const CONTENT_TYPES: Record = { mp3: 'audio/mpeg', m4a: 'audio/mp4', mp4: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', wav: 'audio/wav', ogg: 'audio/ogg', opus: 'audio/opus', wma: 'audio/x-ms-wma', }; // `X-Audio-Duration`: meta.json first, ffprobe only if that has no answer. // // ffprobe alone cost 50ms warm and 315ms cold, once per file per restart, paid BEFORE THE FIRST BYTE. // // It was removed outright on the reasoning that nothing in the web read the header and the indexer had // already written `durationSec` into the album's `meta.json`. The mobile player stopped playing, which // settles it: the header is part of the contract and something depends on it. // // Reading it from meta.json instead is the same number — the indexer got it from ffprobe at index time — // for a file read rather than a process spawn. But it is only the same number FOR AN INDEXED ALBUM, and // streaming does not require indexing: /stream serves any home-relative path, so a library that has never // been indexed (alpha's, at the time of writing) would get no header at all. Replacing "always present" // with "present if indexed" is the same regression again, one layer down. // // Hence both. The fast path covers the normal case and the spawn stays as the floor, so the header is // present whenever it used to be, and costs a process only where meta.json cannot answer. /** * `durationSec` for one track, from its album's `meta.json`. Undefined if the album is not indexed. * * Cached per album rather than per track: one read answers every track on the record, which is the access * pattern — a queue plays an album in order. Keyed on the meta file's own mtime so a reindex invalidates * it without anything having to remember to. */ const albumDurations = new Map>(); async function durationFromIndex(relPath: string): Promise { // meta.json does NOT sit beside the audio — it lives under the sidecar's own cache root, mirroring the // album's relative path. `metaFilePath` is the one place that knows that; do not re-derive it here. const metaPath = metaFilePath(dirname(relPath.replace(/^\/+/, ''))); if (!metaPath) return undefined; let key: string; try { key = `${metaPath}:${(await stat(metaPath)).mtimeMs}`; } catch { return undefined; // unindexed album — caller falls back to ffprobe } let byFile = albumDurations.get(key); if (!byFile) { try { const meta = (await Bun.file(metaPath).json()) as { tracks?: IndexTrack[] }; byFile = new Map( (meta.tracks ?? []).flatMap((t) => (t.durationSec ? [[t.file, t.durationSec] as [string, number]] : [])), ); albumDurations.set(key, byFile); } catch { return undefined; } } return byFile.get(basename(relPath)); } // Probe duration once per file (keyed by absolute path + mtime) — the player makes many range requests // per track, and we don't want to shell out to ffprobe on each one. const durationCache = new Map(); async function probeDuration(absPath: string, mtimeMs: number): Promise { const key = `${absPath}:${mtimeMs}`; const cached = durationCache.get(key); if (cached !== undefined) return cached; try { const proc = Bun.spawn( [ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath, ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = (await new Response(proc.stdout).text()).trim(); await proc.exited; const d = parseFloat(out); if (Number.isFinite(d) && d > 0) { durationCache.set(key, d); return d; } } catch { /* ffprobe missing or failed — no duration header */ } return undefined; } /** Resolve a home-relative path within ROOT_DIR; null if it escapes (traversal). */ function resolveWithinRoot(relPath: string): string | null { const clean = relPath.replace(/^\/+/, ''); const abs = resolve(ROOT_DIR, clean); if (abs !== ROOT_DIR && !abs.startsWith(ROOT_DIR + sep)) return null; return abs; } /** * Serve an audio file with byte-range support and an `X-Audio-Duration` header. * * ── No ETag, no Cache-Control, no 304 — and that is deliberate ── * * All three were added on 2026-08-19 to spare a re-download on replay and seek: /stream was the only * route with no validator, so every seek re-read the file over the network. The reasoning still looks * right and the measurement was real. * * The mobile player stopped playing. Restoring `X-Audio-Duration` did not fix it, and the range and * whole-file branches are byte-identical to the version that worked, which left these headers as the * only remaining difference. A cache that keeps a validator after evicting the body would send * `If-None-Match`, get a 304, and have no audio — plausible, and not something to keep testing on * someone's phone. * * So /stream is back to exactly what worked. `ifNoneMatch` is still accepted and deliberately unused: * the caller passes it, and dropping the parameter would only have to be re-added. Re-introducing this * needs a capture of what the player actually sends, not another guess. */ export async function streamAudioFile( relPath: string, rangeHeader: string | null, _ifNoneMatch: string | null = null, ): Promise { const absPath = resolveWithinRoot(relPath); if (!absPath) return new Response('Invalid path', { status: 400 }); let s; try { s = await stat(absPath); } catch { return new Response('Not found', { status: 404 }); } if (!s.isFile()) return new Response('Not a file', { status: 404 }); const total = s.size; const ext = absPath.slice(absPath.lastIndexOf('.') + 1).toLowerCase(); const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream'; const file = Bun.file(absPath); const duration = (await durationFromIndex(relPath)) ?? (await probeDuration(absPath, s.mtimeMs)); const baseHeaders: Record = { 'Content-Type': contentType, 'Accept-Ranges': 'bytes', ...(duration ? { 'X-Audio-Duration': String(duration) } : {}), }; if (rangeHeader) { const m = rangeHeader.match(/bytes=(\d*)-(\d*)/); if (m) { const start = m[1] ? parseInt(m[1], 10) : 0; const end = m[2] ? parseInt(m[2], 10) : total - 1; if (Number.isNaN(start) || start < 0 || end >= total || start > end) { return new Response('Invalid range', { status: 416, headers: { 'Content-Range': `bytes */${total}` } }); } return new Response(file.slice(start, end + 1), { status: 206, headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1), }, }); } } return new Response(file, { status: 200, headers: { ...baseHeaders, 'Content-Length': String(total) } }); }