diff --git a/MUSIC_API.md b/MUSIC_API.md index 94cee14..22b5c50 100644 --- a/MUSIC_API.md +++ b/MUSIC_API.md @@ -30,8 +30,8 @@ Byte-range streaming so the player can **seek without downloading the whole file | Case | Status | Headers | | --------------------- | ------ | --------------------------------------------------------------------------------------------- | -| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `ETag`, `Cache-Control` | -| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `ETag` | +| 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`, `X-Audio-Duration` | - **`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. @@ -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 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. -- **`ETag`** is `mtime-size` in base36. A whole-file request with a matching `If-None-Match` answers - `304`; a Range request never does, because a 304 would answer a question about part of a file with a - statement about all of it. +- **`/stream` has no `ETag` and no `Cache-Control`.** One was added on 2026-08-19 and removed the same + day: the mobile player stopped playing, and after `X-Audio-Duration` was ruled out these headers were + 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. ``` diff --git a/sidecar/stream-audio.ts b/sidecar/stream-audio.ts index 529ba1f..30334c4 100644 --- a/sidecar/stream-audio.ts +++ b/sidecar/stream-audio.ts @@ -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 { 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 = { '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) {