Each Albums/<Artist>/_discography.md (author-maintained source of truth, never modified) is compiled into a per-artist discography.json in the cache = album folder → normalized release type (Studio/Live/Compilation/Single/EP/…), so the player can split an artist's album list into sections. - indexer.ts: parse the md table, normalize the Type (EP?→EP, Compilation (VA)→ Compilation, …), write discography.json. The artist folder's `v` now includes _discography.md so regenerating it re-syncs just that small JSON (isolated from the albums' meta/cover). Manifest gains `disco: true` on such entries. Also fixed the skip check to require all expected outputs to exist, so artist/ cover-only folders no longer rebuild every run. New `discographies` counter. - sidecar: GET /discography?path=<artist rel> (ETag/304), documented in the contract header. - MUSIC_API.md: §2.4 + manifest disco flag + resync algorithm updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
222 lines
8.5 KiB
Markdown
222 lines
8.5 KiB
Markdown
# 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 },
|
||
"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).
|
||
|
||
### 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, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||
Only meaningful when the manifest entry has `"cover": true`.
|
||
|
||
### 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`.
|
||
```jsonc
|
||
{
|
||
"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 (its `v` bumps 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`:
|
||
```jsonc
|
||
{
|
||
"running": true,
|
||
"startedAt": 1785034701973, "finishedAt": null,
|
||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
||
"tracksIndexed": 320, "coversSaved": 12, "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=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, "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:
|
||
|
||
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`, + `GET /discography?path=<rel>` if `disco: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.
|