removing it was wrong: the mobile player stopped playing. nothing in the web read the header and /meta already carried durationSec, so it looked dead. it was not — it is a documented part of the contract and something depended on it. the header is back and the ffprobe is not. the value is read from the album's meta.json, which is the same number ffprobe produced (the indexer got it from ffprobe at index time), cached per album and keyed on the meta file's mtime so a reindex invalidates it. 1.5ms cold, 0.1ms warm, against 50ms warm and 315ms cold for the process spawn. first pass looked for meta.json beside the audio; it lives under the sidecar's own cache root mirroring the relative path. uses metaFilePath now rather than re-deriving it. verified against a real file: header present and equal to meta, 304 on whole-file revalidation, 206 on range with a matching etag.
385 lines
16 KiB
Markdown
385 lines
16 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:** `plugins/music/sidecar/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`, `ETag`, `Cache-Control` |
|
||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `ETag` |
|
||
|
||
- **`X-Audio-Duration` still ships, and no longer costs an `ffprobe`** (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's `meta.json` and 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 `/meta` already
|
||
carried `durationSec`. The mobile player stopped playing, so it is load-bearing for at least one
|
||
client and stays in the contract. New clients should still prefer `durationSec` from `/meta` — it
|
||
arrives with the queue, before the first stream request.
|
||
|
||
Absent when the album has not been indexed, exactly as it was absent when ffprobe failed.
|
||
- **`ETag`** is `mtime-size` in base36. A whole-file request with a matching `If-None-Match` answers
|
||
`304`; a Range request never does, because a 304 would answer a question about part of a file with a
|
||
statement about all of it.
|
||
- 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_.
|
||
|
||
**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
|
||
```
|
||
|
||
```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/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`.
|
||
|
||
```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,
|
||
"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`.
|
||
|
||
```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,
|
||
"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=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.
|
||
|
||
---
|
||
|
||
## 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:
|
||
```json
|
||
{
|
||
"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 or `null`:
|
||
```json
|
||
{
|
||
"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"
|
||
}
|
||
```
|
||
`dir` is 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" }` if `homePath` is 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`'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.
|