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.
This commit is contained in:
+20
-20
@@ -118,11 +118,29 @@ function resolveWithinRoot(relPath: string): string | null {
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** Serve an audio file with byte-range support and an mtime+size ETag. */
|
||||
/**
|
||||
* 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,
|
||||
_ifNoneMatch: string | null = null,
|
||||
): Promise<Response> {
|
||||
const absPath = resolveWithinRoot(relPath);
|
||||
if (!absPath) return new Response('Invalid path', { status: 400 });
|
||||
@@ -140,32 +158,14 @@ export async function streamAudioFile(
|
||||
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 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) } : {}),
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user