Files
platform/plugins/music/manifest.ts
T
pastilhas e930586878 plugins declare the host binaries they need, and the installer checks
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.
2026-08-15 02:33:18 +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: three pieces stay behind deliberately. Each is a documented seam rather than a loose end,
// and each is recorded in ./PLUGIN.md with what would have to change to close it.
//
// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one
// 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 ──
//
// 1. cliamp (`/api/cliamp/ws`, `/api/cliamp/audio/ws`, `sidecar/music/cliamp-ws.ts`, `pulse-audio.ts`,
// `asoundrc`). A second playback path — the `cliamp` TUI run on the server with its terminal and its
// PulseAudio null sink piped to the browser. Already inert (the routes upgrade into commented-out
// handlers) and out of scope by the owner's decision. This sidecar still serves those sockets, so it
// imports both modules from `@@/sidecar/music/`.
//
// 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' },
},
],
};