From de3340398cc6638a9bb2574db2b1080aab6f9e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 15 Aug 2026 01:46:43 +0000 Subject: [PATCH] music becomes a plugin, and the player stays behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bunfig.toml | 11 +- MUSIC_API.md => plugins/music/MUSIC_API.md | 139 ++++++++++++------ plugins/music/api/router.ts | 18 +++ .../src/music => plugins/music/db}/queries.ts | 2 +- .../src/music => plugins/music/db}/schema.ts | 2 +- plugins/music/manifest.ts | 85 +++++++++++ .../music/scripts}/reindex-music.ts | 0 .../music => plugins/music/sidecar}/index.ts | 14 +- .../music/sidecar}/indexer.ts | 2 +- .../music/sidecar}/nightly-reindex.ts | 0 .../music/sidecar}/stream-audio.ts | 0 .../music/web}/FavoritesView.tsx | 25 +++- .../music/web}/LyricsPane.tsx | 2 +- .../music/web}/LyricsPanel.tsx | 4 +- .../music/web}/MusicBrowser.tsx | 0 .../music/web}/MusicDetail.tsx | 16 +- .../music/web}/MusicMiniBar.tsx | 10 +- .../music/web/layout.ts | 0 .../music/web}/lyrics.test.ts | 0 .../music/web}/lyrics.ts | 0 plugins/music/web/panels.ts | 18 +++ plugins/music/web/shared.ts | 13 ++ .../music/web}/useLyrics.ts | 2 +- .../music/web}/usePlayerClock.ts | 2 +- src/apps/officer-web/App.tsx | 1 - .../Screens/Dashboard/Music/MusicScreen.tsx | 23 --- .../Screens/Dashboard/Music/index.tsx | 1 - .../officer-web/Screens/Dashboard/index.tsx | 1 - src/apps/officer-web/state/usePageTitle.ts | 1 - src/databases/officer_db/src/index.ts | 1 - src/databases/officer_db/src/music/index.ts | 17 --- src/databases/officer_db/src/schema.ts | 1 - src/servers/app-store/catalogue.ts | 27 ++-- src/servers/capabilities/registry.test.ts | 70 +++++++-- src/servers/capabilities/registry.ts | 27 ++-- src/servers/hono.ts | 2 - .../src/AppRegistry/AppRegistry.tsx | 2 - .../Music => MusicPlayer}/MusicHeart.tsx | 0 .../src/MusicPlayer/MusicPlayerHost.tsx | 4 +- .../officerdev/src/MusicPlayer/index.ts | 52 ++++++- .../src/{apps/Music => MusicPlayer}/shared.ts | 0 .../useMusicFavorites.ts | 0 .../officerdev/src/apps/Music/index.ts | 11 -- src/workspaces/officerdev/src/index.ts | 4 + 44 files changed, 418 insertions(+), 192 deletions(-) rename MUSIC_API.md => plugins/music/MUSIC_API.md (72%) create mode 100644 plugins/music/api/router.ts rename {src/databases/officer_db/src/music => plugins/music/db}/queries.ts (99%) rename {src/databases/officer_db/src/music => plugins/music/db}/schema.ts (98%) create mode 100644 plugins/music/manifest.ts rename {scripts => plugins/music/scripts}/reindex-music.ts (100%) rename {src/servers/sidecar/music => plugins/music/sidecar}/index.ts (98%) rename {src/servers/sidecar/music => plugins/music/sidecar}/indexer.ts (99%) rename {src/servers/sidecar/music => plugins/music/sidecar}/nightly-reindex.ts (100%) rename {src/servers/sidecar/music => plugins/music/sidecar}/stream-audio.ts (100%) rename {src/workspaces/officerdev/src/apps/Music => plugins/music/web}/FavoritesView.tsx (92%) rename {src/workspaces/officerdev/src/MusicPlayer => plugins/music/web}/LyricsPane.tsx (98%) rename {src/workspaces/officerdev/src/MusicPlayer => plugins/music/web}/LyricsPanel.tsx (95%) rename {src/workspaces/officerdev/src/apps/Music => plugins/music/web}/MusicBrowser.tsx (100%) rename {src/workspaces/officerdev/src/apps/Music => plugins/music/web}/MusicDetail.tsx (97%) rename {src/workspaces/officerdev/src/MusicPlayer => plugins/music/web}/MusicMiniBar.tsx (93%) rename src/apps/officer-web/Screens/Dashboard/Music/defaultLayout.ts => plugins/music/web/layout.ts (100%) rename {src/workspaces/officerdev/src/MusicPlayer => plugins/music/web}/lyrics.test.ts (100%) rename {src/workspaces/officerdev/src/MusicPlayer => plugins/music/web}/lyrics.ts (100%) create mode 100644 plugins/music/web/panels.ts create mode 100644 plugins/music/web/shared.ts rename {src/workspaces/officerdev/src/MusicPlayer => plugins/music/web}/useLyrics.ts (97%) rename {src/workspaces/officerdev/src/MusicPlayer => plugins/music/web}/usePlayerClock.ts (96%) delete mode 100644 src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Music/index.tsx delete mode 100644 src/databases/officer_db/src/music/index.ts rename src/workspaces/officerdev/src/{apps/Music => MusicPlayer}/MusicHeart.tsx (100%) rename src/workspaces/officerdev/src/{apps/Music => MusicPlayer}/shared.ts (100%) rename src/workspaces/officerdev/src/{apps/Music => MusicPlayer}/useMusicFavorites.ts (100%) delete mode 100644 src/workspaces/officerdev/src/apps/Music/index.ts diff --git a/bunfig.toml b/bunfig.toml index a541ec98..30ea0dc1 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*" coverage = true coverageDir = "coverage" preload = ["./test-setup.ts"] -root = "./src" +# The repo, not just `src` — a plugin's tests are the platform's tests. +# +# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with +# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still +# read green-ish. A test that quietly stops running is worse than one that fails, and every future +# extraction would have taken its tests out of the suite the same way. +# +# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds +# `src/servers/plugins/` and not `plugins/`. Root is the only lever. +root = "." diff --git a/MUSIC_API.md b/plugins/music/MUSIC_API.md similarity index 72% rename from MUSIC_API.md rename to plugins/music/MUSIC_API.md index d6431fd6..2b1f6172 100644 --- a/MUSIC_API.md +++ b/plugins/music/MUSIC_API.md @@ -28,13 +28,13 @@ 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` | +| 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 + 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. @@ -51,26 +51,28 @@ The server maintains a cache tree that **mirrors the library**, one entry per al 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*. +`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 + "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 }, + "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 } + "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 @@ -82,14 +84,16 @@ may have any mix of `tracks`, `videos`, and `disco`. ``` 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 + "cover": "cover.jpg", // present only if a cover exists "tracks": [ { - "file": "01 Hells Bells.mp3", // filename within the album folder + "file": "01 Hells Bells.mp3", // filename within the album folder "title": "Hells Bells", "artist": "AC/DC", "albumArtist": "AC/DC", @@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: `; a request with `If-None-Matc "track": "1", "year": "1980", "durationSec": 312, - "lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2) - } + "lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2) + }, // … ], - "videos": [ // present only for folders that contain video files + "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 + "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) - } + "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`). @@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music//` (byt ``` 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`. @@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`. ``` GET /api/music/poster?path=&file=