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:
+10
-5
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user