Files
music/sidecar/stream-audio.ts
T
pastilhas efc9581889 restore the three streaming improvements
reverts 0e08aef, b430a70 and 1170e41 — stream-audio.ts and MUSIC_API.md are
now byte-identical to 2a70bc1.

all three were unwound over two days chasing a mobile playback failure that
turned out to have nothing to do with this plugin. the reasoning that undid
them was elimination, not diagnosis: the duration header did not fix it, so
the etag "must" be the cause. the option never considered was that none of it
was mine, which is the one that was true.

nothing here was measured wrong. removing the per-file ffprobe from the path
before the first byte is the speedup, and it is confirmed in real use.

what the mobile client should know, unchanged from MUSIC_API.md: x-audio-duration
is gone, read durationSec from /meta instead — it arrives with the queue,
before the first stream request.
2026-08-19 17:11:26 +00:00

106 lines
4.1 KiB
TypeScript

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<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',
};
// 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<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);
// 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<string, string> = {
'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) } });
}