Files
platform/plugins/music/MUSIC_API.md
T
pastilhas a9bf51407e cliamp moves into the plugin, and the platform loses its last music file
The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.

The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so its call to getMusicServerWsUrl never ran; and the file's other export,
getMusicServerUrl, had no consumers at all. A dead import held a music-named
file in the platform, and I documented that as a "seam" last night after
checking the import existed and stopping there.

Everything cliamp now lives in plugins/music/cliamp/:

  sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
  api/cliamp/relay.ts
  apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx

src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.

Two of the things that moved were live, not inert.

The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.

That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.

The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/music and
would have forwarded /alice/music/stream to a sidecar expecting /stream. A
latent bug only third parties would ever hit, and a quiet violation of the rule
that mountPrefix is the one function allowed to know about provenance. Offscale
has the identical hardcode and still needs it.

Still open there: appName is passed as a literal, because a plugin's router
cannot see its own directory name — the platform imports the module and reads
`router`, so there is nowhere to inject it. The fix is a factory the installer
calls with the plugin's identity.

Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.

bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
2026-08-15 13:52:53 +00:00

15 KiB
Raw Blame History

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, 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
{
  "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.

{
  "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.

{
  "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:

{
  "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):

{
  "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).


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 unchangedskip (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:
    {
      "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:
    {
      "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.