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.
107 lines
6.3 KiB
TypeScript
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' },
|
|
},
|
|
],
|
|
};
|