Files
platform/plugins/music/MUSIC_API.md
T
pastilhas de3340398c music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.

Three things stayed, each on purpose.

cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.

The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.

api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.

Two bugs found on the way, neither visible from reading.

The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.

[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.

registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.

music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.

bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.

Not yet verified on the live server — that is next.
2026-08-15 01:46:43 +00:00

364 lines
15 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 },
"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, ~3080 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) 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.