Measured, not guessed. ffprobe is gone from the stream path. It ran on the first request for each file — 50ms warm, 315ms cold on this machine, paid before the first byte, once per file per sidecar restart — to fill X-Audio-Duration, a header nothing in the web read and which duplicates the durationSec the indexer already wrote into meta.json. 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. BREAKING for any client that read that header; read /meta instead, which every client already fetches to build its queue. /stream gains an ETag and Cache-Control. It 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. mtime+size identifies the bytes, since a re-encode replaces the file. A Range request never answers 304: that would answer a question about part of a file with a statement about all of it, and players handle it badly. And `?v=` makes covers cacheable rather than merely revalidatable. `no-cache` means "ask before reusing", so a grid of forty albums cost forty conditional requests on every load, all answering 304. The manifest already carries each album's version, so passing it costs the client nothing — and the server then answers `immutable`, making a repeat visit send nothing at all. That last one is deliberately conditional on `v` being present, which keeps it backward compatible: a client that has not been updated gets today's behaviour and cannot end up holding a stale cover forever.
106 lines
4.1 KiB
TypeScript
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) } });
|
|
}
|