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:
2026-08-19 15:43:11 +00:00
parent b430a702b4
commit 1170e411ac
2 changed files with 30 additions and 25 deletions
+10 -5
View File
@@ -30,8 +30,8 @@ Byte-range streaming so the player can **seek without downloading the whole file
| Case | Status | Headers | | Case | Status | Headers |
| --------------------- | ------ | --------------------------------------------------------------------------------------------- | | --------------------- | ------ | --------------------------------------------------------------------------------------------- |
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `ETag`, `Cache-Control` | | No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `ETag` | | With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
- **`X-Audio-Duration` still ships, and no longer costs an `ffprobe`** (2026-08-19). It used to spawn - **`X-Audio-Duration` still ships, and no longer costs an `ffprobe`** (2026-08-19). It used to spawn
one per file on the first request — measured at 50ms warm, 315ms cold, paid before the first byte. one per file on the first request — measured at 50ms warm, 315ms cold, paid before the first byte.
@@ -46,9 +46,14 @@ Byte-range streaming so the player can **seek without downloading the whole file
`meta.json` is the fast path, not the only one: `/stream` serves any home-relative path and does not `meta.json` is the fast path, not the only one: `/stream` serves any home-relative path and does not
require indexing, so an unindexed album falls back to ffprobe (cached per file) rather than dropping require indexing, so an unindexed album falls back to ffprobe (cached per file) rather than dropping
the header. Absent only if ffprobe is missing or the file is unreadable — as before. the header. Absent only if ffprobe is missing or the file is unreadable — as before.
- **`ETag`** is `mtime-size` in base36. A whole-file request with a matching `If-None-Match` answers - **`/stream` has no `ETag` and no `Cache-Control`.** One was added on 2026-08-19 and removed the same
`304`; a Range request never does, because a 304 would answer a question about part of a file with a day: the mobile player stopped playing, and after `X-Audio-Duration` was ruled out these headers were
statement about all of it. the only remaining difference from the version that worked. A cache that keeps a validator after
evicting the body would send `If-None-Match`, receive `304`, and have no audio.
`If-None-Match` on `/stream` is therefore ignored — never `304`, always bytes. `/cover`, `/meta`,
`/lyrics` and `/poster` DO have validators and are unaffected.
- Errors: `400` invalid/missing path · `404` not found · `416` bad range. - Errors: `400` invalid/missing path · `404` not found · `416` bad range.
``` ```
+20 -20
View File
@@ -118,11 +118,29 @@ function resolveWithinRoot(relPath: string): string | null {
return abs; 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( export async function streamAudioFile(
relPath: string, relPath: string,
rangeHeader: string | null, rangeHeader: string | null,
ifNoneMatch: string | null = null, _ifNoneMatch: string | null = null,
): Promise<Response> { ): Promise<Response> {
const absPath = resolveWithinRoot(relPath); const absPath = resolveWithinRoot(relPath);
if (!absPath) return new Response('Invalid path', { status: 400 }); 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 contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
const file = Bun.file(absPath); 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 duration = (await durationFromIndex(relPath)) ?? (await probeDuration(absPath, s.mtimeMs));
const baseHeaders: Record<string, string> = { const baseHeaders: Record<string, string> = {
'Content-Type': contentType, 'Content-Type': contentType,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
...(duration ? { 'X-Audio-Duration': String(duration) } : {}), ...(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) { if (rangeHeader) {
const m = rangeHeader.match(/bytes=(\d*)-(\d*)/); const m = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (m) { if (m) {