plum-control-mcp/README.md
Natalie 4c8b5702f9 feat(@applications/plum-control-mcp): add http bridge endpoint for tvanarchy
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:50:44 -07:00

145 lines
8.1 KiB
Markdown

# plum-control-mcp
MCP server for controlling VLC and listing displays on plum (the MacBook). Exposes proper seek/scrub via VLC's HTTP/Lua interface — AppleScript only does play/pause/next/prev and silently ignores `set current time`.
## Tools
**VLC** (`vlc_*`):
| Tool | Inputs | What it does |
|---|---|---|
| `vlc_status` | — | playing state, time, length, volume, fullscreen, current filename |
| `vlc_play_pause` | — | toggle |
| `vlc_next` / `vlc_previous` | — | playlist nav |
| `vlc_seek_to_seconds` | `seconds: number` | absolute seek |
| `vlc_seek_relative` | `seconds: number` | +/- relative seek |
| `vlc_set_volume` | `volume: 0..512` | 256 = 100%, 512 = 200% boost |
| `vlc_fullscreen_toggle` | — | |
| `vlc_play_file` | `path: string` | replace playlist + play |
| `vlc_enqueue_file` | `path: string` | append to playlist |
| `vlc_clear_playlist` | — | empty playlist (doesn't stop current item) |
**Display** (`display_*`):
| Tool | Inputs | What it does |
|---|---|---|
| `display_list` | — | array of `{ index, displayId, name, width, height, originX, originY, isPrimary, isBuiltIn }` |
| `display_set_vlc_fullscreen_output` | `displayId?: number, preferTv?: boolean` | set VLC's `macosx-vdev` pref so Cmd-F goes to a specific display (default: first external screen). |
**Media** (`media_*`) — TV-show library + resume:
| Tool | Inputs | What it does |
|---|---|---|
| `media_recents` | `limit?: number` | VLC's recently-played list with per-file position (s) and MRU rank, from the macOS plist. |
| `media_list_shows` | — | scan `MEDIA_ROOTS` (default `~/media`) for SxxEyy-named videos; return shows with episode counts + seasons. |
| `media_resume_show` | `show: string` | find latest-watched ep of show in VLC recents, replace playlist with that ep → end of series. |
| `media_play_show` | `show: string, season?: number, episode?: number` | replace playlist with show from given S/E (default S1E1) → end. |
VLC's plist is the source of truth for "where did we leave off" — no parallel state store. Show matching is case-insensitive substring against directory names (with release-group/year/codec noise stripped).
**Black TV** (`black_*`) — the HDMI TV physically attached to **black** (the media server), driven by mpv straight to the DRM console (no X). Unlike `vlc_*` (plum's VLC), these play black's *local* `/bigdisk` library, so they work even when plum is off-LAN / NFS is down. One long-lived mpv is controlled over its IPC socket, so volume/seek/pause never restart playback.
| Tool | Inputs | What it does |
|---|---|---|
| `black_status` | — | `{playing, paused, title, volume, position, duration, playlist_pos, playlist_count}` (or `{playing:false}`) |
| `black_play_show` | `show: string, season?, episode?` | resolve a show under black's `tv/cartoons/anime` (prefers a 1080p release), build an ordered playlist, play **from the start** to the end |
| `black_resume_show` | `show: string` | **continue watching** — resume the exact episode + second last stopped (falls back to start if no saved position) |
| `black_play_file` | `path: string` | play a file or directory by absolute path on black |
| `black_enqueue` | `target: string` | append a file/dir/show to the current playlist without interrupting |
| `black_play_index` | `index: number` | jump to a 0-based playlist entry |
| `black_play_pause` / `black_resume` | — | live pause toggle / resume |
| `black_set_volume` | `volume: 0..130` | live; 100 = normal, >100 = software boost |
| `black_seek_relative` | `seconds: number` | live +/- seek |
| `black_next` / `black_previous` | — | playlist nav (next/prev episode) |
| `black_watched` | `show?: string` | black's local watch history (newest last) |
| `black_stop` | — | stop and release the display |
**Persistence.** An mpv Lua hook ([`src/blacktv/black-tv-watch.lua`](src/blacktv/black-tv-watch.lua), deployed to `/usr/local/share/black-tv/`) records a `play` event per episode to a black-local watch log and snapshots the current position into a per-show `resume.json` (`$XDG_STATE_HOME/black-tv/`, i.e. lilith's home on black). `black_resume_show` reads that map and the hook **self-seeks the first file** to the saved second — so resume never leaks into later episodes (a global `--start` would). This state is **black-local on purpose**: it is read over SSH, never written to plum's log over NFS (the flakiest link). `black_play_show` uses `--no-resume-playback`, so deliberate restarts always begin at 0.
All black-side logic lives in [`src/blacktv/black-tv.sh`](src/blacktv/black-tv.sh) (deployed to `/usr/local/bin/black-tv` on black); the TS layer just SSHes to it (`lilith@10.9.0.4`), mirroring how `transmission_*` wraps `transmission-remote`. The script brings up the GPU driver on demand (nouveau, atomic KMS) since black boots headless. **No HDMI-CEC** — the TV must be powered on by hand. Deploy/update with:
```sh
scp src/blacktv/black-tv.sh black-wg:/tmp/black-tv && \
ssh black-wg 'sudo install -m0755 /tmp/black-tv /usr/local/bin/black-tv'
scp src/blacktv/black-tv-watch.lua black-wg:/tmp/h.lua && \
ssh black-wg 'sudo install -D -m0644 /tmp/h.lua /usr/local/share/black-tv/black-tv-watch.lua'
```
## Setup
### One-time: enable VLC's HTTP/Lua interface
1. VLC → **Preferences** → bottom-left **Show All**.
2. **Interface → Main interfaces** → check **Web**.
3. **Interface → Main interfaces → Lua** → set **Lua HTTP password** to anything strong.
4. Quit + relaunch VLC. (The Web interface only loads at startup.)
5. Confirm: `curl -u :"$VLC_HTTP_PASSWORD" http://127.0.0.1:8080/requests/status.json` should return JSON.
### Install
```sh
cd ~/Code/@applications/plum-control-mcp
bun install
bun run typecheck
```
### Register with Claude Code
Set the password in your shell or in the MCP config block:
```sh
claude mcp add plum-control \
--command bun \
--args "run,$HOME/Code/@applications/plum-control-mcp/src/index.ts" \
--env "VLC_HTTP_PASSWORD=your-vlc-password"
```
Or edit `~/.claude.json` directly with the same effect.
## HTTP bridge (`src/http.ts`) — for the TVAnarchy iOS app
The same domain logic, exposed over plain HTTP/JSON for clients that can't speak
stdio JSON-RPC or shell out (the iOS app). `Bun.serve`, stderr logging.
```sh
BRIDGE_PORT=8787 MEDIA_ROOTS=~/media bun run bridge # or: bun run src/http.ts
```
Routes: `GET /healthz`, `GET /library/shows`, `GET|HEAD /stream/:id` (HTTP Range),
`GET /artwork/:id` (ffmpeg frame-grab), `POST /watch/progress`,
`GET /watch/continue` (resume + next-episode for prefetch), `GET /watch/episode/:id`,
`GET /remote/status` + `POST /remote/command` + `POST /remote/play` (Black TV),
`GET /torrents`, `GET /torrents/search`, `POST /torrents`, `DELETE /torrents/:id`.
Stream ids are base64url of the file path, re-validated under `MEDIA_ROOTS` on
every request (no arbitrary-file disclosure). Set `BRIDGE_TOKEN` on a
mesh-reachable host — it's required as a `Bearer` header (or `?token=` on media
URLs). Known limitation: `/remote/*` and `/torrents*` shell out to black over ssh
synchronously and briefly block the event loop; fine for personal single-user use.
### Deploy on black
```sh
# on black:
~/…/plum-control-mcp/deploy/install-bridge.sh # installs a systemd --user unit
# then edit ~/.config/tvanarchy-bridge/bridge.env (set BRIDGE_TOKEN) and:
systemctl --user restart tvanarchy-bridge
```
See `deploy/` for the unit, env template, and installer.
## Env vars
| Var | Default | Notes |
|---|---|---|
| `VLC_HTTP_HOST` | `127.0.0.1` | Where VLC is running. Cross-host access requires VLC's HTTP-Bind setting. |
| `VLC_HTTP_PORT` | `8080` | VLC's web port. |
| `VLC_HTTP_PASSWORD` | (required) | Lua HTTP password. No insecure fallback. |
| `MEDIA_ROOTS` | `~/media` | Colon-separated list of directories scanned by `media_*` tools. |
## Constraints
- macOS only (display_list uses NSScreen via osascript-jxa; `media_recents` reads `org.videolan.vlc.plist`).
- VLC must be running and have the Web interface enabled. Tools error with a clear message if the server is unreachable.
- Window-positioning across displays needs Accessibility permission and isn't in v1.
- Episode parsing relies on `SxxEyy` in the filename — files without that pattern are skipped.