Standalone reference for the mobile team: auth, streaming (/stream + X-Audio-Duration), the synced library index (manifest/meta/cover + per-album v diffing, ETag/304), building/refreshing (reindex + SSE progress), the recommended resync algorithm, and the data shapes (IndexMeta/Manifest/ IndexStatus/IndexReport). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.7 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:
src/servers/sidecar/music/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-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 VBR MP3s. No need to scan the file.- 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.
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 }
// …
}
}
404 if the index has never been built (see §3).
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
}
// …
]
}
All track fields except file are optional (absent when the tag is missing).
To stream a track: GET /api/music/stream?path=Music/<rel>/<file>.
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.
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, "coversSaved": 12,
"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, "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); 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.
Notes
- Covers are server-compressed (≤600px / q5) — sync them as-is; no client-side resizing needed.
- Durations are exact (ffprobe) in both
X-Audio-Durationandmeta.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.