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 // cliamp/ the second playback path, parked // widgets/ the dashboard widget, parked // 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 ── (nothing. All three moved here.) // // 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~~ — MOVED HERE, to `./widgets/`, and unregistered from WidgetRegistry. // Parked: plugins cannot contribute widgets and that mechanism is not built. // // 3. ~~The global player overlay~~ — MOVED HERE, to `./web/`. It stayed while the widget pinned // `useMusicPlayer` in `officerdev`; once the widget left, the only platform dependency was one line // in DashboardLayout. `MusicPlayerHost` now mounts inside the MusicDetail panel, where it owns the // audio engine and renders nothing — which is what it already did on /music. // // `[phase 2]` Leaving /music stops playback. Giving audio a life outside the route needs a shell slot // a plugin can contribute to, or the engine hoisted to module scope. Deferred deliberately; nothing // breaks meanwhile. // // ── 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' }, }, ], };