28 range requests per song start on iOS turned out to be AVPlayer paging
through ~1 MB of embedded cover art in 32 KB round trips. The library
has been slimmed (23,833 files rewritten, art capped at 80 KB, ID3v1
stripped); the log served its purpose.
reverts 0e08aef, b430a70 and 1170e41 — stream-audio.ts and MUSIC_API.md are
now byte-identical to 2a70bc1.
all three were unwound over two days chasing a mobile playback failure that
turned out to have nothing to do with this plugin. the reasoning that undid
them was elimination, not diagnosis: the duration header did not fix it, so
the etag "must" be the cause. the option never considered was that none of it
was mine, which is the one that was true.
nothing here was measured wrong. removing the per-file ffprobe from the path
before the first byte is the speedup, and it is confirmed in real use.
what the mobile client should know, unchanged from MUSIC_API.md: x-audio-duration
is gone, read durationSec from /meta instead — it arrives with the queue,
before the first stream request.
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.
the previous commit read the duration from meta.json instead of spawning
ffprobe. correct for an indexed album and wrong everywhere else: /stream
serves any home-relative path and never required an index, so a library that
has not been indexed got no header at all. alpha is exactly that case — its
music cache is empty — which is how this was caught rather than shipped.
"always present" replaced with "present if indexed" is the same regression as
removing the header, one layer down.
so both, in order: meta.json when it can answer, ffprobe when it cannot, each
cached. verified on an indexed album (247 from meta, 0.3ms warm) and on the
same file with its index deleted (7.03 from ffprobe, 83ms cold, 0.1ms warm).
removing it was wrong: the mobile player stopped playing. nothing in the web
read the header and /meta already carried durationSec, so it looked dead. it
was not — it is a documented part of the contract and something depended on it.
the header is back and the ffprobe is not. the value is read from the album's
meta.json, which is the same number ffprobe produced (the indexer got it from
ffprobe at index time), cached per album and keyed on the meta file's mtime so
a reindex invalidates it. 1.5ms cold, 0.1ms warm, against 50ms warm and 315ms
cold for the process spawn.
first pass looked for meta.json beside the audio; it lives under the sidecar's
own cache root mirroring the relative path. uses metaFilePath now rather than
re-deriving it.
verified against a real file: header present and equal to meta, 304 on
whole-file revalidation, 206 on range with a matching etag.
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.
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 —
41 files, unchanged from the tree they left.
manifest.ts identity, one permission, ffmpeg/ffprobe declared
api/ the sidecar proxy; the prefix comes from mountPrefix()
sidecar/ the whole /api/music contract — indexing, streaming, per-user state
db/ music_favorites, _playlists, _playlist_items, _now_playing
web/ panels, layout, and the player: engine, bar, lyrics, favourites
cliamp/ the second playback path, parked — not working, kept deliberately
widgets/ the dashboard widget, parked — plugins cannot contribute widgets
assets/ icon.png, the dock tile
scripts/ the reindex CLI
PLUGIN.md is the design record: what moved, what stayed, what broke, and why.
MUSIC_API.md is the contract the phone and tablet apps speak, and the reason
the sidecar's HTTP shape is not free to change.
── It does not build here, and that is the point ──
The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*`
through the workspace links in its own node_modules. Measured from this
directory, outside the platform checkout, every one of them fails to resolve —
7 imports in the backend, ~29 in the frontend.
So this repository is the source of truth, not yet a buildable unit. Making it
one means the host API becoming something a plugin can depend on rather than
something it reaches into. That is the next problem, and having the code here
is what makes it unavoidable rather than theoretical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>