Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05eb947bd1 | ||
|
|
de3340398c |
+10
-1
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
|
|||||||
coverage = true
|
coverage = true
|
||||||
coverageDir = "coverage"
|
coverageDir = "coverage"
|
||||||
preload = ["./test-setup.ts"]
|
preload = ["./test-setup.ts"]
|
||||||
root = "./src"
|
# The repo, not just `src` — a plugin's tests are the platform's tests.
|
||||||
|
#
|
||||||
|
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
|
||||||
|
# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still
|
||||||
|
# read green-ish. A test that quietly stops running is worse than one that fails, and every future
|
||||||
|
# extraction would have taken its tests out of the suite the same way.
|
||||||
|
#
|
||||||
|
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
|
||||||
|
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
|
||||||
|
root = "."
|
||||||
|
|||||||
+67
-108
@@ -7,7 +7,9 @@ The runbook, written the day offscale became the first one. Follow it for music,
|
|||||||
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
||||||
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
||||||
3. `plugins/offscale/` — the worked example, all four parts
|
3. `plugins/offscale/` — the worked example, all four parts
|
||||||
4. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
|
||||||
|
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
|
||||||
|
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ The runbook, written the day offscale became the first one. Follow it for music,
|
|||||||
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
||||||
|
|
||||||
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
||||||
of any sort. The platform's answer is uniform; what a grant *means* — whose rows a member sees, whether a
|
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
|
||||||
resource is shared or per-user — is the plugin's own job, in its own queries.
|
resource is shared or per-user — is the plugin's own job, in its own queries.
|
||||||
|
|
||||||
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
||||||
@@ -80,18 +82,18 @@ pm2 restart officer
|
|||||||
|
|
||||||
Then through `/plugins`, watching PM2 and the browser at each step:
|
Then through `/plugins`, watching PM2 and the browser at each step:
|
||||||
|
|
||||||
| Step | Expect |
|
| Step | Expect |
|
||||||
| --- | --- |
|
| ------------------------------- | ----------------------------------------------------------- |
|
||||||
| install | streamed log; schema applied; sidecar online; route mounted |
|
| install | streamed log; schema applied; sidecar online; route mounted |
|
||||||
| the plugin's API | answers |
|
| the plugin's API | answers |
|
||||||
| the plugin's screen | renders as a Workspace |
|
| the plugin's screen | renders as a Workspace |
|
||||||
| dock | tile appears |
|
| dock | tile appears |
|
||||||
| permissions page | its permission is listed, read/write/none |
|
| permissions page | its permission is listed, read/write/none |
|
||||||
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||||
| enable | comes back |
|
| enable | comes back |
|
||||||
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
||||||
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
||||||
| install again | identical to the first install |
|
| install again | identical to the first install |
|
||||||
|
|
||||||
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||||
|
|
||||||
@@ -110,117 +112,74 @@ A normal refresh is enough; the shell is `no-store`. When the log's last line ap
|
|||||||
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
||||||
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||||
contract change. Regenerate rather than hand-edit.
|
contract change. Regenerate rather than hand-edit.
|
||||||
|
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `capabilityAvailability`
|
||||||
|
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
||||||
|
`plugin_installs`. A leftover catalogue entry therefore makes the capability permanently `unavailable`,
|
||||||
|
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
||||||
|
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
||||||
|
`catalogue.ts` is the one to read.
|
||||||
|
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
||||||
|
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
||||||
|
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
||||||
|
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
||||||
|
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
||||||
|
it. Find those before you plan the split; they decide it for you.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Music specifically — READ THIS BEFORE STARTING
|
## Music is done. What it changed about this runbook
|
||||||
|
|
||||||
**Music is bigger than offscale, and one part of it has nowhere to go.** Two others did, and the owner cut
|
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
|
||||||
both from tonight's scope — see below.
|
record; the parts worth carrying forward are already folded into the rules and traps above.
|
||||||
Mapped 2026-08-15.
|
|
||||||
|
|
||||||
**Decide these yourself. Do not stop to ask.** Every one of them has two defensible answers, the owner has
|
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
||||||
said either is fine, and a corrected decision is cheap where a stalled extraction is not. Record what you
|
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
||||||
chose and why — in the manifest, in a comment, in the commit message — and keep going. The failure mode to
|
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
||||||
avoid is not a wrong call; it is a tree left half-moved with a question attached.
|
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
||||||
|
none, so it came out whole and made the job look cleaner than it is.
|
||||||
|
|
||||||
### What has no home yet
|
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
||||||
|
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
||||||
|
not tidy them into a plugin as a warm-up.
|
||||||
|
|
||||||
1. ~~**Two websocket providers.**~~ **OUT OF SCOPE — do not touch cliamp.**
|
### The global-overlay question is answered, and the answer is no
|
||||||
|
|
||||||
`cliamp` and `cliamp-audio` exist for one thing: running the `cliamp` TUI player on the server and
|
|
||||||
piping its terminal and its audio (a PulseAudio null sink tapped by `parec`) to the browser. It is a
|
|
||||||
second, separate playback path and the owner's word is that it is the least important part of music —
|
|
||||||
fun, wanted eventually, not tonight.
|
|
||||||
|
|
||||||
**This is the whole reason the websocket gap does not block this extraction.** Both sockets are already
|
|
||||||
inert (routes upgrade into commented-out handlers). Leave `sidecar/music/cliamp-ws.ts`,
|
|
||||||
`sidecar/music/pulse-audio.ts`, `sidecar/music/asoundrc`, `src/servers/api/cliamp/relay.ts` and the two
|
|
||||||
providers in `server.tsx` exactly where they are, untouched. If the relay's import of
|
|
||||||
`getMusicServerWsUrl` is the only thing keeping `api/music/router.ts` alive, leave that file too and
|
|
||||||
say so — a small documented seam is fine.
|
|
||||||
|
|
||||||
What music actually is: the `/music` screen, the library, and the phone and tablet apps that stream
|
|
||||||
from it. That is what has to work.
|
|
||||||
|
|
||||||
2. **A global UI overlay.** `MusicPlayerHost` is rendered by `DashboardLayout` on **every route**, not
|
|
||||||
inside a panel, and gates itself on `can('music')`. A plugin contributes panels and a layout; there is
|
|
||||||
no "render this everywhere" slot, and inventing one is a platform change.
|
|
||||||
3. ~~**A dashboard widget.**~~ **OUT OF SCOPE — leave it where it is.**
|
|
||||||
|
|
||||||
`src/workspaces/widgets/MusicPlayer/` sits in a third workspace package and is wired through
|
|
||||||
`WidgetRegistry`. Plugins cannot contribute widgets and are not going to learn how tonight. Leave the
|
|
||||||
directory, the registration and the barrel export alone; note the seam in the manifest.
|
|
||||||
|
|
||||||
### What else differs
|
|
||||||
|
|
||||||
- **External binaries**: with cliamp out of scope, what the plugin still needs is `ffmpeg` and `ffprobe`,
|
|
||||||
for tag reading, cover compression and poster frames. A manifest has no way to declare a host
|
|
||||||
dependency; note it and move on. (`cliamp`, `parec`, `pulseaudio`, `pactl` and `asoundrc` stay behind
|
|
||||||
with cliamp.)
|
|
||||||
- **Range requests**: `stream-audio.ts` does 206 / `Content-Range` / 416 and a custom `X-Audio-Duration`;
|
|
||||||
the proxy runs at `timeoutSeconds: 1800` for reindexes. Verify `Range` survives the hop.
|
|
||||||
- **Cross-capability**: `MusicBrowser` calls `/file-browser/ls`, not the music sidecar.
|
|
||||||
- **A CLI tool**: `scripts/reindex-music.ts` reads `DATA_PATH/music/.server`.
|
|
||||||
- **Music is the platform's worked example.** `registry.ts` explains `personal` through it and
|
|
||||||
`registry.test.ts` tests the mechanism *through the music capability*. Those tests must be rewritten
|
|
||||||
against something else, not deleted.
|
|
||||||
- **Already half-disabled**: `hono.ts` mount and `schema.ts` export are commented out since 2026-08-13;
|
|
||||||
`officer_db/src/index.ts:49` is still live. So the tree is mid-migration already.
|
|
||||||
|
|
||||||
### The per-user model is NOT tonight's work
|
|
||||||
|
|
||||||
`personal: ['/favorites', '/now-playing', '/playlists', '/queue']` on music's registry entry, the four
|
|
||||||
user-scoped tables, and the question of what a member's grant *means* — **all of it stays exactly as it
|
|
||||||
is. Do not design it, do not improve it, do not think about it.** It will be built inside the plugin
|
|
||||||
later, which is the whole reason the platform's answer is a uniform read/write and nothing more.
|
|
||||||
|
|
||||||
Two facts that make leaving it alone safe rather than lazy:
|
|
||||||
|
|
||||||
- **The web frontend does not use those routes at all.** Favourites, playlists and now-playing are used
|
|
||||||
only by the phone and tablet apps. Nothing you can see in a browser depends on them, so nothing in this
|
|
||||||
extraction can regress by carrying them across untouched.
|
|
||||||
- `/queue` is in that list and **no such route exists**, in the sidecar or anywhere else. Dead or
|
|
||||||
aspirational. Carry it as-is; do not investigate.
|
|
||||||
|
|
||||||
Move the routes, the tables and the queries verbatim. `userId` keeps arriving from `X-Officer-User` and
|
|
||||||
the queries keep scoping by it, exactly as today.
|
|
||||||
|
|
||||||
**The one mechanical consequence you cannot avoid:** `registry.test.ts` tests the `personal` mechanism
|
|
||||||
*through* the music entry, and `registry.ts` uses music as its worked example in prose. Removing the
|
|
||||||
entry breaks those tests. Re-anchor them on another entry that has `personal` — the smallest edit that
|
|
||||||
makes them green and still test the mechanism. That is a test fix, not a redesign.
|
|
||||||
|
|
||||||
### How to decide the three, if you want a default
|
|
||||||
|
|
||||||
Each of these is "close the platform gap" or "leave the piece behind". **Closing the gap is better when
|
|
||||||
you have the runway**, because every later plugin needs it too — but a music that works with cliamp left
|
|
||||||
in the platform is worth far more than a perfect design that did not land.
|
|
||||||
|
|
||||||
| Part | Close the gap | Leave it behind |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| ~~websockets~~ | — | **out of scope. Do not spend a minute on cliamp.** |
|
|
||||||
| ~~widget~~ | — | **out of scope. Leave `widgets/MusicPlayer` exactly as it is.** |
|
|
||||||
| global overlay | add one slot the shell renders from installed plugins | leave `MusicPlayerHost` in `DashboardLayout`, gated as it already is |
|
|
||||||
|
|
||||||
**One open call, then.** The player overlay is the only judgement left, and either answer is fine.
|
|
||||||
|
|
||||||
Whatever you pick, the plugin must **install, enable, disable and uninstall cleanly** at the end. A piece
|
|
||||||
left in the platform is a documented seam; a piece left dangling is a bug.
|
|
||||||
|
|
||||||
`assertCapabilityTotality` is **not** part of this. It is worth fixing eventually and the cliamp routes
|
|
||||||
are the live example of the drift it cannot see, but both are someone else's evening.
|
|
||||||
|
|
||||||
|
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
||||||
|
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
||||||
|
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
||||||
|
gated on its plugin's permission so it switches itself off with the plugin.
|
||||||
|
|
||||||
|
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Which one next
|
||||||
|
|
||||||
|
No decision has been made. What the tree says, for whoever picks it:
|
||||||
|
|
||||||
|
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
||||||
|
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
||||||
|
is exactly what its extraction needs.
|
||||||
|
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
||||||
|
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
||||||
|
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
||||||
|
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
||||||
|
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
||||||
|
way neither of the first two did. Read `docs/secret-store.md` first.
|
||||||
|
|
||||||
## Still open, platform-wide. Do not rediscover these
|
## Still open, platform-wide. Do not rediscover these
|
||||||
|
|
||||||
- **Websocket providers** — `server.reload({ routes })` proven, never called
|
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
||||||
|
music would have been the first and cliamp being out of scope is what let it pass.
|
||||||
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
||||||
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
||||||
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
||||||
|
The live example is the two cliamp sockets: served in the route table, claimed by no capability, and
|
||||||
|
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
||||||
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
||||||
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||||
owner's. Its own job, not the platform's.
|
owner's. Its own job, not the platform's.
|
||||||
|
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
||||||
|
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
||||||
|
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
|
|||||||
|
|
||||||
Byte-range streaming so the player can **seek without downloading the whole file**.
|
Byte-range streaming so the player can **seek without downloading the whole file**.
|
||||||
|
|
||||||
| Case | Status | Headers |
|
| Case | Status | Headers |
|
||||||
|---|---|---|
|
| --------------------- | ------ | --------------------------------------------------------------------------------------------- |
|
||||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||||
|
|
||||||
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||||
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed
|
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
|
||||||
VBR MP3s. No need to scan the file.
|
VBR MP3s. No need to scan the file.
|
||||||
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||||
|
|
||||||
@@ -51,26 +51,28 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
|
|||||||
this instead of walking + ID3-parsing the library itself.
|
this instead of walking + ID3-parsing the library itself.
|
||||||
|
|
||||||
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*.
|
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
|
||||||
|
|
||||||
### 2.1 Manifest — one call, whole library
|
### 2.1 Manifest — one call, whole library
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/music/manifest
|
GET /api/music/manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"generatedAt": 1785034701973, // ms; when the index was last built
|
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||||
"albums": {
|
"albums": {
|
||||||
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||||
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
||||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
|
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
|
||||||
// …
|
// …
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
||||||
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
||||||
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
||||||
@@ -82,14 +84,16 @@ may have any mix of `tracks`, `videos`, and `disco`.
|
|||||||
```
|
```
|
||||||
GET /api/music/meta?path=<rel>
|
GET /api/music/meta?path=<rel>
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"path": "Albums/AC-DC/[1980] Back in Black",
|
"path": "Albums/AC-DC/[1980] Back in Black",
|
||||||
"cover": "cover.jpg", // present only if a cover exists
|
"cover": "cover.jpg", // present only if a cover exists
|
||||||
"tracks": [
|
"tracks": [
|
||||||
{
|
{
|
||||||
"file": "01 Hells Bells.mp3", // filename within the album folder
|
"file": "01 Hells Bells.mp3", // filename within the album folder
|
||||||
"title": "Hells Bells",
|
"title": "Hells Bells",
|
||||||
"artist": "AC/DC",
|
"artist": "AC/DC",
|
||||||
"albumArtist": "AC/DC",
|
"albumArtist": "AC/DC",
|
||||||
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
|
|||||||
"track": "1",
|
"track": "1",
|
||||||
"year": "1980",
|
"year": "1980",
|
||||||
"durationSec": 312,
|
"durationSec": 312,
|
||||||
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||||
}
|
},
|
||||||
// …
|
// …
|
||||||
],
|
],
|
||||||
"videos": [ // present only for folders that contain video files
|
"videos": [
|
||||||
|
// present only for folders that contain video files
|
||||||
{
|
{
|
||||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||||
"durationSec": 8130,
|
"durationSec": 8130,
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
|
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
|
||||||
}
|
},
|
||||||
// …
|
// …
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
||||||
omitted entirely when the folder has none.
|
omitted entirely when the folder has none.
|
||||||
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
||||||
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
|
|||||||
```
|
```
|
||||||
GET /api/music/cover?path=<rel>
|
GET /api/music/cover?path=<rel>
|
||||||
```
|
```
|
||||||
|
|
||||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
Only meaningful when the manifest entry has `"cover": true`.
|
Only meaningful when the manifest entry has `"cover": true`.
|
||||||
|
|
||||||
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
|
|||||||
```
|
```
|
||||||
GET /api/music/poster?path=<rel>&file=<video filename>
|
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||||
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||||
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||||
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
|
|||||||
```
|
```
|
||||||
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||||
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||||
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||||
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
|
|||||||
```
|
```
|
||||||
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||||
```
|
```
|
||||||
|
|
||||||
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"artist": "Anthrax",
|
"artist": "Anthrax",
|
||||||
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
|||||||
"[1984] Fistful Of Metal": "Studio",
|
"[1984] Fistful Of Metal": "Studio",
|
||||||
"[1985] Armed And Dangerous": "EP",
|
"[1985] Armed And Dangerous": "EP",
|
||||||
"[1994] The Island Years": "Live",
|
"[1994] The Island Years": "Live",
|
||||||
"[1991] Attack Of The Killer B's": "Compilation"
|
"[1991] Attack Of The Killer B's": "Compilation",
|
||||||
// …
|
// …
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||||
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||||
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||||
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
```
|
```
|
||||||
|
|
||||||
`IndexStatus`:
|
`IndexStatus`:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"running": true,
|
"running": true,
|
||||||
"startedAt": 1785034701973, "finishedAt": null,
|
"startedAt": 1785034701973,
|
||||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
"finishedAt": null,
|
||||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
|
"foldersScanned": 45,
|
||||||
|
"albumsBuilt": 12,
|
||||||
|
"albumsSkipped": 3,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"videosIndexed": 4,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"postersSaved": 4,
|
||||||
|
"lyricsIndexed": 45,
|
||||||
|
"discographies": 3,
|
||||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||||
"error": null
|
"error": null,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
```
|
```
|
||||||
GET /api/music/reindex/stream
|
GET /api/music/reindex/stream
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
||||||
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
||||||
`IndexReport`) and **closes** the stream.
|
`IndexReport`) and **closes** the stream.
|
||||||
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
|
|||||||
```
|
```
|
||||||
|
|
||||||
`IndexReport` (the `done` payload):
|
`IndexReport` (the `done` payload):
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
{
|
||||||
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
|
"albums": 15,
|
||||||
|
"built": 12,
|
||||||
|
"skipped": 3,
|
||||||
|
"foldersScanned": 45,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"discographies": 3,
|
||||||
|
"elapsedSec": 37.2,
|
||||||
|
"error": null,
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||||
@@ -256,17 +288,21 @@ Unlike everything above (library data served by the sidecar), these are **per-us
|
|||||||
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
|
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
|
||||||
supplies; the server never interprets them:
|
supplies; the server never interprets them:
|
||||||
|
|
||||||
| kind | key |
|
| kind | key |
|
||||||
|---|---|
|
| -------- | --------------------------------------------------------------------- |
|
||||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||||
| `artist` | music-rel — `Albums/AC-DC` |
|
| `artist` | music-rel — `Albums/AC-DC` |
|
||||||
|
|
||||||
### Favorites
|
### Favorites
|
||||||
|
|
||||||
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
||||||
```json
|
```json
|
||||||
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
|
{
|
||||||
|
"tracks": ["Music/…/01 Hells Bells.mp3"],
|
||||||
|
"albums": ["Albums/AC-DC/[1980] Back in Black"],
|
||||||
|
"artists": ["Albums/AC-DC"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
||||||
(a repeat add is a no-op).
|
(a repeat add is a no-op).
|
||||||
@@ -281,9 +317,16 @@ launch to offer "resume".
|
|||||||
|
|
||||||
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
||||||
```json
|
```json
|
||||||
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
{
|
||||||
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
|
"homePath": "Music/…/01 Hells Bells.mp3",
|
||||||
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
|
"dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||||
|
"title": "Hells Bells",
|
||||||
|
"artist": "AC/DC",
|
||||||
|
"album": "Back in Black",
|
||||||
|
"durationSec": 312.5,
|
||||||
|
"positionSec": 140,
|
||||||
|
"updatedAt": "2026-07-27T11:27:54.441Z"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
||||||
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
||||||
@@ -299,15 +342,15 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
|
|||||||
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays
|
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays
|
||||||
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
||||||
|
|
||||||
| method | path | body | returns |
|
| method | path | body | returns |
|
||||||
|---|---|---|---|
|
| -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
|
||||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||||
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
||||||
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
||||||
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
||||||
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
||||||
|
|
||||||
`PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
|
`PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
|
||||||
|
|
||||||
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
|
|||||||
|
|
||||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||||
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline
|
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
|
||||||
audio files* is a separate, later feature.)
|
audio files_ is a separate, later feature.)
|
||||||
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Music — the second plugin
|
||||||
|
|
||||||
|
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
|
||||||
|
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
|
||||||
|
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
|
||||||
|
|
||||||
|
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
|
||||||
|
worked second case, and it is interesting mainly for being the messy one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What music is
|
||||||
|
|
||||||
|
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
|
||||||
|
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
|
||||||
|
free to change.
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.ts identity, one permission
|
||||||
|
api/router.ts re-exports the platform's proxy — see below
|
||||||
|
sidecar/index.ts the whole /api/music contract (503 lines)
|
||||||
|
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
|
||||||
|
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
|
||||||
|
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
|
||||||
|
db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
web/ two panels and a layout; the shell renders the Workspace
|
||||||
|
scripts/ the reindex CLI, which talks to the sidecar port directly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three things that stayed, and why
|
||||||
|
|
||||||
|
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
|
||||||
|
means each one is written down with what would close it.
|
||||||
|
|
||||||
|
### 1. cliamp — out of scope by decision
|
||||||
|
|
||||||
|
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
|
||||||
|
terminal and its PulseAudio null sink piped to the browser. The owner's call was that it is the least
|
||||||
|
important part of music and not worth blocking the extraction on.
|
||||||
|
|
||||||
|
It was already inert before any of this — the two sockets are declared in `server.tsx`'s route table and
|
||||||
|
upgrade into `handlers` entries that are commented out. So:
|
||||||
|
|
||||||
|
- `src/servers/sidecar/music/` still holds `cliamp-ws.ts`, `pulse-audio.ts`, `asoundrc` and
|
||||||
|
`cliamp-ws.test.ts`. **Untouched.**
|
||||||
|
- This plugin's sidecar still serves those sockets, so it imports both modules from
|
||||||
|
`@@/sidecar/music/`. A plugin importing platform code is ordinary; the reverse would not be.
|
||||||
|
- `src/servers/api/cliamp/relay.ts` stays, and it is what keeps the next item alive.
|
||||||
|
|
||||||
|
### 2. `src/servers/api/music/router.ts` — kept alive by the relay
|
||||||
|
|
||||||
|
`relay.ts` imports `getMusicServerWsUrl` from it. So the platform's proxy could not move, and this
|
||||||
|
plugin's `api/router.ts` **re-exports it** rather than building a second one.
|
||||||
|
|
||||||
|
That is not laziness. `createSidecarProxy` learns its port from a one-shot `music:server` event and
|
||||||
|
subscribes at import. Two proxies would mean two subscribers, both working today, and a `503` on the
|
||||||
|
first reconnect where only one of them happened to be listening — the same class of failure as the
|
||||||
|
install-order bug offscale found, and just as invisible from reading.
|
||||||
|
|
||||||
|
### 3. The player — the one open judgement call, and it is decided
|
||||||
|
|
||||||
|
**`officerdev/src/MusicPlayer/` stays in the platform.** The runbook left this open with either answer
|
||||||
|
acceptable. What decided it was not the overlay but the state:
|
||||||
|
|
||||||
|
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by
|
||||||
|
> `src/workspaces/widgets/MusicPlayer/`, the dashboard widget — which is _also_ out of scope and stays.
|
||||||
|
> **The platform cannot import from a plugin.** So the player state stays here whatever is decided about
|
||||||
|
> the UI around it, and a second copy would mean two audio engines fighting over one pair of speakers.
|
||||||
|
|
||||||
|
Given the state had to stay, splitting the engine and the bar away from the thing they drive would have
|
||||||
|
left the same seam in a worse place. And moving them needed a shell slot that renders a plugin-provided
|
||||||
|
component on **every route** — which 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, and reopening it for one plugin is a bad trade.
|
||||||
|
|
||||||
|
The seam is inert without the plugin: `MusicPlayerHost` gates on `can('music')`, and `music` is now the
|
||||||
|
plugin's permission — registered at install, gone at uninstall.
|
||||||
|
|
||||||
|
What stayed with it, and why each: `gapless-engine` (the engine the state drives), `player-time` (the
|
||||||
|
module-level bridge the lyrics pane meets it through), `useLyricsOpen` and `MusicHeart` +
|
||||||
|
`useMusicFavorites` (the bar renders a heart), and `shared.ts` — the library vocabulary, which the host
|
||||||
|
needs a third of and the plugin needs all of. One definition on the host side beats a copy either side
|
||||||
|
of the boundary drifting apart; `plugins/music/web/shared.ts` re-exports it from the package's declared
|
||||||
|
`officerdev/MusicPlayer/shared` subpath.
|
||||||
|
|
||||||
|
**What would close it:** the widget learning to come from a plugin. Not the overlay slot — that one
|
||||||
|
should stay shut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two bugs, neither visible from reading
|
||||||
|
|
||||||
|
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
||||||
|
`capabilityAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
|
||||||
|
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
|
||||||
|
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
|
||||||
|
installed, enabled and healthy.
|
||||||
|
|
||||||
|
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
|
||||||
|
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
|
||||||
|
it cost minutes instead of an evening. Entry removed.
|
||||||
|
|
||||||
|
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** The
|
||||||
|
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
|
||||||
|
one that fails, and _every_ future extraction would have taken its tests out of the suite the same way.
|
||||||
|
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
|
||||||
|
so it finds `src/servers/plugins/` and not `plugins/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
|
||||||
|
every existing `role_capabilities` grant keeps meaning what it meant, and `can('music')` keeps resolving
|
||||||
|
for the overlay. Renaming it would have been a silent data change.
|
||||||
|
|
||||||
|
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
|
||||||
|
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
|
||||||
|
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
|
||||||
|
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
|
||||||
|
therefore permits exactly the four paths it permitted yesterday, and no field was added.
|
||||||
|
|
||||||
|
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
|
||||||
|
|
||||||
|
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
|
||||||
|
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
|
||||||
|
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
|
||||||
|
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
|
||||||
|
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
|
||||||
|
change inside `db/queries.ts`, not a flag on the manifest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host dependencies a manifest cannot declare
|
||||||
|
|
||||||
|
`ffmpeg` and `ffprobe`, for tag reading, cover compression and video poster frames. There is no field for
|
||||||
|
a host binary and inventing one for this would be a field nothing else reads.
|
||||||
|
|
||||||
|
**Neither is installed on this machine** (verified 2026-08-15). The sidecar starts and serves fine — the
|
||||||
|
`X-Audio-Duration` header simply does not appear, and indexing would produce no tags or covers. Nothing
|
||||||
|
breaks today because there is no library: `~/Music` did not exist either.
|
||||||
|
|
||||||
|
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
||||||
|
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified on the live server, 2026-08-15
|
||||||
|
|
||||||
|
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
|
||||||
|
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
|
||||||
|
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199` → **206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd` → **400** |
|
||||||
|
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
|
||||||
|
| dock | tile present in `/api/user/capabilities`; `/music` in `routes`, not in `deniedRoutes` |
|
||||||
|
| permissions page | `music` listed among the grantable |
|
||||||
|
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
||||||
|
| enable | 200 again, sidecar online, favourites still there |
|
||||||
|
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
|
||||||
|
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
|
||||||
|
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
|
||||||
|
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
|
||||||
|
|
||||||
|
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
|
||||||
|
exist before.
|
||||||
|
|
||||||
|
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
|
||||||
|
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
|
||||||
|
wire — noted rather than fixed.
|
||||||
|
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
|
||||||
|
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
|
||||||
|
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
|
||||||
|
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
|
||||||
|
- **The cliamp sockets are claimed by no capability**, and are served. Now pinned by a test in
|
||||||
|
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { musicRouter } from '@@/api/music/router';
|
||||||
|
|
||||||
|
// /api/music/* — auth, then forward to officer-music.
|
||||||
|
//
|
||||||
|
// ── Why this re-exports the platform's proxy instead of creating its own ──
|
||||||
|
//
|
||||||
|
// `src/servers/api/music/router.ts` has to stay behind: `api/cliamp/relay.ts` imports
|
||||||
|
// `getMusicServerWsUrl` from it to pipe the cliamp player socket to this sidecar, and cliamp is
|
||||||
|
// deliberately out of scope — it is a second playback path that the platform still owns.
|
||||||
|
//
|
||||||
|
// So the proxy already exists, and building a SECOND `createSidecarProxy({ name: 'music' })` here would
|
||||||
|
// mean two subscribers to the one-shot `music:server` port announcement. Both would work today, and the
|
||||||
|
// first reconnect where only one of them was listening would produce a 503 nobody could explain. One
|
||||||
|
// proxy, one subscription, mounted by whoever needs it.
|
||||||
|
//
|
||||||
|
// The seam is one file, and it is inert when this plugin is not installed: the router is only reachable
|
||||||
|
// once `mountPrefix()` puts it under `/api/music`, which only happens for an installed, enabled plugin.
|
||||||
|
export const router = musicRouter;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from 'officerdb/db';
|
||||||
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
||||||
|
|
||||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { users } from '../auth/schema';
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
||||||
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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 a manifest cannot declare ──
|
||||||
|
//
|
||||||
|
// `ffmpeg` and `ffprobe` must be on PATH: tag reading, cover compression and video poster frames. There
|
||||||
|
// is no field for a host binary and inventing one for this would be a field nothing else reads.
|
||||||
|
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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename } from 'node:path';
|
||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
import { cliampUpgradeData, musicWebsocket } from '@@/sidecar/music/cliamp-ws';
|
||||||
import { ensurePulseAudio } from './pulse-audio';
|
import { ensurePulseAudio } from '@@/sidecar/music/pulse-audio';
|
||||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||||
import {
|
import {
|
||||||
reindexNow,
|
reindexNow,
|
||||||
@@ -37,9 +37,9 @@ import {
|
|||||||
addPlaylistItems,
|
addPlaylistItems,
|
||||||
setPlaylistItems,
|
setPlaylistItems,
|
||||||
type FavoriteKind,
|
type FavoriteKind,
|
||||||
} from 'officerdb';
|
} from '../db/queries';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
// ── Per-user state validation ──
|
// ── Per-user state validation ──
|
||||||
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
||||||
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
|||||||
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
||||||
// phone's resync diff (fetch only changed `v`s).
|
// phone's resync diff (fetch only changed `v`s).
|
||||||
|
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
|
|
||||||
const HOME = homedir();
|
const HOME = homedir();
|
||||||
export const MUSIC_ROOT = join(HOME, 'Music');
|
export const MUSIC_ROOT = join(HOME, 'Music');
|
||||||
+19
-6
@@ -3,9 +3,9 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
||||||
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
|
import { useMusicPlayer, type PlayerTrack } from 'officerdev';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from 'officerdev';
|
||||||
import { useMusicFavorites } from './useMusicFavorites';
|
import { useMusicFavorites } from 'officerdev';
|
||||||
import {
|
import {
|
||||||
MUSIC_FAV_CHANNEL,
|
MUSIC_FAV_CHANNEL,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
|
|||||||
const albumRel = toRel(albumHome);
|
const albumRel = toRel(albumHome);
|
||||||
try {
|
try {
|
||||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
|
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||||
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file)));
|
albumRel,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
|
player.playQueue(
|
||||||
|
q,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
q.findIndex((t) => t.file === file),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
player.playQueue([{ albumRel, file }], 0);
|
player.playQueue([{ albumRel, file }], 0);
|
||||||
}
|
}
|
||||||
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
|
|||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||||
<Heart size={44} className="text-muted-foreground/30" />
|
<Heart size={44} className="text-muted-foreground/30" />
|
||||||
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No favorites yet. Click the heart on any artist, album or track.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { Loader2, Music4 } from 'lucide-react';
|
import { Loader2, Music4 } from 'lucide-react';
|
||||||
import type { LyricLine } from './lyrics';
|
import type { LyricLine } from './lyrics';
|
||||||
import { seekPlayer } from './player-time';
|
import { seekPlayer } from 'officerdev';
|
||||||
import { useActiveLyricIndex } from './useLyrics';
|
import { useActiveLyricIndex } from './useLyrics';
|
||||||
|
|
||||||
type LyricsPaneProps = {
|
type LyricsPaneProps = {
|
||||||
+2
-2
@@ -2,8 +2,8 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { MicVocal } from 'lucide-react';
|
import { MicVocal } from 'lucide-react';
|
||||||
import { LyricsPane } from './LyricsPane';
|
import { LyricsPane } from './LyricsPane';
|
||||||
import { useLyrics } from './useLyrics';
|
import { useLyrics } from './useLyrics';
|
||||||
import { useLyricsOpen } from './useLyricsOpen';
|
import { useLyricsOpen } from 'officerdev';
|
||||||
import { useMusicPlayer } from './useMusicPlayer';
|
import { useMusicPlayer } from 'officerdev';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
||||||
+8
-8
@@ -4,15 +4,15 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||||
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from '../../components/Workspace';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from 'officerdev';
|
||||||
import { FavoritesView } from './FavoritesView';
|
import { FavoritesView } from './FavoritesView';
|
||||||
import { useMusicPlayer } from '../../MusicPlayer';
|
import { useMusicPlayer } from 'officerdev';
|
||||||
import type { PlayerTrack } from '../../MusicPlayer';
|
import type { PlayerTrack } from 'officerdev';
|
||||||
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
|
import { LyricsPanel } from './LyricsPanel';
|
||||||
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
|
import { MusicMiniBar } from './MusicMiniBar';
|
||||||
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
|
import { useLyricsOpen } from 'officerdev';
|
||||||
import {
|
import {
|
||||||
MUSIC_ROOT,
|
MUSIC_ROOT,
|
||||||
MUSIC_FAV_CHANNEL,
|
MUSIC_FAV_CHANNEL,
|
||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { MicVocal, Pause, Play } from 'lucide-react';
|
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
import { SeekBar } from 'officerdev';
|
||||||
import { coverUrl, fmtClock } from '../apps/Music/shared';
|
import { coverUrl, fmtClock } from './shared';
|
||||||
import { seekPlayer } from './player-time';
|
import { seekPlayer } from 'officerdev';
|
||||||
import { useLyricsOpen } from './useLyricsOpen';
|
import { useLyricsOpen } from 'officerdev';
|
||||||
import { useMusicPlayer } from './useMusicPlayer';
|
import { useMusicPlayer } from 'officerdev';
|
||||||
import { usePlayerClock } from './usePlayerClock';
|
import { usePlayerClock } from './usePlayerClock';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { Music, ListMusic } from 'lucide-react';
|
||||||
|
import { MusicBrowser } from './MusicBrowser';
|
||||||
|
import { MusicDetail } from './MusicDetail';
|
||||||
|
|
||||||
|
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
|
||||||
|
// `layout.ts` — a plugin never renders the screen.
|
||||||
|
//
|
||||||
|
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
|
||||||
|
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
|
||||||
|
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
|
||||||
|
// screen.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
||||||
|
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// The library vocabulary, re-exported from the host.
|
||||||
|
//
|
||||||
|
// It lives at `officerdev/src/MusicPlayer/shared.ts` rather than here because `MusicPlayerHost` — the
|
||||||
|
// global player bar, which stays in the platform; see that directory's index.ts for why — needs a third
|
||||||
|
// of it. One definition on the host side beats a copy either side of the plugin boundary drifting apart.
|
||||||
|
//
|
||||||
|
// Taken from the `officerdev/MusicPlayer/shared` subpath rather than the `officerdev` barrel because the
|
||||||
|
// type names here (`DirEntry`, `Track`, `Manifest`) are ones the barrel already spends on the FileBrowser.
|
||||||
|
// The subpath is a declared export of the package (`"./*": "./src/*.ts"`), not a reach into its insides.
|
||||||
|
//
|
||||||
|
// Every panel in this directory imports from HERE, so the seam is one file to read rather than a
|
||||||
|
// different specifier in each of them.
|
||||||
|
export * from 'officerdev/MusicPlayer/shared';
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { LyricLine } from './lyrics';
|
import type { LyricLine } from './lyrics';
|
||||||
import { activeLineIndex, parseLyrics } from './lyrics';
|
import { activeLineIndex, parseLyrics } from './lyrics';
|
||||||
import { getPlayerTime, subscribePlayerTime } from './player-time';
|
import { getPlayerTime, subscribePlayerTime } from 'officerdev';
|
||||||
|
|
||||||
export type UseLyrics = {
|
export type UseLyrics = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
|
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from 'officerdev';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Position + duration, straight off the engine's per-frame feed.
|
* Position + duration, straight off the engine's per-frame feed.
|
||||||
@@ -60,7 +60,6 @@ export function App() {
|
|||||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
|
||||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
|
||||||
import { WorkspaceView } from 'officerdev';
|
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
|
||||||
import { defaultLayout } from './defaultLayout';
|
|
||||||
|
|
||||||
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
|
|
||||||
// (music-browser) and the content/detail (music-detail). They do not coordinate with each other; both
|
|
||||||
// read `?path=` off the URL. No route pair and no guard: the bare /music is the library root, a real
|
|
||||||
// state, and an unknown path gets an empty listing rather than a rewritten address.
|
|
||||||
|
|
||||||
export const MusicScreen = () => {
|
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full pt-2">
|
|
||||||
<WorkspaceView
|
|
||||||
workspace={workspace}
|
|
||||||
locked
|
|
||||||
appTypes={{ allowed: ['music-browser', 'music-detail'], fallback: 'music-detail' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './MusicScreen';
|
|
||||||
@@ -13,7 +13,6 @@ export * from './Tasks';
|
|||||||
export * from './Files';
|
export * from './Files';
|
||||||
export * from './Calendar';
|
export * from './Calendar';
|
||||||
export * from './Contacts';
|
export * from './Contacts';
|
||||||
export * from './Music';
|
|
||||||
export * from './Soulseek';
|
export * from './Soulseek';
|
||||||
export * from './Photos';
|
export * from './Photos';
|
||||||
export * from './Jellyfin';
|
export * from './Jellyfin';
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/files'), title: 'Files' },
|
{ match: (p) => p.startsWith('/files'), title: 'Files' },
|
||||||
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
|
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
|
||||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
|
||||||
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
|
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
|
||||||
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
|
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
|
||||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export * from './dav';
|
|||||||
export * from './email';
|
export * from './email';
|
||||||
export * from './invoiceshelf';
|
export * from './invoiceshelf';
|
||||||
export * from './jellyfin';
|
export * from './jellyfin';
|
||||||
export * from './music';
|
|
||||||
export * from './notify';
|
export * from './notify';
|
||||||
export * from './photos';
|
export * from './photos';
|
||||||
export * from './soulseek';
|
export * from './soulseek';
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
export {
|
|
||||||
getMusicFavorites,
|
|
||||||
addMusicFavorite,
|
|
||||||
removeMusicFavorite,
|
|
||||||
getNowPlaying,
|
|
||||||
setNowPlaying,
|
|
||||||
clearNowPlaying,
|
|
||||||
getPlaylists,
|
|
||||||
getPlaylist,
|
|
||||||
createPlaylist,
|
|
||||||
renamePlaylist,
|
|
||||||
deletePlaylist,
|
|
||||||
addPlaylistItems,
|
|
||||||
setPlaylistItems,
|
|
||||||
} from './queries';
|
|
||||||
|
|
||||||
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput, PlaylistSummary, Playlist } from './queries';
|
|
||||||
@@ -44,7 +44,6 @@ export * from './service-connections/schema'; // service_connections
|
|||||||
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
|
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
|
||||||
|
|
||||||
// export * from './email/schema'; // email_accounts officer-email
|
// export * from './email/schema'; // email_accounts officer-email
|
||||||
// export * from './music/schema'; // music_favorites, _playlists, _playlist_items, _now_playing
|
|
||||||
// export * from './notify/schema'; // push_devices officer-notify
|
// export * from './notify/schema'; // push_devices officer-notify
|
||||||
// export * from './dav/schema'; // dav_app_passwords officer-caldav
|
// export * from './dav/schema'; // dav_app_passwords officer-caldav
|
||||||
// export * from './photos/schema'; // photos_config officer-photos
|
// export * from './photos/schema'; // photos_config officer-photos
|
||||||
|
|||||||
@@ -386,17 +386,22 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
// form. Duplicating it here would be a second place to maintain the same credentials.
|
// form. Duplicating it here would be a second place to maintain the same credentials.
|
||||||
configFields: [],
|
configFields: [],
|
||||||
},
|
},
|
||||||
{
|
// Music was here until 2026-08-15, when it became `plugins/music/`. Removing it was not tidying — it
|
||||||
id: 'music',
|
// was the headscale bug above, exactly, and it would have fired on the first install.
|
||||||
ui: { name: 'Music', icon: 'Music', color: '#22c55e', rootRoute: '/music', routes: ['/music'] },
|
//
|
||||||
process: 'officer-music',
|
// `capabilityAvailability` reads `sidecar_installs`, and a PLUGIN never gets a row there: its install
|
||||||
label: 'Music',
|
// state lives in `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
|
||||||
summary: 'Index and play the library on this machine',
|
// `/music` into `deniedRoutes` — the screen blank and the dock tile withheld on a server where the
|
||||||
members: 'none',
|
// plugin was installed, enabled and healthy. The same shape as headscale, found by reading that note
|
||||||
modes: ['config'],
|
// rather than by hitting it again.
|
||||||
capability: 'music',
|
//
|
||||||
configFields: [{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }],
|
// The tile now comes from `pluginDockManifests()`, and the library folder is not configured at all:
|
||||||
},
|
// the sidecar reads `~/Music`. That `configFields` entry wrote a `service_connections` row nothing
|
||||||
|
// ever read.
|
||||||
|
//
|
||||||
|
// `[open]` This is the "two dock sources" seam. The store keeps its own catalogue while the plugin
|
||||||
|
// system builds tiles from manifests, and the self endpoint concatenates both. One when the store is
|
||||||
|
// rebuilt on the plugin system.
|
||||||
{
|
{
|
||||||
id: 'wallet',
|
id: 'wallet',
|
||||||
ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
|
ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { Capability } from './registry';
|
||||||
import {
|
import {
|
||||||
CAPABILITIES,
|
CAPABILITIES,
|
||||||
CAPABILITY_BY_KEY,
|
CAPABILITY_BY_KEY,
|
||||||
@@ -57,8 +58,8 @@ describe('totality', () => {
|
|||||||
|
|
||||||
test('refuses a claim on a socket that is not served', () => {
|
test('refuses a claim on a socket that is not served', () => {
|
||||||
const s = surface();
|
const s = surface();
|
||||||
s.wsProviders = REAL_WS.filter((p) => p !== 'cliamp');
|
s.wsProviders = REAL_WS.filter((p) => p !== 'terminal');
|
||||||
expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'cliamp', which is not served/);
|
expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'terminal', which is not served/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no two capabilities claim the same prefix', () => {
|
test('no two capabilities claim the same prefix', () => {
|
||||||
@@ -74,7 +75,7 @@ describe('totality', () => {
|
|||||||
test('signin is exempt and gitea is not', () => {
|
test('signin is exempt and gitea is not', () => {
|
||||||
expect(isExemptApiPath('/api/auth/signin')).toBe(true);
|
expect(isExemptApiPath('/api/auth/signin')).toBe(true);
|
||||||
expect(isExemptApiPath('/api/gitea')).toBe(false);
|
expect(isExemptApiPath('/api/gitea')).toBe(false);
|
||||||
expect(isExemptApiPath('/api/music/albums')).toBe(false);
|
expect(isExemptApiPath('/api/dashboards/4')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,8 +86,9 @@ describe('path → capability', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('does not match a prefix that is merely a string prefix', () => {
|
test('does not match a prefix that is merely a string prefix', () => {
|
||||||
// '/api/musicbrainz' must not resolve to the 'music' capability. A naive startsWith would.
|
// '/api/username' must not resolve to the 'account' capability, which claims '/user'. A naive
|
||||||
expect(capabilityForApiPath('/api/musicbrainz')).toBeNull();
|
// startsWith would. (This was '/api/musicbrainz' against 'music' until music became a plugin.)
|
||||||
|
expect(capabilityForApiPath('/api/username')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('longest prefix wins, so /dav and /caldav do not fight', () => {
|
test('longest prefix wins, so /dav and /caldav do not fight', () => {
|
||||||
@@ -99,34 +101,72 @@ describe('path → capability', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sockets resolve to their capability', () => {
|
test('sockets resolve to their capability', () => {
|
||||||
expect(capabilityForWsProvider('cliamp')?.key).toBe('music');
|
|
||||||
expect(capabilityForWsProvider('terminal')?.key).toBe('terminal');
|
expect(capabilityForWsProvider('terminal')?.key).toBe('terminal');
|
||||||
|
expect(capabilityForWsProvider('chat')?.key).toBe('chat');
|
||||||
expect(capabilityForWsProvider('nope')).toBeNull();
|
expect(capabilityForWsProvider('nope')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `cliamp` and `cliamp-audio` are SERVED in server.tsx's route table and claimed by nothing, because
|
||||||
|
// music's `ws` list was commented out on 2026-08-13 and the capability itself left with the plugin on
|
||||||
|
// 2026-08-15. They upgrade into handlers that are commented out too, so nothing is reachable — but the
|
||||||
|
// boot check cannot see the drift, since it reads `Object.keys(handlers)` rather than the route table.
|
||||||
|
//
|
||||||
|
// Pinned here so the hole is a documented fact with a test on it rather than something to rediscover.
|
||||||
|
// Closing it is the totality work in plugins/EXTRACTING-A-PLUGIN.md, not this file's.
|
||||||
|
test('the cliamp sockets are claimed by nothing — known drift, see server.tsx', () => {
|
||||||
|
expect(capabilityForWsProvider('cliamp')).toBeNull();
|
||||||
|
expect(capabilityForWsProvider('cliamp-audio')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('levels', () => {
|
describe('levels', () => {
|
||||||
const music = CAPABILITY_BY_KEY.get('music')!;
|
// A FIXTURE, not a registry entry.
|
||||||
|
//
|
||||||
|
// These tests ran against the real `music` capability until 2026-08-15, when music left with
|
||||||
|
// `plugins/music/`. Re-anchoring them on whichever entry happens to have a `personal` list today only
|
||||||
|
// moves the problem to the next extraction — and it had already half-broken before that, because the
|
||||||
|
// moment music's `api` was commented out (2026-08-13) every path below stopped matching and three of
|
||||||
|
// these four tests passed for the wrong reason: everything is refused when nothing is claimed.
|
||||||
|
//
|
||||||
|
// `isRequestAllowedAtLevel` is a pure function of a Capability. Handing it one states what is actually
|
||||||
|
// under test — the RULE — rather than borrowing a feature that can leave.
|
||||||
|
const fixture: Capability = {
|
||||||
|
key: 'fixture',
|
||||||
|
label: 'Fixture',
|
||||||
|
description: 'Not in the registry — a shape to exercise the level rules against',
|
||||||
|
kind: 'app',
|
||||||
|
api: ['/fixture'],
|
||||||
|
personal: ['/favorites', '/now-playing'],
|
||||||
|
};
|
||||||
|
|
||||||
test('write permits anything within the capability', () => {
|
test('write permits anything within the capability', () => {
|
||||||
expect(isRequestAllowedAtLevel(music, 'write', 'DELETE', '/api/music/track/9')).toBe(true);
|
expect(isRequestAllowedAtLevel(fixture, 'write', 'DELETE', '/api/fixture/track/9')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('read permits safe methods', () => {
|
test('read permits safe methods', () => {
|
||||||
expect(isRequestAllowedAtLevel(music, 'read', 'GET', '/api/music/albums')).toBe(true);
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'GET', '/api/fixture/albums')).toBe(true);
|
||||||
expect(isRequestAllowedAtLevel(music, 'read', 'HEAD', '/api/music/albums')).toBe(true);
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'HEAD', '/api/fixture/albums')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('read permits mutations only under personal sub-paths', () => {
|
test('read permits mutations only under personal sub-paths', () => {
|
||||||
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites/7')).toBe(true);
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
|
||||||
expect(isRequestAllowedAtLevel(music, 'read', 'PUT', '/api/music/now-playing')).toBe(true);
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'PUT', '/api/fixture/now-playing')).toBe(true);
|
||||||
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/scan')).toBe(false);
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/scan')).toBe(false);
|
||||||
expect(isRequestAllowedAtLevel(music, 'read', 'DELETE', '/api/music/track/9')).toBe(false);
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'DELETE', '/api/fixture/track/9')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a personal entry does not leak across a name boundary', () => {
|
test('a personal entry does not leak across a name boundary', () => {
|
||||||
// '/favorites-export' must not be covered by the '/favorites' personal entry.
|
// '/favorites-export' must not be covered by the '/favorites' personal entry.
|
||||||
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites-export')).toBe(false);
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites-export')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plugin gets the same rule through readOnlyWrites', () => {
|
||||||
|
// The two lists are concatenated, so a plugin declaring per-caller paths on the one field its
|
||||||
|
// manifest has behaves identically to a core capability declaring `personal`. This is what music
|
||||||
|
// relies on now that it ships as one.
|
||||||
|
const plugin: Capability = { ...fixture, personal: undefined, readOnlyWrites: ['/favorites', '/now-playing'] };
|
||||||
|
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
|
||||||
|
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/scan')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,13 @@
|
|||||||
//
|
//
|
||||||
// A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in
|
// A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in
|
||||||
// the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing
|
// the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing
|
||||||
// else. Music is the worked example: `/favorites`, `/now-playing` and `/playlists` are already per-caller
|
// else. `dashboards` is the worked example: a dashboard belongs to the account that made it, so the whole
|
||||||
// in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write
|
// surface is personal and a read grant is really "your own, fully". So "may a member write here" is a
|
||||||
// here" is a property of the endpoint, not a policy knob someone has to remember to set.
|
// property of the endpoint, not a policy knob someone has to remember to set.
|
||||||
|
//
|
||||||
|
// Music was this note's example until 2026-08-15, when it left with `plugins/music/`. A plugin declares
|
||||||
|
// the same thing through its manifest's `readOnlyWrites` — `isRequestAllowedAtLevel` merges the two lists,
|
||||||
|
// so they are one mechanism under two names.
|
||||||
|
|
||||||
export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
|
export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
|
||||||
|
|
||||||
@@ -75,8 +79,8 @@ export type Capability = {
|
|||||||
routes?: string[];
|
routes?: string[];
|
||||||
/**
|
/**
|
||||||
* Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only
|
* Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only
|
||||||
* the caller's own data. Matched as a prefix after the capability's own: `/favorites` on the `music`
|
* the caller's own data. Matched as a prefix after the capability's own: `/devices` on the `notify`
|
||||||
* capability permits `POST /api/music/favorites/123`.
|
* capability permits `POST /api/notify/devices/123`.
|
||||||
*/
|
*/
|
||||||
personal?: string[];
|
personal?: string[];
|
||||||
/**
|
/**
|
||||||
@@ -146,19 +150,6 @@ const CORE_REGISTRY: Capability[] = [
|
|||||||
// commenting on their own issues — security theatre with a real cost and no benefit.
|
// commenting on their own issues — security theatre with a real cost and no benefit.
|
||||||
personal: ['/'],
|
personal: ['/'],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'music',
|
|
||||||
label: 'Music',
|
|
||||||
description: 'The music library, playback, and your own favourites and playlists',
|
|
||||||
kind: 'app',
|
|
||||||
// api: ['/music'], // plugin — switched off 2026-08-13
|
|
||||||
api: [],
|
|
||||||
// ws: ['cliamp', 'cliamp-audio'], // plugin — switched off 2026-08-13
|
|
||||||
routes: ['/music'],
|
|
||||||
// Already per-caller in the sidecar contract (X-Officer-User), which is what makes them safe to write
|
|
||||||
// at read level. The library itself — scanning, tags, file moves — is not, and is not listed.
|
|
||||||
personal: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'photos',
|
key: 'photos',
|
||||||
label: 'Photos',
|
label: 'Photos',
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { settingsRouter } from './api/settings/settings';
|
|||||||
import { dashboardsRouter } from './api/dashboards';
|
import { dashboardsRouter } from './api/dashboards';
|
||||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||||
import { pluginsRouter } from './api/plugins/router';
|
import { pluginsRouter } from './api/plugins/router';
|
||||||
// import { musicRouter } from './api/music/router';
|
|
||||||
// import { vaultRouter } from './api/vault/router';
|
// import { vaultRouter } from './api/vault/router';
|
||||||
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
|
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
|
||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
@@ -133,7 +132,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
|||||||
['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
|
['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
|
||||||
['/dashboards', dashboardsRouter],
|
['/dashboards', dashboardsRouter],
|
||||||
['/file-browser', fileBrowserRouter],
|
['/file-browser', fileBrowserRouter],
|
||||||
// ['/music', musicRouter], // plugin — switched off 2026-08-13
|
|
||||||
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
|
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
|
||||||
['/terminal', terminalRouter],
|
['/terminal', terminalRouter],
|
||||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
|
|||||||
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||||
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
|
||||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||||
import { appRegistryMetas as photosMetas } from '../apps/Photos';
|
import { appRegistryMetas as photosMetas } from '../apps/Photos';
|
||||||
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
||||||
@@ -33,7 +32,6 @@ export const apps = [
|
|||||||
...chatHistoryMetas,
|
...chatHistoryMetas,
|
||||||
...widgetMetas,
|
...widgetMetas,
|
||||||
...desktopMetas,
|
...desktopMetas,
|
||||||
...musicMetas,
|
|
||||||
...soulseekMetas,
|
...soulseekMetas,
|
||||||
...photosMetas,
|
...photosMetas,
|
||||||
...jellyfinMetas,
|
...jellyfinMetas,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { useCapabilities } from 'hooks/useCapabilities';
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
||||||
import { MusicHeart } from '../apps/Music/MusicHeart';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
|
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
||||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||||
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
||||||
|
|||||||
@@ -1,2 +1,50 @@
|
|||||||
export * from './useMusicPlayer';
|
// The music player the SHELL hosts.
|
||||||
export * from './MusicPlayerHost';
|
//
|
||||||
|
// ── Why this is still in the platform after music became a plugin ──
|
||||||
|
//
|
||||||
|
// Music was extracted on 2026-08-15 (`plugins/music/`) and this directory deliberately did not go with
|
||||||
|
// it. It is the one seam that extraction could not close, and the reason is not the overlay — it is the
|
||||||
|
// state:
|
||||||
|
//
|
||||||
|
// `useMusicPlayer` is imported from `officerdev` by `src/workspaces/widgets/MusicPlayer/`, the
|
||||||
|
// dashboard widget, which is out of scope and stays. The platform cannot import from a plugin, so the
|
||||||
|
// player state stays here whatever is decided about the UI around it — and two copies of it would mean
|
||||||
|
// two audio engines fighting over one pair of speakers.
|
||||||
|
//
|
||||||
|
// Given the state had to stay, the engine and the bar stayed with it rather than being split from the
|
||||||
|
// thing they drive. `MusicPlayerHost` is mounted once by `DashboardLayout`, OUTSIDE `<Routes>`, which is
|
||||||
|
// what makes playback survive navigation — and a plugin has no way to ask for that. Contributing one
|
||||||
|
// would mean a shell slot that renders a plugin-provided component on every route, which is exactly the
|
||||||
|
// escape hatch the plugin system deleted on purpose: there is no way to export a component, and that is
|
||||||
|
// what makes "every plugin route is a Workspace" a property of the shape rather than a rule to remember.
|
||||||
|
//
|
||||||
|
// The seam is inert without the plugin. `MusicPlayerHost` gates on `can('music')`, and `music` is now the
|
||||||
|
// plugin's permission — registered at install, gone at uninstall — so the overlay switches itself off
|
||||||
|
// with the plugin and no code here knows why.
|
||||||
|
//
|
||||||
|
// ── What the plugin imports, and from where ──
|
||||||
|
//
|
||||||
|
// The player API is below, on the `officerdev` barrel. The library VOCABULARY — `shared.ts`, the paths,
|
||||||
|
// sorting and tag shapes — is not: it declares `DirEntry`, `Track` and `Manifest`, names the barrel
|
||||||
|
// already spends on the FileBrowser. `plugins/music/web/shared.ts` takes it from the package's declared
|
||||||
|
// `officerdev/MusicPlayer/shared` subpath instead, which keeps one definition without renaming a type on
|
||||||
|
// its way through a barrel.
|
||||||
|
|
||||||
|
export { useMusicPlayer } from './useMusicPlayer';
|
||||||
|
export type { PlayerTrack, MusicPlayerState } from './useMusicPlayer';
|
||||||
|
|
||||||
|
export { MusicPlayerHost } from './MusicPlayerHost';
|
||||||
|
export { MusicHeart } from './MusicHeart';
|
||||||
|
export { useMusicFavorites } from './useMusicFavorites';
|
||||||
|
|
||||||
|
// The engine↔UI bridge. Module-level singletons on purpose: the lyrics pane and the /music scrubber live
|
||||||
|
// in another React tree from the host that owns the engine, so they meet here rather than through props.
|
||||||
|
export {
|
||||||
|
publishPlayerTime,
|
||||||
|
subscribePlayerTime,
|
||||||
|
registerPlayerSeek,
|
||||||
|
seekPlayer,
|
||||||
|
getPlayerTime,
|
||||||
|
getPlayerDuration,
|
||||||
|
} from './player-time';
|
||||||
|
export { useLyricsOpen } from './useLyricsOpen';
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
|
||||||
import { Music, ListMusic } from 'lucide-react';
|
|
||||||
import { MusicBrowser } from './MusicBrowser';
|
|
||||||
import { MusicDetail } from './MusicDetail';
|
|
||||||
|
|
||||||
export { MusicBrowser, MusicDetail };
|
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
|
||||||
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
|
||||||
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
|
||||||
];
|
|
||||||
@@ -111,6 +111,10 @@ export {
|
|||||||
ARCHIVE_EXTS,
|
ARCHIVE_EXTS,
|
||||||
} from './apps/FileViewer';
|
} from './apps/FileViewer';
|
||||||
export type { FileType } from './apps/FileViewer';
|
export type { FileType } from './apps/FileViewer';
|
||||||
|
// The scrubber, shared by the FileViewer's audio/video renderers, the global player bar and the /music
|
||||||
|
// panels in `plugins/music/`. On the barrel rather than reached for by subpath because the package's
|
||||||
|
// `"./*"` export maps to `.ts` only, and this is a `.tsx`.
|
||||||
|
export { SeekBar, useSeekBar } from './apps/FileViewer/renderers/SeekBar';
|
||||||
export { TerminalView } from './apps/Terminal';
|
export { TerminalView } from './apps/Terminal';
|
||||||
export type { TerminalViewProps } from './apps/Terminal';
|
export type { TerminalViewProps } from './apps/Terminal';
|
||||||
export { DesktopView } from './apps/Desktop';
|
export { DesktopView } from './apps/Desktop';
|
||||||
|
|||||||
Reference in New Issue
Block a user