import { stat } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; import { homedir } from 'node:os'; // 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', }; // NO ffprobe HERE, and no `X-Audio-Duration`. // // This used to shell out to ffprobe on the first request for each file — measured at 50ms warm and 315ms // cold, paid before the first byte, once per file per sidecar restart. It filled a header that nothing in // the web read, duplicating `durationSec` which the indexer already wrote into the album's `meta.json` // and which every client fetches anyway to build its queue. // // Removed rather than cached harder: the fastest work is the work not done, and a second copy of a number // is a second thing that can disagree. /** 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 mtime+size ETag. */ 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 }); // TEMPORARY diagnostic (2026-08-29): iOS first-play issues ~28 range requests in 2 s where Android // issues one. This prints each request range so we can see WHICH bytes AVPlayer asks for — small // sequential chunks would mean a transport problem, scattered offsets a VBR duration scan. console.log( `[stream] ${new Date().toISOString().slice(11, 23)} ${rangeHeader ?? "FULL"} · ${relPath.slice(relPath.lastIndexOf("/") + 1)}`, ); 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); // An audio file's bytes do not change; a re-encode replaces the file, which moves mtime and size. So // the pair identifies the content, and a client that has played this track once can seek in it without // fetching anything again. // // This was the ONLY route with no validator at all — cover, meta, lyrics and poster have had one all // along — so every seek and every replay re-read the file over the network. const etag = `"${s.mtimeMs.toString(36)}-${total.toString(36)}"`; const baseHeaders: Record = { 'Content-Type': contentType, 'Accept-Ranges': 'bytes', ETag: etag, // `no-cache` means "you may keep it, but ask first" — a conditional request rather than a blind // refetch. `private` because a member's library is not a proxy's to hold. 'Cache-Control': 'private, no-cache', }; // Whole-file revalidation. Deliberately NOT applied to a Range request: a 304 there would answer a // question about part of a file with a statement about all of it, and players handle that badly. if (!rangeHeader && ifNoneMatch === etag) { return new Response(null, { status: 304, headers: baseHeaders }); } 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) } }); }