The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.
The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so its call to getMusicServerWsUrl never ran; and the file's other export,
getMusicServerUrl, had no consumers at all. A dead import held a music-named
file in the platform, and I documented that as a "seam" last night after
checking the import existed and stopping there.
Everything cliamp now lives in plugins/music/cliamp/:
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
api/cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx
src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.
Two of the things that moved were live, not inert.
The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.
That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.
The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/music and
would have forwarded /alice/music/stream to a sidecar expecting /stream. A
latent bug only third parties would ever hit, and a quiet violation of the rule
that mountPrefix is the one function allowed to know about provenance. Offscale
has the identical hardcode and still needs it.
Still open there: appName is passed as a literal, because a plugin's router
cannot see its own directory name — the platform imports the module and reads
`router`, so there is nowhere to inject it. The fix is a factory the installer
calls with the plugin's identity.
Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.
bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
280 lines
18 KiB
Markdown
280 lines
18 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 — parked in the plugin, not left in the platform
|
|
|
|
`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. Out of scope by the owner's decision.
|
|
|
|
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
|
|
|
|
```
|
|
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
|
|
api/cliamp/relay.ts → cliamp/relay.ts
|
|
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
|
|
```
|
|
|
|
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
|
|
`server.tsx` has no cliamp import, provider name, handler entry or route left.
|
|
|
|
Two things went with it that were live rather than inert:
|
|
|
|
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
|
|
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
|
|
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
|
|
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
|
|
its two menu entries are removed; the components are parked here.
|
|
- **The two socket routes.** They now 404. Verified live.
|
|
|
|
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
|
|
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
|
|
|
|
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
|
|
and never called. A platform gap, not a music one.
|
|
|
|
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
|
|
|
|
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
|
|
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
|
|
`getMusicServerWsUrl` from it, so it could not move.
|
|
|
|
That reason was three layers of nothing:
|
|
|
|
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
|
|
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
|
|
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
|
|
|
|
A dead import held a music-named file in the platform. The proxy is now built in
|
|
`plugins/music/api/router.ts`; the relay takes its URL from there.
|
|
|
|
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
|
|
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
|
|
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
|
|
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
|
|
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
|
|
to know about provenance. It now calls `mountPrefix`.
|
|
|
|
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
|
|
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
|
|
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
|
|
|
|
### 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 — the field music created
|
|
|
|
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
|
|
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
|
|
that. That was the wrong answer, because of HOW music fails without them.
|
|
|
|
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
|
|
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
|
|
whole library, writes a complete cache tree and reports success. Five swallowed catches in
|
|
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
|
|
nobody reads. A refusal wearing the costume of a normal result.
|
|
|
|
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
|
|
|
|
```ts
|
|
osDependencies: [
|
|
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
|
|
{ binary: 'ffmpeg', reason: '…', packages: { … } },
|
|
]
|
|
```
|
|
|
|
Both are declared even though one package provides both, because the platform probes BINARIES and these
|
|
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
|
|
a single `ffmpeg` before anything reaches a command line.
|
|
|
|
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
|
|
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
|
|
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
|
|
recorded why that indirection was rejected.
|
|
|
|
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
|
|
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
|
|
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
|
|
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
|
|
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
|
|
named — nothing had happened, so there was nothing to undo.
|
|
|
|
`~/Music` still does not exist, so there is no library to index.
|
|
|
|
`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
|
|
|
|
### The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
|
|
|
|
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
|
|
|
|
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
|
|
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** capability, and `files` is
|
|
**`confined`** — so:
|
|
|
|
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
|
|
library**, because every listing 403s;
|
|
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
|
|
account with no `osUser`, so it means nothing without a per-user Linux account.
|
|
|
|
This is the first **cross-plugin permission dependency** in the system, and it is a different animal from
|
|
the one offscale has. Offscale's `ConsoleView` → `TerminalView` is a CODE dependency: it resolves at build
|
|
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
|
|
account, and its failure mode is a screen that renders perfectly and shows nothing.
|
|
|
|
Three possible shapes, none chosen:
|
|
|
|
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
|
|
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
|
|
Most self-contained, and the most work.
|
|
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
|
|
but it makes one plugin's grant conditional on another capability, which is new machinery.
|
|
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
|
|
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
|
|
|
|
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything.
|
|
Not tonight's call.
|
|
|
|
- **`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.
|