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.
17 KiB
Music API (/api/music/*)
Platform API the mobile app uses to stream music and sync a server-built library index, so the app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
- Source of truth for the code:
plugins/music/sidecar/index.ts(theofficer-musicsidecar owns all of this; the platform/api/music/*route is a transparent auth-ing proxy). - Music root:
~/Musicon the server. Allpathvalues are home-relative (e.g.Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3), identical to/api/file-browser/raw. <rel>: an album folder path relative to theMusicroot (e.g.Albums/AC-DC/[1980] Back in Black).
Auth
Every endpoint is behind the standard user auth. Two ways to pass the JWT:
- Header:
Authorization: Bearer <jwt>(normal fetches). - Query:
?token=<jwt>— for media elements / native players that can't set headers (audio, images).
401 = no token · 403 = invalid/expired token.
1. Playback — stream a track
GET /api/music/stream?path=<home-relative>&token=<jwt>
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 |
-
X-Audio-Durationstill ships, and no longer costs anffprobe(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. It is now read from the album'smeta.jsonand cached per album: 1.5ms cold, 0.1ms warm, and the same number, because the indexer got it from ffprobe too.It was briefly removed outright, on the reasoning that nothing in the web read it and
/metaalready carrieddurationSec. The mobile player stopped playing, so it is load-bearing for at least one client and stays in the contract. New clients should still preferdurationSecfrom/meta— it arrives with the queue, before the first stream request.meta.jsonis the fast path, not the only one:/streamserves 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. -
/streamhas noETagand noCache-Control. One was added on 2026-08-19 and removed the same day: the mobile player stopped playing, and afterX-Audio-Durationwas 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 sendIf-None-Match, receive304, and have no audio.If-None-Matchon/streamis therefore ignored — never304, always bytes./cover,/meta,/lyricsand/posterDO have validators and are unaffected. -
Errors:
400invalid/missing path ·404not found ·416bad range.
curl -H "Authorization: Bearer $JWT" -H "Range: bytes=0-1023" \
"$BASE/api/music/stream?path=Music/Albums/AC-DC/[1980]%20Back%20in%20Black/01%20Hells%20Bells.mp3" -D -
2. Library index — the synced cache
The server maintains a cache tree that mirrors the library, one entry per album folder. The app syncs 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
GET /api/music/manifest
{
"version": 1,
"generatedAt": 1785034701973, // ms; when the index was last built
"albums": {
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
// …
},
}
404 if the index has never been built (see §3). Entries with tracks: 0 are container folders (e.g. an
artist folder). An entry with disco: true is an artist folder that has a discography — fetch its
grouping via /discography (§2.4). videos: N (optional) counts video files (concerts, clips) that live
directly in that artist/album folder — their per-file metadata is in that folder's meta.json (§2.2). A folder
may have any mix of tracks, videos, and disco.
2.2 Album metadata
GET /api/music/meta?path=<rel>
Returns the album's meta.json. Sends ETag: <v>; a request with If-None-Match: <v> returns 304.
{
"path": "Albums/AC-DC/[1980] Back in Black",
"cover": "cover.jpg", // present only if a cover exists
"tracks": [
{
"file": "01 Hells Bells.mp3", // filename within the album folder
"title": "Hells Bells",
"artist": "AC/DC",
"albumArtist": "AC/DC",
"album": "Back in Black",
"track": "1",
"year": "1980",
"durationSec": 312,
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
},
// …
],
"videos": [
// present only for folders that contain video files
{
"file": "1989 - Seattle.mp4", // filename within the folder
"title": "Live Shit: Seattle", // from the container title tag, if any
"durationSec": 8130,
"width": 1280,
"height": 720,
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
},
// …
],
}
All track/video fields except file are optional (absent when the tag/stream info is missing). videos is
omitted entirely when the folder has none.
To stream a track or video: GET /api/music/stream?path=Music/<rel>/<file> (byte-range; works for .mp4).
2.3 Cover
GET /api/music/cover?path=<rel>
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends ETag: <v>; If-None-Match: <v> → 304.
Only meaningful when the manifest entry has "cover": true.
2.3.1 Video poster
GET /api/music/poster?path=<rel>&file=<video filename>
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. file is
the video's filename within <rel> (URL-encode it). Sends ETag: <v>; If-None-Match: <v> → 304; 404
when the video has no poster. Only request it when that video's meta.videos[] entry has a poster field.
2.3.2 Lyrics
GET /api/music/lyrics?path=<rel>&file=<track filename>
Plain-text body of the track's lyrics; the X-Lyrics-Format header is lrc (synced, [mm:ss.xx]-timestamped)
or txt (plain). Sends ETag: <v>; If-None-Match: <v> → 304; 404 when the track has no lyrics. Only
request it when that track's meta.tracks[] entry has a lyrics field ("lrc"/"txt").
Sources, in precedence order (indexed at build time): an external <track basename>.lrc > external
<track basename>.txt > embedded lyrics in the audio tags (lyrics / lyrics-<lang> /
unsyncedlyrics). A .txt (or embedded) whose text actually contains [mm:ss] lines is served as lrc.
2.4 Discography (artist album grouping)
For artist folders (manifest entry with "disco": true), this returns a map of album folder → release
type, so the player can split an artist's album list into sections (Studio, Live, Compilation, Single, EP…).
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
Sends ETag: <v>; If-None-Match: <v> → 304.
{
"artist": "Anthrax",
"albums": {
"[1984] Fistful Of Metal": "Studio",
"[1985] Armed And Dangerous": "EP",
"[1994] The Island Years": "Live",
"[1991] Attack Of The Killer B's": "Compilation",
// …
},
}
- Keys are album folder names (
[year] title) — they map 1:1 to the artist's album folders, i.e. the last path segment of that album's manifest<rel>. Group the artist's albums by looking each up here. - Types are a normalized set:
Studio,Live,Compilation,Single,EP,Soundtrack,Remix,DJ-Mix,Demo,Mixtape,Bootleg,Other(unknown values pass through as-is). The player defines section order. - An album folder not present here has no classification → put it in an "Other"/uncategorized section.
- Source of truth is each artist's
_discography.md(author-maintained); this JSON is derived from it and re-generated whenever that file changes (itsvbumps independently of the albums'meta/cover).
3. Building / refreshing the index
The index is built on demand (nothing is pre-built or scheduled). A build is incremental — albums
whose v is unchanged are skipped — and it prunes albums removed from the library.
3.1 Trigger
POST /api/music/reindex → returns IndexStatus (running: true)
GET /api/music/reindex/status → IndexStatus snapshot
IndexStatus:
{
"running": true,
"startedAt": 1785034701973,
"finishedAt": null,
"foldersScanned": 45,
"albumsBuilt": 12,
"albumsSkipped": 3,
"tracksIndexed": 320,
"videosIndexed": 4,
"coversSaved": 12,
"postersSaved": 4,
"lyricsIndexed": 45,
"discographies": 3,
"currentPath": "Albums/AC-DC/[1980] Back in Black",
"error": null,
}
3.2 Live progress — SSE
GET /api/music/reindex/stream
- Triggers a build if none is running. Pass
?trigger=0to watch only (subscribe without starting one). - Emits
event: progress(anIndexStatus) throttled to ~200 ms, then a singleevent: done(anIndexReport) and closes the stream.
event: progress
data: {"running":true,"foldersScanned":45,"tracksIndexed":320,"albumsBuilt":12,"albumsSkipped":3,"coversSaved":12,"currentPath":"Albums/AC-DC/[1980] Back in Black", …}
event: done
data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":320,"coversSaved":12,"elapsedSec":37.2,"error":null}
IndexReport (the done payload):
{
"albums": 15,
"built": 12,
"skipped": 3,
"foldersScanned": 45,
"tracksIndexed": 320,
"coversSaved": 12,
"discographies": 3,
"elapsedSec": 37.2,
"error": null,
}
First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via
v).
4. Recommended resync algorithm (app side)
Keep the last manifest.albums you synced. On resync:
GET /api/music/manifest.- For each
<rel>in the new manifest:- new, or
vdiffers from your stored copy → fetchGET /meta?path=<rel>(+GET /cover?path=<rel>ifcover:true, +GET /discography?path=<rel>ifdisco:true); store them under your local<rel>/. vunchanged → skip (no download).
- new, or
- For each
<rel>you have locally that's absent from the new manifest → delete it. - Save the new manifest as your baseline.
Optionally trigger a fresh server build first via GET /reindex/stream (and show progress from its
progress/done events) so the manifest reflects the latest library before you diff.
Result: a resync after adding one album = 1 manifest fetch + that one album's meta + cover. Nothing else moves.
Per-user state — Favorites & Currently-playing
Unlike everything above (library data served by the sidecar), these are per-user and served by the
platform straight from Postgres — same /api/music prefix and same auth. Keys are opaque paths the app
supplies; the server never interprets them:
| kind | key |
|---|---|
track |
home-path — Music/<rel>/<file> (also the /stream path & queue id) |
album |
music-rel — Albums/AC-DC/[1980] Back in Black |
artist |
music-rel — Albums/AC-DC |
Favorites
GET /api/music/favorites→ grouped keys, newest first:{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }POST /api/music/favorites{ "kind": "track|album|artist", "key": "…" }→{ ok: true }. Idempotent (a repeat add is a no-op).DELETE /api/music/favorites?kind=<kind>&key=<key>→{ ok: true }(no-op if not set). Key passed as a query param (URL-encode it).400 { error: "kind and key required" }on a bad/missing kind or empty key.
Currently-playing (resume)
One snapshot per user — persist while playing (throttled) and on pause / track-change / close; read it on launch to offer "resume".
GET /api/music/now-playing→ the snapshot ornull:{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black", "title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black", "durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }diris the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).PUT /api/music/now-playing{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }→{ ok: true }(upsert). Omitted fields default to""/0.DELETE /api/music/now-playing→{ ok: true }(clear, e.g. on stop).400 { error: "homePath required" }ifhomePathis missing/empty.
Playlists
Server-side playlists, scoped to the calling user. Items are track keys — the same
<albumRel>/<file> strings favorites uses — so a playlist survives a reindex as long as the file stays
put. 404 throughout means "not yours or not there"; the two are deliberately indistinguishable.
| method | path | body | returns |
|---|---|---|---|
GET |
/api/music/playlists |
— | [{ id, name, count, createdAt, updatedAt }], most recent first |
POST |
/api/music/playlists |
{ name } |
201 with the row; 409 if the name is taken |
GET |
/api/music/playlists/:id |
— | { id, name, items: [key], … } |
PATCH |
/api/music/playlists/:id |
{ name } |
rename; 409 if taken |
DELETE |
/api/music/playlists/:id |
— | deletes it, items cascade |
POST |
/api/music/playlists/:id/items |
{ keys: [] } |
append → { count } |
PUT |
/api/music/playlists/:id/items |
{ keys: [] } |
replace the whole list → { count } |
PUT is how you reorder or remove: send the list you want, in order. There is no per-item delete.
Notes
- Covers are server-compressed (≤600px / q5) — sync them as-is; no client-side resizing needed.
- Durations are exact (ffprobe at index time) in
meta.json'sdurationSec(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:
503if the music sidecar isn't connected,502if it's unreachable.