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.
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
# 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.
|