Compare commits

..
2 Commits
Author SHA1 Message Date
pastilhas 05eb947bd1 music is verified on the live server, and the runbook learns from it
Ran the whole table against platform.officer.dev rather than reasoning about
it. install / API / range requests / dock / permissions / disable / enable /
uninstall / db:push-while-uninstalled / reinstall / pm2 restart officer — all
pass, recorded in plugins/music/PLUGIN.md with the actual numbers.

Two results worth naming. The reinstall was a RESTORE: a favourite and a
playlist seeded before uninstall came back untouched, which is the whole point
of the barrel following directories rather than the install table. And
`bun db:push` while uninstalled said `No changes detected` with the rows still
there — the property that makes running push by hand safe at any moment.

Range survives the proxy hop: 206 with a correct Content-Range and exactly the
bytes asked for, 416 unsatisfiable, 400 on a path escaping the Music root.

Two findings from the machine rather than the code. ffmpeg and ffprobe are not
installed here, so X-Audio-Duration never appears and indexing would produce no
tags or covers — the manifest already says a host binary cannot be declared, and
now says it was checked. And ~/Music did not exist at all, so the library is
empty; the sidecar handles both absences and logs them rather than failing.

Music is left INSTALLED and enabled. It had been switched off since 2026-08-13,
so this restores it.

The runbook gains what generalises: map what the PLATFORM still needs from your
feature before planning the split, because two imports pointing the wrong way
dictated music's boundary rather than any judgement about what music is.
Offscale had none, which made the job look cleaner than it is. Also two new
traps — delete the app-store catalogue entry or the screen goes blank, and
compare test FILE COUNTS across a run, since that is the only thing that shows
a test which stopped being discovered.

The global-overlay question is recorded as answered: no. A shell slot for a
plugin-provided component reopens "there is no way to export a component", and
that rule is what the frontend contract rests on.

Seeded rows and the audio fixture removed; ~/Music deleted again.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all pre-existing.
2026-08-15 01:56:16 +00:00
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
46 changed files with 671 additions and 300 deletions
+10 -1
View File
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
coverage = true coverage = true
coverageDir = "coverage" coverageDir = "coverage"
preload = ["./test-setup.ts"] 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 = "."
+67 -108
View File
@@ -7,7 +7,9 @@ The runbook, written the day offscale became the first one. Follow it for music,
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed 1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin 2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
3. `plugins/offscale/` — the worked example, all four parts 3. `plugins/offscale/` — the worked example, all four parts
4. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate` 4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
--- ---
@@ -19,7 +21,7 @@ The runbook, written the day offscale became the first one. Follow it for music,
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden. file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field **Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
of any sort. The platform's answer is uniform; what a grant *means* — whose rows a member sees, whether a of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
resource is shared or per-user — is the plugin's own job, in its own queries. resource is shared or per-user — is the plugin's own job, in its own queries.
**Say `permissions`, never the other word.** It already means three things in this codebase. **Say `permissions`, never the other word.** It already means three things in this codebase.
@@ -80,18 +82,18 @@ pm2 restart officer
Then through `/plugins`, watching PM2 and the browser at each step: Then through `/plugins`, watching PM2 and the browser at each step:
| Step | Expect | | Step | Expect |
| --- | --- | | ------------------------------- | ----------------------------------------------------------- |
| install | streamed log; schema applied; sidecar online; route mounted | | install | streamed log; schema applied; sidecar online; route mounted |
| the plugin's API | answers | | the plugin's API | answers |
| the plugin's screen | renders as a Workspace | | the plugin's screen | renders as a Workspace |
| dock | tile appears | | dock | tile appears |
| permissions page | its permission is listed, read/write/none | | permissions page | its permission is listed, read/write/none |
| disable | route 404s, sidecar stops, **tables and rows survive** | | disable | route 404s, sidecar stops, **tables and rows survive** |
| enable | comes back | | enable | comes back |
| uninstall | route gone, `pm2 list` loses it, **data still there** | | uninstall | route gone, `pm2 list` loses it, **data still there** |
| `bun db:push` while uninstalled | `No changes detected` — data survives | | `bun db:push` while uninstalled | `No changes detected` — data survives |
| install again | identical to the first install | | install again | identical to the first install |
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists. A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
@@ -110,117 +112,74 @@ A normal refresh is enough; the shell is `no-store`. When the log's last line ap
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it. - **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a - **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
contract change. Regenerate rather than hand-edit. contract change. Regenerate rather than hand-edit.
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `capabilityAvailability`
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
`plugin_installs`. A leftover catalogue entry therefore makes the capability permanently `unavailable`,
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
`catalogue.ts` is the one to read.
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
it. Find those before you plan the split; they decide it for you.
--- ---
## Music specifically — READ THIS BEFORE STARTING ## Music is done. What it changed about this runbook
**Music is bigger than offscale, and one part of it has nowhere to go.** Two others did, and the owner cut Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
both from tonight's scope — see below. record; the parts worth carrying forward are already folded into the rules and traps above.
Mapped 2026-08-15.
**Decide these yourself. Do not stop to ask.** Every one of them has two defensible answers, the owner has The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
said either is fine, and a corrected decision is cheap where a stalled extraction is not. Record what you split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
chose and why — in the manifest, in a comment, in the commit message — and keep going. The failure mode to dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
avoid is not a wrong call; it is a tree left half-moved with a question attached. both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
none, so it came out whole and made the job look cleaner than it is.
### What has no home yet The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
not tidy them into a plugin as a warm-up.
1. ~~**Two websocket providers.**~~ **OUT OF SCOPE — do not touch cliamp.** ### The global-overlay question is answered, and the answer is no
`cliamp` and `cliamp-audio` exist for one thing: running the `cliamp` TUI player on the server and
piping its terminal and its audio (a PulseAudio null sink tapped by `parec`) to the browser. It is a
second, separate playback path and the owner's word is that it is the least important part of music —
fun, wanted eventually, not tonight.
**This is the whole reason the websocket gap does not block this extraction.** Both sockets are already
inert (routes upgrade into commented-out handlers). Leave `sidecar/music/cliamp-ws.ts`,
`sidecar/music/pulse-audio.ts`, `sidecar/music/asoundrc`, `src/servers/api/cliamp/relay.ts` and the two
providers in `server.tsx` exactly where they are, untouched. If the relay's import of
`getMusicServerWsUrl` is the only thing keeping `api/music/router.ts` alive, leave that file too and
say so — a small documented seam is fine.
What music actually is: the `/music` screen, the library, and the phone and tablet apps that stream
from it. That is what has to work.
2. **A global UI overlay.** `MusicPlayerHost` is rendered by `DashboardLayout` on **every route**, not
inside a panel, and gates itself on `can('music')`. A plugin contributes panels and a layout; there is
no "render this everywhere" slot, and inventing one is a platform change.
3. ~~**A dashboard widget.**~~ **OUT OF SCOPE — leave it where it is.**
`src/workspaces/widgets/MusicPlayer/` sits in a third workspace package and is wired through
`WidgetRegistry`. Plugins cannot contribute widgets and are not going to learn how tonight. Leave the
directory, the registration and the barrel export alone; note the seam in the manifest.
### What else differs
- **External binaries**: with cliamp out of scope, what the plugin still needs is `ffmpeg` and `ffprobe`,
for tag reading, cover compression and poster frames. A manifest has no way to declare a host
dependency; note it and move on. (`cliamp`, `parec`, `pulseaudio`, `pactl` and `asoundrc` stay behind
with cliamp.)
- **Range requests**: `stream-audio.ts` does 206 / `Content-Range` / 416 and a custom `X-Audio-Duration`;
the proxy runs at `timeoutSeconds: 1800` for reindexes. Verify `Range` survives the hop.
- **Cross-capability**: `MusicBrowser` calls `/file-browser/ls`, not the music sidecar.
- **A CLI tool**: `scripts/reindex-music.ts` reads `DATA_PATH/music/.server`.
- **Music is the platform's worked example.** `registry.ts` explains `personal` through it and
`registry.test.ts` tests the mechanism *through the music capability*. Those tests must be rewritten
against something else, not deleted.
- **Already half-disabled**: `hono.ts` mount and `schema.ts` export are commented out since 2026-08-13;
`officer_db/src/index.ts:49` is still live. So the tree is mid-migration already.
### The per-user model is NOT tonight's work
`personal: ['/favorites', '/now-playing', '/playlists', '/queue']` on music's registry entry, the four
user-scoped tables, and the question of what a member's grant *means* — **all of it stays exactly as it
is. Do not design it, do not improve it, do not think about it.** It will be built inside the plugin
later, which is the whole reason the platform's answer is a uniform read/write and nothing more.
Two facts that make leaving it alone safe rather than lazy:
- **The web frontend does not use those routes at all.** Favourites, playlists and now-playing are used
only by the phone and tablet apps. Nothing you can see in a browser depends on them, so nothing in this
extraction can regress by carrying them across untouched.
- `/queue` is in that list and **no such route exists**, in the sidecar or anywhere else. Dead or
aspirational. Carry it as-is; do not investigate.
Move the routes, the tables and the queries verbatim. `userId` keeps arriving from `X-Officer-User` and
the queries keep scoping by it, exactly as today.
**The one mechanical consequence you cannot avoid:** `registry.test.ts` tests the `personal` mechanism
*through* the music entry, and `registry.ts` uses music as its worked example in prose. Removing the
entry breaks those tests. Re-anchor them on another entry that has `personal` — the smallest edit that
makes them green and still test the mechanism. That is a test fix, not a redesign.
### How to decide the three, if you want a default
Each of these is "close the platform gap" or "leave the piece behind". **Closing the gap is better when
you have the runway**, because every later plugin needs it too — but a music that works with cliamp left
in the platform is worth far more than a perfect design that did not land.
| Part | Close the gap | Leave it behind |
| --- | --- | --- |
| ~~websockets~~ | — | **out of scope. Do not spend a minute on cliamp.** |
| ~~widget~~ | — | **out of scope. Leave `widgets/MusicPlayer` exactly as it is.** |
| global overlay | add one slot the shell renders from installed plugins | leave `MusicPlayerHost` in `DashboardLayout`, gated as it already is |
**One open call, then.** The player overlay is the only judgement left, and either answer is fine.
Whatever you pick, the plugin must **install, enable, disable and uninstall cleanly** at the end. A piece
left in the platform is a documented seam; a piece left dangling is a bug.
`assertCapabilityTotality` is **not** part of this. It is worth fixing eventually and the cliamp routes
are the live example of the drift it cannot see, but both are someone else's evening.
Music was the first feature wanting to render on every route. It does not get to, and neither will the
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
gated on its plugin's permission so it switches itself off with the plugin.
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
--- ---
## Which one next
No decision has been made. What the tree says, for whoever picks it:
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
is exactly what its extraction needs.
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
way neither of the first two did. Read `docs/secret-store.md` first.
## Still open, platform-wide. Do not rediscover these ## Still open, platform-wide. Do not rediscover these
- **Websocket providers** — `server.reload({ routes })` proven, never called - **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
music would have been the first and cliamp being out of scope is what let it pass.
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route - **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`, table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes. now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
The live example is the two cliamp sockets: served in the route table, claimed by no capability, and
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this - **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the - **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
owner's. Its own job, not the platform's. owner's. Its own job, not the platform's.
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
+91 -48
View File
@@ -28,13 +28,13 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
Byte-range streaming so the player can **seek without downloading the whole file**. Byte-range streaming so the player can **seek without downloading the whole file**.
| Case | Status | Headers | | Case | Status | Headers |
|---|---|---| | --------------------- | ------ | --------------------------------------------------------------------------------------------- |
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` | | 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` | | 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 - **`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. VBR MP3s. No need to scan the file.
- Errors: `400` invalid/missing path · `404` not found · `416` bad range. - 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. 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). 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 ### 2.1 Manifest — one call, whole library
``` ```
GET /api/music/manifest GET /api/music/manifest
``` ```
```jsonc ```jsonc
{ {
"version": 1, "version": 1,
"generatedAt": 1785034701973, // ms; when the index was last built "generatedAt": 1785034701973, // ms; when the index was last built
"albums": { "albums": {
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 }, "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/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 `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 **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 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=<rel> GET /api/music/meta?path=<rel>
``` ```
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`. Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
```jsonc ```jsonc
{ {
"path": "Albums/AC-DC/[1980] Back in Black", "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": [ "tracks": [
{ {
"file": "01 Hells Bells.mp3", // filename within the album folder "file": "01 Hells Bells.mp3", // filename within the album folder
"title": "Hells Bells", "title": "Hells Bells",
"artist": "AC/DC", "artist": "AC/DC",
"albumArtist": "AC/DC", "albumArtist": "AC/DC",
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
"track": "1", "track": "1",
"year": "1980", "year": "1980",
"durationSec": 312, "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 "file": "1989 - Seattle.mp4", // filename within the folder
"title": "Live Shit: Seattle", // from the container title tag, if any "title": "Live Shit: Seattle", // from the container title tag, if any
"durationSec": 8130, "durationSec": 8130,
"width": 1280, "width": 1280,
"height": 720, "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 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. 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`). To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
``` ```
GET /api/music/cover?path=<rel> GET /api/music/cover?path=<rel>
``` ```
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`. 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`. 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=<rel>&file=<video filename> 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 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` 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. when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
``` ```
GET /api/music/lyrics?path=<rel>&file=<track filename> 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) 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 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"`). request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
``` ```
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
``` ```
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`. Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
```jsonc ```jsonc
{ {
"artist": "Anthrax", "artist": "Anthrax",
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
"[1984] Fistful Of Metal": "Studio", "[1984] Fistful Of Metal": "Studio",
"[1985] Armed And Dangerous": "EP", "[1985] Armed And Dangerous": "EP",
"[1994] The Island Years": "Live", "[1994] The Island Years": "Live",
"[1991] Attack Of The Killer B's": "Compilation" "[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 - 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. 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`, - **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
``` ```
`IndexStatus`: `IndexStatus`:
```jsonc ```jsonc
{ {
"running": true, "running": true,
"startedAt": 1785034701973, "finishedAt": null, "startedAt": 1785034701973,
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3, "finishedAt": null,
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3, "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", "currentPath": "Albums/AC-DC/[1980] Back in Black",
"error": null "error": null,
} }
``` ```
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
``` ```
GET /api/music/reindex/stream GET /api/music/reindex/stream
``` ```
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one). - **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 - Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
`IndexReport`) and **closes** the stream. `IndexReport`) and **closes** the stream.
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
``` ```
`IndexReport` (the `done` payload): `IndexReport` (the `done` payload):
```jsonc ```jsonc
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45, {
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null } "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`). > First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
@@ -256,17 +288,21 @@ Unlike everything above (library data served by the sidecar), these are **per-us
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
supplies; the server never interprets them: supplies; the server never interprets them:
| kind | key | | kind | key |
|---|---| | -------- | --------------------------------------------------------------------- |
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) | | `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` | | `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
| `artist` | music-rel — `Albums/AC-DC` | | `artist` | music-rel — `Albums/AC-DC` |
### Favorites ### Favorites
- **`GET /api/music/favorites`** → grouped keys, newest first: - **`GET /api/music/favorites`** → grouped keys, newest first:
```json ```json
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] } {
"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 - **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
(a repeat add is a no-op). (a repeat add is a no-op).
@@ -281,9 +317,16 @@ launch to offer "resume".
- **`GET /api/music/now-playing`** → the snapshot or `null`: - **`GET /api/music/now-playing`** → the snapshot or `null`:
```json ```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", "homePath": "Music/…/01 Hells Bells.mp3",
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" } "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). `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? }` - **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
@@ -299,15 +342,15 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays `<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. put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
| method | path | body | returns | | method | path | body | returns |
|---|---|---|---| | -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first | | `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 | | `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` | | `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken | | `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade | | `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` | | `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ 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. `PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed. - **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). - **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 - **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
audio files* is a separate, later feature.) audio files_ is a separate, later feature.)
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable. - **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
+186
View File
@@ -0,0 +1,186 @@
# Music — the second plugin
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
worked second case, and it is interesting mainly for being the messy one.
---
## What music is
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
free to change.
```
manifest.ts identity, one permission
api/router.ts re-exports the platform's proxy — see below
sidecar/index.ts the whole /api/music contract (503 lines)
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
db/ music_favorites, _playlists, _playlist_items, _now_playing
web/ two panels and a layout; the shell renders the Workspace
scripts/ the reindex CLI, which talks to the sidecar port directly
```
---
## The three things that stayed, and why
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
means each one is written down with what would close it.
### 1. cliamp — out of scope by decision
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
terminal and its PulseAudio null sink piped to the browser. The owner's call was that it is the least
important part of music and not worth blocking the extraction on.
It was already inert before any of this — the two sockets are declared in `server.tsx`'s route table and
upgrade into `handlers` entries that are commented out. So:
- `src/servers/sidecar/music/` still holds `cliamp-ws.ts`, `pulse-audio.ts`, `asoundrc` and
`cliamp-ws.test.ts`. **Untouched.**
- This plugin's sidecar still serves those sockets, so it imports both modules from
`@@/sidecar/music/`. A plugin importing platform code is ordinary; the reverse would not be.
- `src/servers/api/cliamp/relay.ts` stays, and it is what keeps the next item alive.
### 2. `src/servers/api/music/router.ts` — kept alive by the relay
`relay.ts` imports `getMusicServerWsUrl` from it. So the platform's proxy could not move, and this
plugin's `api/router.ts` **re-exports it** rather than building a second one.
That is not laziness. `createSidecarProxy` learns its port from a one-shot `music:server` event and
subscribes at import. Two proxies would mean two subscribers, both working today, and a `503` on the
first reconnect where only one of them happened to be listening — the same class of failure as the
install-order bug offscale found, and just as invisible from reading.
### 3. The player — the one open judgement call, and it is decided
**`officerdev/src/MusicPlayer/` stays in the platform.** The runbook left this open with either answer
acceptable. What decided it was not the overlay but the state:
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by
> `src/workspaces/widgets/MusicPlayer/`, the dashboard widget — which is _also_ out of scope and stays.
> **The platform cannot import from a plugin.** So the player state stays here whatever is decided about
> the UI around it, and a second copy would mean two audio engines fighting over one pair of speakers.
Given the state had to stay, splitting the engine and the bar away from the thing they drive would have
left the same seam in a worse place. And moving them needed a shell slot that renders a plugin-provided
component on **every route** — which is exactly the escape hatch this system deleted on purpose. "There
is no way to export a component" is what makes "every plugin route is a Workspace" a property of the
shape rather than a rule someone has to remember, and reopening it for one plugin is a bad trade.
The seam is inert without the plugin: `MusicPlayerHost` gates on `can('music')`, and `music` is now the
plugin's permission — registered at install, gone at uninstall.
What stayed with it, and why each: `gapless-engine` (the engine the state drives), `player-time` (the
module-level bridge the lyrics pane meets it through), `useLyricsOpen` and `MusicHeart` +
`useMusicFavorites` (the bar renders a heart), and `shared.ts` — the library vocabulary, which the host
needs a third of and the plugin needs all of. One definition on the host side beats a copy either side
of the boundary drifting apart; `plugins/music/web/shared.ts` re-exports it from the package's declared
`officerdev/MusicPlayer/shared` subpath.
**What would close it:** the widget learning to come from a plugin. Not the overlay slot — that one
should stay shut.
---
## Two bugs, neither visible from reading
**The app-store catalogue still listed music, and that would have blanked the screen.**
`capabilityAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
installed, enabled and healthy.
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
it cost minutes instead of an evening. Entry removed.
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** 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.
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
so it finds `src/servers/plugins/` and not `plugins/`.
---
## Permissions
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
every existing `role_capabilities` grant keeps meaning what it meant, and `can('music')` keeps resolving
for the overlay. Renaming it would have been a silent data change.
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
therefore permits exactly the four paths it permitted yesterday, and no field was added.
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
change inside `db/queries.ts`, not a flag on the manifest.
---
## Host dependencies a manifest cannot declare
`ffmpeg` and `ffprobe`, for tag reading, cover compression and video poster frames. There is no field for
a host binary and inventing one for this would be a field nothing else reads.
**Neither is installed on this machine** (verified 2026-08-15). The sidecar starts and serves fine — the
`X-Audio-Duration` header simply does not appear, and indexing would produce no tags or covers. Nothing
breaks today because there is no library: `~/Music` did not exist either.
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
---
## Verified on the live server, 2026-08-15
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
| Step | Result |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199` → **206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd` → **400** |
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
| dock | tile present in `/api/user/capabilities`; `/music` in `routes`, not in `deniedRoutes` |
| permissions page | `music` listed among the grantable |
| disable | route 404s, sidecar `stopped`, **rows survive** |
| enable | 200 again, sidecar online, favourites still there |
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
exist before.
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
---
## Still open
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
wire — noted rather than fixed.
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
- **The cliamp sockets are claimed by no capability**, and are served. Now pinned by a test in
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
+18
View File
@@ -0,0 +1,18 @@
import { musicRouter } from '@@/api/music/router';
// /api/music/* — auth, then forward to officer-music.
//
// ── Why this re-exports the platform's proxy instead of creating its own ──
//
// `src/servers/api/music/router.ts` has to stay behind: `api/cliamp/relay.ts` imports
// `getMusicServerWsUrl` from it to pipe the cliamp player socket to this sidecar, and cliamp is
// deliberately out of scope — it is a second playback path that the platform still owns.
//
// So the proxy already exists, and building a SECOND `createSidecarProxy({ name: 'music' })` here would
// mean two subscribers to the one-shot `music:server` port announcement. Both would work today, and the
// first reconnect where only one of them was listening would produce a 503 nobody could explain. One
// proxy, one subscription, mounted by whoever needs it.
//
// The seam is one file, and it is inert when this plugin is not installed: the router is only reachable
// once `mountPrefix()` puts it under `/api/music`, which only happens for an installed, enabled plugin.
export const router = musicRouter;
@@ -1,5 +1,5 @@
import { eq, and, desc, asc, sql } from 'drizzle-orm'; import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { db } from '../db'; import { db } from 'officerdb/db';
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema'; import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
export type FavoriteKind = 'track' | 'album' | 'artist'; export type FavoriteKind = 'track' | 'album' | 'artist';
@@ -1,5 +1,5 @@
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core'; import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema'; import { users } from 'officerdb/auth/schema';
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets: // Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id) // track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
+85
View File
@@ -0,0 +1,85 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Music — the library, the player, and the phone and tablet apps that stream from it.
//
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
// a clean cut: three pieces stay behind deliberately. Each is a documented seam rather than a loose end,
// and each is recorded in ./PLUGIN.md with what would have to change to close it.
//
// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
// db/ music_favorites, _playlists, _playlist_items, _now_playing
// web/ the library panels; the shell renders the Workspace
//
// ── What stayed in the platform, and why ──
//
// 1. cliamp (`/api/cliamp/ws`, `/api/cliamp/audio/ws`, `sidecar/music/cliamp-ws.ts`, `pulse-audio.ts`,
// `asoundrc`). A second playback path — the `cliamp` TUI run on the server with its terminal and its
// PulseAudio null sink piped to the browser. Already inert (the routes upgrade into commented-out
// handlers) and out of scope by the owner's decision. This sidecar still serves those sockets, so it
// imports both modules from `@@/sidecar/music/`.
//
// 2. The dashboard widget (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and
// the mechanism was not worth inventing for one.
//
// 3. The global player overlay (`officerdev/src/MusicPlayer/`, mounted by `DashboardLayout`). This was
// the one open judgement call and it is decided: THE PLAYER STAYS IN THE PLATFORM. Two reasons, and
// the second is the one that settles it.
//
// - Moving it needs a shell slot that renders a plugin-provided component on every route. That is
// exactly the escape hatch this system deleted on purpose — "there is no way to export a component"
// is what makes "every plugin route is a Workspace" a property of the shape rather than a rule
// someone has to remember. Reopening it for one plugin is a bad trade.
// - It would not even work. The widget above 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. Splitting the engine from the state it drives would leave the same seam
// in a worse place, and two copies of that state would mean two engines.
//
// The overlay gates on `can('music')`, which resolves against the permission below — registered at
// install and gone at uninstall. So the seam switches itself off with the plugin, with no code path
// that knows why.
//
// ── Host dependencies a manifest cannot declare ──
//
// `ffmpeg` and `ffprobe` must be on PATH: tag reading, cover compression and video poster frames. There
// is no field for a host binary and inventing one for this would be a field nothing else reads.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Music',
summary: 'The music library — browse, play, favourites and playlists',
icon: 'Music',
color: '#22c55e',
// One permission gating the whole surface, grantable per role at read or write like every other.
//
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
// this extraction, so every existing `role_capabilities` grant keeps meaning what it meant, and the
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
//
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
permissions: [
{
key: 'music',
label: 'Music',
description: 'The music library, playback, and your own favourites and playlists',
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
//
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
// be designing the per-user visibility model that is explicitly not this extraction's work. It
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
// a read grant permits exactly the same four paths it permitted yesterday.
//
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
},
],
};
@@ -1,10 +1,10 @@
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import { join, basename } from 'node:path'; import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '../protocol'; import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '../connect'; import { createSidecarConnector } from '@@/sidecar/connect';
import { streamAudioFile } from './stream-audio'; import { streamAudioFile } from './stream-audio';
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws'; import { cliampUpgradeData, musicWebsocket } from '@@/sidecar/music/cliamp-ws';
import { ensurePulseAudio } from './pulse-audio'; import { ensurePulseAudio } from '@@/sidecar/music/pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex'; import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
import { import {
reindexNow, reindexNow,
@@ -37,9 +37,9 @@ import {
addPlaylistItems, addPlaylistItems,
setPlaylistItems, setPlaylistItems,
type FavoriteKind, type FavoriteKind,
} from 'officerdb'; } from '../db/queries';
import { DATA_PATH } from '../../data-path'; import { DATA_PATH } from '@@/data-path';
import { API_URL } from '../../officer-url.mjs'; import { API_URL } from '@@/officer-url.mjs';
// ── Per-user state validation ── // ── Per-user state validation ──
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're // The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the // name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
// phone's resync diff (fetch only changed `v`s). // phone's resync diff (fetch only changed `v`s).
import { DATA_PATH } from '../../data-path'; import { DATA_PATH } from '@@/data-path';
const HOME = homedir(); const HOME = homedir();
export const MUSIC_ROOT = join(HOME, 'Music'); export const MUSIC_ROOT = join(HOME, 'Music');
@@ -3,9 +3,9 @@ import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react'; import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer'; import { useMusicPlayer, type PlayerTrack } from 'officerdev';
import { MusicHeart } from './MusicHeart'; import { MusicHeart } from 'officerdev';
import { useMusicFavorites } from './useMusicFavorites'; import { useMusicFavorites } from 'officerdev';
import { import {
MUSIC_FAV_CHANNEL, MUSIC_FAV_CHANNEL,
coverUrl, coverUrl,
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
const albumRel = toRel(albumHome); const albumRel = toRel(albumHome);
try { try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`); const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist })); const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file))); albumRel,
file: t.file,
title: t.title,
artist: t.artist,
}));
player.playQueue(
q,
Math.max(
0,
q.findIndex((t) => t.file === file),
),
);
} catch { } catch {
player.playQueue([{ albumRel, file }], 0); player.playQueue([{ albumRel, file }], 0);
} }
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
{empty ? ( {empty ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center"> <div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
<Heart size={44} className="text-muted-foreground/30" /> <Heart size={44} className="text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p> <p className="text-sm text-muted-foreground">
No favorites yet. Click the heart on any artist, album or track.
</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { Loader2, Music4 } from 'lucide-react'; import { Loader2, Music4 } from 'lucide-react';
import type { LyricLine } from './lyrics'; import type { LyricLine } from './lyrics';
import { seekPlayer } from './player-time'; import { seekPlayer } from 'officerdev';
import { useActiveLyricIndex } from './useLyrics'; import { useActiveLyricIndex } from './useLyrics';
type LyricsPaneProps = { type LyricsPaneProps = {
@@ -2,8 +2,8 @@ import { useClient } from 'hooks/useClient';
import { MicVocal } from 'lucide-react'; import { MicVocal } from 'lucide-react';
import { LyricsPane } from './LyricsPane'; import { LyricsPane } from './LyricsPane';
import { useLyrics } from './useLyrics'; import { useLyrics } from './useLyrics';
import { useLyricsOpen } from './useLyricsOpen'; import { useLyricsOpen } from 'officerdev';
import { useMusicPlayer } from './useMusicPlayer'; import { useMusicPlayer } from 'officerdev';
/** /**
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not * The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
@@ -4,15 +4,15 @@ import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react'; import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from '../../components/Workspace'; import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from '../../components/Workspace'; import { WorkspaceLayout } from 'officerdev';
import { MusicHeart } from './MusicHeart'; import { MusicHeart } from 'officerdev';
import { FavoritesView } from './FavoritesView'; import { FavoritesView } from './FavoritesView';
import { useMusicPlayer } from '../../MusicPlayer'; import { useMusicPlayer } from 'officerdev';
import type { PlayerTrack } from '../../MusicPlayer'; import type { PlayerTrack } from 'officerdev';
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel'; import { LyricsPanel } from './LyricsPanel';
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar'; import { MusicMiniBar } from './MusicMiniBar';
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen'; import { useLyricsOpen } from 'officerdev';
import { import {
MUSIC_ROOT, MUSIC_ROOT,
MUSIC_FAV_CHANNEL, MUSIC_FAV_CHANNEL,
@@ -1,11 +1,11 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { MicVocal, Pause, Play } from 'lucide-react'; import { MicVocal, Pause, Play } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar'; import { SeekBar } from 'officerdev';
import { coverUrl, fmtClock } from '../apps/Music/shared'; import { coverUrl, fmtClock } from './shared';
import { seekPlayer } from './player-time'; import { seekPlayer } from 'officerdev';
import { useLyricsOpen } from './useLyricsOpen'; import { useLyricsOpen } from 'officerdev';
import { useMusicPlayer } from './useMusicPlayer'; import { useMusicPlayer } from 'officerdev';
import { usePlayerClock } from './usePlayerClock'; import { usePlayerClock } from './usePlayerClock';
/** /**
+18
View File
@@ -0,0 +1,18 @@
import type { AppRegistryMeta } from 'officerdev';
import { Music, ListMusic } from 'lucide-react';
import { MusicBrowser } from './MusicBrowser';
import { MusicDetail } from './MusicDetail';
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
// `layout.ts` — a plugin never renders the screen.
//
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
// screen.
//
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
];
+13
View File
@@ -0,0 +1,13 @@
// The library vocabulary, re-exported from the host.
//
// It lives at `officerdev/src/MusicPlayer/shared.ts` rather than here because `MusicPlayerHost` — the
// global player bar, which stays in the platform; see that directory's index.ts for why — needs a third
// of it. One definition on the host side beats a copy either side of the plugin boundary drifting apart.
//
// Taken from the `officerdev/MusicPlayer/shared` subpath rather than the `officerdev` barrel because the
// type names here (`DirEntry`, `Track`, `Manifest`) are ones the barrel already spends on the FileBrowser.
// The subpath is a declared export of the package (`"./*": "./src/*.ts"`), not a reach into its insides.
//
// Every panel in this directory imports from HERE, so the seam is one file to read rather than a
// different specifier in each of them.
export * from 'officerdev/MusicPlayer/shared';
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { LyricLine } from './lyrics'; import type { LyricLine } from './lyrics';
import { activeLineIndex, parseLyrics } from './lyrics'; import { activeLineIndex, parseLyrics } from './lyrics';
import { getPlayerTime, subscribePlayerTime } from './player-time'; import { getPlayerTime, subscribePlayerTime } from 'officerdev';
export type UseLyrics = { export type UseLyrics = {
loading: boolean; loading: boolean;
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time'; import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from 'officerdev';
/** /**
* Position + duration, straight off the engine's per-frame feed. * Position + duration, straight off the engine's per-frame feed.
-1
View File
@@ -60,7 +60,6 @@ export function App() {
<Route path="/files" element={<Dashboard.FilesScreen />} /> <Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} /> <Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} /> <Route path="/contacts" element={<Dashboard.ContactsScreen />} />
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} /> <Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} /> <Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} /> <Route path="/photos" element={<Dashboard.PhotosScreen />} />
@@ -1,23 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
// (music-browser) and the content/detail (music-detail). They do not coordinate with each other; both
// read `?path=` off the URL. No route pair and no guard: the bare /music is the library root, a real
// state, and an unknown path gets an empty listing rather than a rewritten address.
export const MusicScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['music-browser', 'music-detail'], fallback: 'music-detail' }}
/>
</div>
);
};
@@ -1 +0,0 @@
export * from './MusicScreen';
@@ -13,7 +13,6 @@ export * from './Tasks';
export * from './Files'; export * from './Files';
export * from './Calendar'; export * from './Calendar';
export * from './Contacts'; export * from './Contacts';
export * from './Music';
export * from './Soulseek'; export * from './Soulseek';
export * from './Photos'; export * from './Photos';
export * from './Jellyfin'; export * from './Jellyfin';
@@ -21,7 +21,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/files'), title: 'Files' }, { match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' }, { match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' }, { match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' }, { match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' }, { match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' }, { match: (p) => p.startsWith('/photos'), title: 'Photos' },
-1
View File
@@ -46,7 +46,6 @@ export * from './dav';
export * from './email'; export * from './email';
export * from './invoiceshelf'; export * from './invoiceshelf';
export * from './jellyfin'; export * from './jellyfin';
export * from './music';
export * from './notify'; export * from './notify';
export * from './photos'; export * from './photos';
export * from './soulseek'; export * from './soulseek';
@@ -1,17 +0,0 @@
export {
getMusicFavorites,
addMusicFavorite,
removeMusicFavorite,
getNowPlaying,
setNowPlaying,
clearNowPlaying,
getPlaylists,
getPlaylist,
createPlaylist,
renamePlaylist,
deletePlaylist,
addPlaylistItems,
setPlaylistItems,
} from './queries';
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput, PlaylistSummary, Playlist } from './queries';
-1
View File
@@ -44,7 +44,6 @@ export * from './service-connections/schema'; // service_connections
// ── Plugins — uncomment when the plugin is installed ───────────────────────────────────────────── // ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
// export * from './email/schema'; // email_accounts officer-email // export * from './email/schema'; // email_accounts officer-email
// export * from './music/schema'; // music_favorites, _playlists, _playlist_items, _now_playing
// export * from './notify/schema'; // push_devices officer-notify // export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav // export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './photos/schema'; // photos_config officer-photos // export * from './photos/schema'; // photos_config officer-photos
+16 -11
View File
@@ -386,17 +386,22 @@ export const CATALOGUE: CatalogueEntry[] = [
// form. Duplicating it here would be a second place to maintain the same credentials. // form. Duplicating it here would be a second place to maintain the same credentials.
configFields: [], configFields: [],
}, },
{ // Music was here until 2026-08-15, when it became `plugins/music/`. Removing it was not tidying — it
id: 'music', // was the headscale bug above, exactly, and it would have fired on the first install.
ui: { name: 'Music', icon: 'Music', color: '#22c55e', rootRoute: '/music', routes: ['/music'] }, //
process: 'officer-music', // `capabilityAvailability` reads `sidecar_installs`, and a PLUGIN never gets a row there: its install
label: 'Music', // state lives in `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
summary: 'Index and play the library on this machine', // `/music` into `deniedRoutes` — the screen blank and the dock tile withheld on a server where the
members: 'none', // plugin was installed, enabled and healthy. The same shape as headscale, found by reading that note
modes: ['config'], // rather than by hitting it again.
capability: 'music', //
configFields: [{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }], // The tile now comes from `pluginDockManifests()`, and the library folder is not configured at all:
}, // the sidecar reads `~/Music`. That `configFields` entry wrote a `service_connections` row nothing
// ever read.
//
// `[open]` This is the "two dock sources" seam. The store keeps its own catalogue while the plugin
// system builds tiles from manifests, and the self endpoint concatenates both. One when the store is
// rebuilt on the plugin system.
{ {
id: 'wallet', id: 'wallet',
ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] }, ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
+55 -15
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import type { Capability } from './registry';
import { import {
CAPABILITIES, CAPABILITIES,
CAPABILITY_BY_KEY, CAPABILITY_BY_KEY,
@@ -57,8 +58,8 @@ describe('totality', () => {
test('refuses a claim on a socket that is not served', () => { test('refuses a claim on a socket that is not served', () => {
const s = surface(); const s = surface();
s.wsProviders = REAL_WS.filter((p) => p !== 'cliamp'); s.wsProviders = REAL_WS.filter((p) => p !== 'terminal');
expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'cliamp', which is not served/); expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'terminal', which is not served/);
}); });
test('no two capabilities claim the same prefix', () => { test('no two capabilities claim the same prefix', () => {
@@ -74,7 +75,7 @@ describe('totality', () => {
test('signin is exempt and gitea is not', () => { test('signin is exempt and gitea is not', () => {
expect(isExemptApiPath('/api/auth/signin')).toBe(true); expect(isExemptApiPath('/api/auth/signin')).toBe(true);
expect(isExemptApiPath('/api/gitea')).toBe(false); expect(isExemptApiPath('/api/gitea')).toBe(false);
expect(isExemptApiPath('/api/music/albums')).toBe(false); expect(isExemptApiPath('/api/dashboards/4')).toBe(false);
}); });
}); });
@@ -85,8 +86,9 @@ describe('path → capability', () => {
}); });
test('does not match a prefix that is merely a string prefix', () => { test('does not match a prefix that is merely a string prefix', () => {
// '/api/musicbrainz' must not resolve to the 'music' capability. A naive startsWith would. // '/api/username' must not resolve to the 'account' capability, which claims '/user'. A naive
expect(capabilityForApiPath('/api/musicbrainz')).toBeNull(); // startsWith would. (This was '/api/musicbrainz' against 'music' until music became a plugin.)
expect(capabilityForApiPath('/api/username')).toBeNull();
}); });
test('longest prefix wins, so /dav and /caldav do not fight', () => { test('longest prefix wins, so /dav and /caldav do not fight', () => {
@@ -99,34 +101,72 @@ describe('path → capability', () => {
}); });
test('sockets resolve to their capability', () => { test('sockets resolve to their capability', () => {
expect(capabilityForWsProvider('cliamp')?.key).toBe('music');
expect(capabilityForWsProvider('terminal')?.key).toBe('terminal'); expect(capabilityForWsProvider('terminal')?.key).toBe('terminal');
expect(capabilityForWsProvider('chat')?.key).toBe('chat');
expect(capabilityForWsProvider('nope')).toBeNull(); expect(capabilityForWsProvider('nope')).toBeNull();
}); });
// `cliamp` and `cliamp-audio` are SERVED in server.tsx's route table and claimed by nothing, because
// music's `ws` list was commented out on 2026-08-13 and the capability itself left with the plugin on
// 2026-08-15. They upgrade into handlers that are commented out too, so nothing is reachable — but the
// boot check cannot see the drift, since it reads `Object.keys(handlers)` rather than the route table.
//
// Pinned here so the hole is a documented fact with a test on it rather than something to rediscover.
// Closing it is the totality work in plugins/EXTRACTING-A-PLUGIN.md, not this file's.
test('the cliamp sockets are claimed by nothing — known drift, see server.tsx', () => {
expect(capabilityForWsProvider('cliamp')).toBeNull();
expect(capabilityForWsProvider('cliamp-audio')).toBeNull();
});
}); });
describe('levels', () => { describe('levels', () => {
const music = CAPABILITY_BY_KEY.get('music')!; // A FIXTURE, not a registry entry.
//
// These tests ran against the real `music` capability until 2026-08-15, when music left with
// `plugins/music/`. Re-anchoring them on whichever entry happens to have a `personal` list today only
// moves the problem to the next extraction — and it had already half-broken before that, because the
// moment music's `api` was commented out (2026-08-13) every path below stopped matching and three of
// these four tests passed for the wrong reason: everything is refused when nothing is claimed.
//
// `isRequestAllowedAtLevel` is a pure function of a Capability. Handing it one states what is actually
// under test — the RULE — rather than borrowing a feature that can leave.
const fixture: Capability = {
key: 'fixture',
label: 'Fixture',
description: 'Not in the registry — a shape to exercise the level rules against',
kind: 'app',
api: ['/fixture'],
personal: ['/favorites', '/now-playing'],
};
test('write permits anything within the capability', () => { test('write permits anything within the capability', () => {
expect(isRequestAllowedAtLevel(music, 'write', 'DELETE', '/api/music/track/9')).toBe(true); expect(isRequestAllowedAtLevel(fixture, 'write', 'DELETE', '/api/fixture/track/9')).toBe(true);
}); });
test('read permits safe methods', () => { test('read permits safe methods', () => {
expect(isRequestAllowedAtLevel(music, 'read', 'GET', '/api/music/albums')).toBe(true); expect(isRequestAllowedAtLevel(fixture, 'read', 'GET', '/api/fixture/albums')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'HEAD', '/api/music/albums')).toBe(true); expect(isRequestAllowedAtLevel(fixture, 'read', 'HEAD', '/api/fixture/albums')).toBe(true);
}); });
test('read permits mutations only under personal sub-paths', () => { test('read permits mutations only under personal sub-paths', () => {
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites/7')).toBe(true); expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'PUT', '/api/music/now-playing')).toBe(true); expect(isRequestAllowedAtLevel(fixture, 'read', 'PUT', '/api/fixture/now-playing')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/scan')).toBe(false); expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/scan')).toBe(false);
expect(isRequestAllowedAtLevel(music, 'read', 'DELETE', '/api/music/track/9')).toBe(false); expect(isRequestAllowedAtLevel(fixture, 'read', 'DELETE', '/api/fixture/track/9')).toBe(false);
}); });
test('a personal entry does not leak across a name boundary', () => { test('a personal entry does not leak across a name boundary', () => {
// '/favorites-export' must not be covered by the '/favorites' personal entry. // '/favorites-export' must not be covered by the '/favorites' personal entry.
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites-export')).toBe(false); expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites-export')).toBe(false);
});
test('a plugin gets the same rule through readOnlyWrites', () => {
// The two lists are concatenated, so a plugin declaring per-caller paths on the one field its
// manifest has behaves identically to a core capability declaring `personal`. This is what music
// relies on now that it ships as one.
const plugin: Capability = { ...fixture, personal: undefined, readOnlyWrites: ['/favorites', '/now-playing'] };
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/scan')).toBe(false);
}); });
}); });
+9 -18
View File
@@ -51,9 +51,13 @@
// //
// A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in // A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in
// the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing // the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing
// else. Music is the worked example: `/favorites`, `/now-playing` and `/playlists` are already per-caller // else. `dashboards` is the worked example: a dashboard belongs to the account that made it, so the whole
// in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write // surface is personal and a read grant is really "your own, fully". So "may a member write here" is a
// here" is a property of the endpoint, not a policy knob someone has to remember to set. // property of the endpoint, not a policy knob someone has to remember to set.
//
// Music was this note's example until 2026-08-15, when it left with `plugins/music/`. A plugin declares
// the same thing through its manifest's `readOnlyWrites` — `isRequestAllowedAtLevel` merges the two lists,
// so they are one mechanism under two names.
export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin'; export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
@@ -75,8 +79,8 @@ export type Capability = {
routes?: string[]; routes?: string[];
/** /**
* Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only * Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only
* the caller's own data. Matched as a prefix after the capability's own: `/favorites` on the `music` * the caller's own data. Matched as a prefix after the capability's own: `/devices` on the `notify`
* capability permits `POST /api/music/favorites/123`. * capability permits `POST /api/notify/devices/123`.
*/ */
personal?: string[]; personal?: string[];
/** /**
@@ -146,19 +150,6 @@ const CORE_REGISTRY: Capability[] = [
// commenting on their own issues — security theatre with a real cost and no benefit. // commenting on their own issues — security theatre with a real cost and no benefit.
personal: ['/'], personal: ['/'],
}, },
{
key: 'music',
label: 'Music',
description: 'The music library, playback, and your own favourites and playlists',
kind: 'app',
// api: ['/music'], // plugin — switched off 2026-08-13
api: [],
// ws: ['cliamp', 'cliamp-audio'], // plugin — switched off 2026-08-13
routes: ['/music'],
// Already per-caller in the sidecar contract (X-Officer-User), which is what makes them safe to write
// at read level. The library itself — scanning, tags, file moves — is not, and is not listed.
personal: ['/favorites', '/now-playing', '/playlists', '/queue'],
},
{ {
key: 'photos', key: 'photos',
label: 'Photos', label: 'Photos',
-2
View File
@@ -20,7 +20,6 @@ import { settingsRouter } from './api/settings/settings';
import { dashboardsRouter } from './api/dashboards'; import { dashboardsRouter } from './api/dashboards';
import { router as fileBrowserRouter } from './api/file-browser/router'; import { router as fileBrowserRouter } from './api/file-browser/router';
import { pluginsRouter } from './api/plugins/router'; import { pluginsRouter } from './api/plugins/router';
// import { musicRouter } from './api/music/router';
// import { vaultRouter } from './api/vault/router'; // import { vaultRouter } from './api/vault/router';
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router'; // import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
import { agentHandoffRouter } from './api/agent-handoff/router'; import { agentHandoffRouter } from './api/agent-handoff/router';
@@ -133,7 +132,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it ['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
['/dashboards', dashboardsRouter], ['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter], ['/file-browser', fileBrowserRouter],
// ['/music', musicRouter], // plugin — switched off 2026-08-13
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13 // ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
['/terminal', terminalRouter], ['/terminal', terminalRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13 // ['/memos', memosRouter], // plugin — switched off 2026-08-13
@@ -9,7 +9,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as musicMetas } from '../apps/Music';
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek'; import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as photosMetas } from '../apps/Photos'; import { appRegistryMetas as photosMetas } from '../apps/Photos';
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin'; import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
@@ -33,7 +32,6 @@ export const apps = [
...chatHistoryMetas, ...chatHistoryMetas,
...widgetMetas, ...widgetMetas,
...desktopMetas, ...desktopMetas,
...musicMetas,
...soulseekMetas, ...soulseekMetas,
...photosMetas, ...photosMetas,
...jellyfinMetas, ...jellyfinMetas,
@@ -4,8 +4,8 @@ import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities'; import { useCapabilities } from 'hooks/useCapabilities';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react'; import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar'; import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { MusicHeart } from '../apps/Music/MusicHeart'; import { MusicHeart } from './MusicHeart';
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared'; import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer'; import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
import { GaplessEngine, type EngineTrack } from './gapless-engine'; import { GaplessEngine, type EngineTrack } from './gapless-engine';
import { publishPlayerTime, registerPlayerSeek } from './player-time'; import { publishPlayerTime, registerPlayerSeek } from './player-time';
@@ -1,2 +1,50 @@
export * from './useMusicPlayer'; // The music player the SHELL hosts.
export * from './MusicPlayerHost'; //
// ── Why this is still in the platform after music became a plugin ──
//
// Music was extracted on 2026-08-15 (`plugins/music/`) and this directory deliberately did not go with
// it. It is the one seam that extraction could not close, and the reason is not the overlay — it is the
// state:
//
// `useMusicPlayer` is imported from `officerdev` by `src/workspaces/widgets/MusicPlayer/`, the
// dashboard widget, which is out of scope and stays. The platform cannot import from a plugin, so the
// player state stays here whatever is decided about the UI around it — and two copies of it would mean
// two audio engines fighting over one pair of speakers.
//
// Given the state had to stay, the engine and the bar stayed with it rather than being split from the
// thing they drive. `MusicPlayerHost` is mounted once by `DashboardLayout`, OUTSIDE `<Routes>`, which is
// what makes playback survive navigation — and a plugin has no way to ask for that. Contributing one
// would mean a shell slot that renders a plugin-provided component on every route, which is exactly the
// escape hatch the plugin system deleted on purpose: there is no way to export a component, and that is
// what makes "every plugin route is a Workspace" a property of the shape rather than a rule to remember.
//
// The seam is inert without the plugin. `MusicPlayerHost` gates on `can('music')`, and `music` is now the
// plugin's permission — registered at install, gone at uninstall — so the overlay switches itself off
// with the plugin and no code here knows why.
//
// ── What the plugin imports, and from where ──
//
// The player API is below, on the `officerdev` barrel. The library VOCABULARY — `shared.ts`, the paths,
// sorting and tag shapes — is not: it declares `DirEntry`, `Track` and `Manifest`, names the barrel
// already spends on the FileBrowser. `plugins/music/web/shared.ts` takes it from the package's declared
// `officerdev/MusicPlayer/shared` subpath instead, which keeps one definition without renaming a type on
// its way through a barrel.
export { useMusicPlayer } from './useMusicPlayer';
export type { PlayerTrack, MusicPlayerState } from './useMusicPlayer';
export { MusicPlayerHost } from './MusicPlayerHost';
export { MusicHeart } from './MusicHeart';
export { useMusicFavorites } from './useMusicFavorites';
// The engine↔UI bridge. Module-level singletons on purpose: the lyrics pane and the /music scrubber live
// in another React tree from the host that owns the engine, so they meet here rather than through props.
export {
publishPlayerTime,
subscribePlayerTime,
registerPlayerSeek,
seekPlayer,
getPlayerTime,
getPlayerDuration,
} from './player-time';
export { useLyricsOpen } from './useLyricsOpen';
@@ -1,11 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Music, ListMusic } from 'lucide-react';
import { MusicBrowser } from './MusicBrowser';
import { MusicDetail } from './MusicDetail';
export { MusicBrowser, MusicDetail };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
];
+4
View File
@@ -111,6 +111,10 @@ export {
ARCHIVE_EXTS, ARCHIVE_EXTS,
} from './apps/FileViewer'; } from './apps/FileViewer';
export type { FileType } from './apps/FileViewer'; export type { FileType } from './apps/FileViewer';
// The scrubber, shared by the FileViewer's audio/video renderers, the global player bar and the /music
// panels in `plugins/music/`. On the barrel rather than reached for by subpath because the package's
// `"./*"` export maps to `.ts` only, and this is a `.tsx`.
export { SeekBar, useSeekBar } from './apps/FileViewer/renderers/SeekBar';
export { TerminalView } from './apps/Terminal'; export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal'; export type { TerminalViewProps } from './apps/Terminal';
export { DesktopView } from './apps/Desktop'; export { DesktopView } from './apps/Desktop';