three bottlenecks in streaming and loading
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.
This commit is contained in:
+20
-5
@@ -30,11 +30,16 @@ 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`, `X-Audio-Duration` |
|
||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||
| 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` |
|
||||
|
||||
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
|
||||
- **`X-Audio-Duration` is GONE** (2026-08-19). It cost an `ffprobe` per file on the first request —
|
||||
measured at 50ms warm, 315ms cold, paid before the first byte — to report a number the indexer had
|
||||
already written into `meta.json`. **Read `durationSec` from `/meta` instead**, which every client
|
||||
already fetches to build its queue.
|
||||
- **`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.
|
||||
VBR MP3s. No need to scan the file.
|
||||
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||
|
||||
@@ -53,6 +58,16 @@ this instead of walking + ID3-parsing the library itself.
|
||||
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
|
||||
|
||||
**Put `v` in the URL of anything you fetch per album** — `/cover?path=…&v=<v>`, and the same for `/meta`
|
||||
and `/discography`. With it the server answers `Cache-Control: immutable` and a repeat visit sends **no
|
||||
request at all**. Without it you get `no-cache`, which does not mean "do not store" but "ask first" — so
|
||||
every cover costs a conditional round trip on every load, and a grid of forty albums is forty requests
|
||||
that all answer `304`.
|
||||
|
||||
The aggressive caching is conditional on `v` matching precisely so an older client cannot end up holding
|
||||
a cover forever: send no `v`, get the old behaviour. When an album changes, its `v` changes, the URL you
|
||||
build changes with it, and the stale entry is never asked for again.
|
||||
|
||||
### 2.1 Manifest — one call, whole library
|
||||
|
||||
```
|
||||
@@ -357,7 +372,7 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
|
||||
## Notes
|
||||
|
||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||
- **Durations are exact** (ffprobe at index time) in `meta.json`'s `durationSec` (seconds).
|
||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
|
||||
audio files_ is a separate, later feature.)
|
||||
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||
|
||||
Reference in New Issue
Block a user