diff --git a/MUSIC_API.md b/MUSIC_API.md new file mode 100644 index 00000000..cd1e19be --- /dev/null +++ b/MUSIC_API.md @@ -0,0 +1,188 @@ +# 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`. +- **``:** 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 ` (normal fetches). +- **Query:** `?token=` — 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=&token= +``` + +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= +``` +Returns the album's `meta.json`. Sends `ETag: `; a request with `If-None-Match: ` 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//`. + +### 2.3 Cover + +``` +GET /api/music/cover?path= +``` +Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: `; `If-None-Match: ` → `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 `` in the new manifest: + - **new**, or **`v` differs** from your stored copy → fetch `GET /meta?path=` (+ `GET /cover?path=` + if `cover:true`); store them under your local `/`. + - **`v` unchanged** → **skip** (no download). +3. For each `` 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.