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' }, }, ], };