Files
platform/plugins/music/manifest.ts
T
pastilhas a9bf51407e cliamp moves into the plugin, and the platform loses its last music file
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.
2026-08-15 13:52:53 +00:00

107 lines
6.3 KiB
TypeScript

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. It took two passes: the first left three pieces in the platform, and the second moved two
// of them here after the owner read the code and asked why the platform still had files named for music.
// He was right — one of the three "seams" turned out to be dead code holding the door open.
//
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
// 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 ── (one item, down from three)
//
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
//
// 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 ──
//
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
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'],
},
],
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
//
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
// differently. Losing `ffprobe` is the quiet one: 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 reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
// least visible. Naming both means the owner is told which of the two they are missing.
osDependencies: [
{
binary: 'ffprobe',
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
{
binary: 'ffmpeg',
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
],
};