Files
music/sidecar/stream-audio.ts
T
pastilhas 1170e411ac revert the /stream etag, cache-control and 304
playback did not come back when x-audio-duration did, so the duration header
was not the only thing i broke. the range and whole-file branches are
byte-identical to the version that worked, which leaves these three as the
only remaining difference on /stream.

a cache that keeps a validator after evicting the body sends if-none-match,
gets a 304 and has no audio. plausible, unproven, and not worth another round
trip through someone's phone to find out — the etag was an optimisation and
playback is not.

/stream is now byte-identical to the last version known to play, verified by
running both implementations side by side over no-range, closed, open and
invalid ranges and diffing status, every header and body length.

if-none-match is still accepted and ignored. re-introducing this needs a
capture of what the player actually sends.
2026-08-19 15:43:11 +00:00

190 lines
7.4 KiB
TypeScript

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<string, string> = {
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<string, Map<string, number>>();
async function durationFromIndex(relPath: string): Promise<number | undefined> {
// 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<string, number>();
async function probeDuration(absPath: string, mtimeMs: number): Promise<number | undefined> {
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<Response> {
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<string, string> = {
'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) } });
}