commit 6e07da7a3650786e5ef729ee6ae7ff2b6a37862a Author: Claude Opus 5 Date: Sat Aug 15 17:34:51 2026 +0000 music, extracted from the platform into its own repository Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/MUSIC_API.md b/MUSIC_API.md new file mode 100644 index 0000000..3f07484 --- /dev/null +++ b/MUSIC_API.md @@ -0,0 +1,363 @@ +# 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`. +- **``:** 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 }, + "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= +``` + +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, + "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//` (byte-range; works for `.mp4`). + +### 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`. + +#### 2.3.1 Video poster + +``` +GET /api/music/poster?path=&file=