music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
# 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~~ — moved, and the reasoning that kept it was removed rather than refuted
|
||||
|
||||
The first version of this document said the player stayed in the platform and called the decision
|
||||
settled by a hard constraint:
|
||||
|
||||
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by `widgets/MusicPlayer/`, the
|
||||
> dashboard widget — and the platform cannot import from a plugin. So the player state stays whatever is
|
||||
> decided about the UI.
|
||||
|
||||
True at the time. The owner then moved the widget into the plugin, and the constraint evaporated: the
|
||||
complete remaining platform dependency became one line, `DashboardLayout.tsx:66`.
|
||||
|
||||
So the whole of `officerdev/src/MusicPlayer/` now lives in `web/` — engine, state, bar, favourites,
|
||||
lyrics toggle and the library vocabulary. **`src/` contains no music code at all.**
|
||||
|
||||
**Where the engine is mounted, and why it is not the shell.** `MusicPlayerHost` renders inside
|
||||
`MusicDetail`, at the foot of the library view. That reads odd until you notice what it already did:
|
||||
|
||||
```tsx
|
||||
if (pathname.startsWith('/music')) return null; // the panel draws its own MusicMiniBar
|
||||
```
|
||||
|
||||
On `/music` the host has always rendered nothing and existed purely to own the `GaplessEngine`. Mounted
|
||||
in the panel it does exactly that, and the bar code stays intact for whenever there is a slot to put it in.
|
||||
|
||||
**`[phase 2]` Leaving `/music` unmounts the host, which stops playback.** Deliberately deferred rather
|
||||
than solved: making audio outlive the route needs either a shell slot a plugin can contribute to — which
|
||||
reopens "there is no way to export a component" — or the engine hoisted to module scope so a panel
|
||||
attaches and detaches from a singleton. The second keeps the rule and loses only the off-route transport
|
||||
controls, and is the better idea, but it is a rewrite of the host's lifecycle rather than a move.
|
||||
|
||||
Nothing breaks in the meantime, and that was the bar: `player-time`'s `seekPlayer` is optional-chained
|
||||
so a call with no host registered is a no-op, `registerPlayerSeek` clears only its own registration, the
|
||||
host's cleanup destroys the engine and nulls its ref, and the queue lives in global state — so returning
|
||||
to `/music` remounts the host and reloads it.
|
||||
|
||||
## Two bugs, neither visible from reading
|
||||
|
||||
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
||||
`permissionAvailability()` 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_permissions` 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/permissions`; `/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`** permission, 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 permission, 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 permission**, and are served. Now pinned by a test in
|
||||
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||
Reference in New Issue
Block a user