# 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.