Files
platform/MUSIC_API.md
T
pastilhasandClaude Opus 4.8 4331882693 docs: MUSIC_API.md — /api/music/* contract for the app team
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>
2026-07-26 03:27:53 +00:00

189 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` (the `officer-music` sidecar owns
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
- **Music root:** `~/Music` on the server. All `path` values 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 the `Music` root** (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: `400` invalid/missing path · `404` not found · `416` bad 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
```
```jsonc
{
"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`.
```jsonc
{
"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, ~3080 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`:
```jsonc
{
"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=0` to **watch only** (subscribe without starting one).
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
`IndexReport`) 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):
```jsonc
{ "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:
1. `GET /api/music/manifest`.
2. For each `<rel>` in the new manifest:
- **new**, or **`v` differs** from your stored copy → fetch `GET /meta?path=<rel>` (+ `GET /cover?path=<rel>`
if `cover:true`); store them under your local `<rel>/`.
- **`v` unchanged** → **skip** (no download).
3. For each `<rel>` you have locally that's **absent** from the new manifest → delete it.
4. 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-Duration` and `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.