Offscale was self-sufficient. Music is not — it shells out to ffmpeg and ffprobe — and the way it fails without them is the reason this is a check rather than a line in a README. It does not fail. Missing ffprobe 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. It then walks the whole library, writes a complete cache tree and reports success. Five swallowed catches, no log, no counter, and the only tell is coversSaved: 0 in a report nobody reads. So `osDependencies` is a manifest field: the binary to probe on PATH, why it is needed, and a package name per package manager. The shape is taken from scripts/setup-old/setup.sh rather than invented — probe the binary, case on $PM — and the names are per-manager rather than canonical-with-overrides because lib/packages.sh already recorded why that indirection was rejected. Probing the binary is what makes "built-in on this OS" free: on PATH means the package map is never consulted. Four decisions worth naming. Missing and uninstallable REFUSES the install, first, before a table is created or a row written — so there is nothing to undo, and the alternative is a plugin that installs, answers 200 and quietly produces nothing. The status is on GET /api/plugins and rendered before the button, because the owner is deciding whether to let the server run a package manager as root and that needs answering first. Installing by hand and watching it flip to present is the escape hatch on a machine without passwordless sudo. Package names get a deliberately narrow regex and reach Bun.spawn as an argv ARRAY, never a shell. Both halves are load-bearing: the regex means a metacharacter cannot get there, argv means it would be an argument rather than syntax if it did. Narrower than package managers actually accept — no `:`, no `+` version pins — because a plugin needing one wants a conversation. Success is OBSERVED, not inferred: after installing, the binaries are re-probed. A package manager exiting 0 having installed something that does not provide the binary is exactly the failure this exists to catch. installCommand mirrors lib/packages.sh's pkg_install_now exactly, including apt's non-interactive environment, so there is one definition of "install a package" rather than two that drift. sudo always gets -n: under PM2 a password prompt is not a slow path, it is a hang. brew never escalates. Verified live. ffmpeg and ffprobe were absent on this machine all evening; the page showed both missing with the exact root command, the install streamed `dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`, and X-Audio-Duration appeared on a stream response for the first time. The refusal path was exercised against a temporary probe dependency: HTTP 400, steps: [], reason named. THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt. Found on the way: a manifest is read once per process. Discovery does `await import()` and the module cache holds it, so editing a manifest changes nothing until pm2 restart officer — including `outdated`. Cost ten minutes and is now in the runbook. bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new.
217 lines
14 KiB
Markdown
217 lines
14 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 — 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
|
|
|
|
- **`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.
|