feat(@applications/plum-control-mcp): position persistence + resume-show for black's TV

- mpv Lua hook (black-tv-watch.lua): per-episode watch log + per-show resume.json
- self-seeks first file on resume (script-opts), so resume never leaks via global --start
- new verbs/tools: resume_show, enqueue, play_index (goto-ep), watched
- launch hardened: --no-resume-playback; volume default via BLACKTV_VOLUME (100)
- state is black-local (read over SSH, never plum's NFS log); README documents it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-07 19:37:12 -07:00
parent 845f67d34a
commit 6680cc6e7f
5 changed files with 284 additions and 4 deletions

View file

@ -42,19 +42,27 @@ VLC's plist is the source of truth for "where did we leave off" — no parallel
| 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 to the end |
| `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

View file

@ -0,0 +1,108 @@
-- black-tv-watch.lua — playback persistence for black-tv.
--
-- Loaded by mpv (`--script=`). Two jobs, both black-local (this state never
-- crosses to plum — see README): append a "play" event to a watch history on
-- every file load, and keep a small per-show "resume.json" map of the last
-- position so `black-tv resume-show` can pick up the exact episode + second.
--
-- It also performs the resume seek itself: when launched with
-- `--script-opts=blacktv-resume_seconds=N`, it seeks the FIRST loaded file to N
-- and then forgets it — so resume can never leak into later episodes the way a
-- global --start does. Every fs op is guarded; a failure here must never take
-- down playback.
local utils = require "mp.utils"
local options = require "mp.options"
local opts = {
state_dir = "", -- explicit state dir (script passes this); else XDG/HOME
media_root = "/bigdisk/_/media", -- to derive the show-dir key from a path
resume_seconds = -1, -- seek first file here, then forget (<0 = disabled)
save_interval = 20, -- seconds between position snapshots
}
options.read_options(opts, "blacktv")
if opts.state_dir == "" then
local xdg = os.getenv("XDG_STATE_HOME")
local home = os.getenv("HOME") or "."
if xdg and xdg ~= "" then opts.state_dir = xdg .. "/black-tv" else opts.state_dir = home .. "/.local/state/black-tv" end
end
local WATCHLOG = opts.state_dir .. "/watched.jsonl"
local RESUME = opts.state_dir .. "/resume.json"
local resume_pending = tonumber(opts.resume_seconds) or -1
local function ensure_dir()
os.execute('mkdir -p "' .. opts.state_dir .. '" 2>/dev/null')
end
-- show key = the directory immediately under <media_root>/<category>/ (e.g.
-- "Psych"). Matches how black-tv.sh resolves shows, so the bash side owns
-- show-name handling and this script doesn't duplicate the normalize regex.
local function show_key(path)
local root = opts.media_root:gsub("/+$", "")
if path:sub(1, #root + 1) == root .. "/" then
local rel = path:sub(#root + 2)
local _, show = rel:match("^([^/]+)/([^/]+)")
if show and show ~= "" then return show end
end
return mp.get_property("filename/no-ext") or "unknown"
end
local function season_episode(path)
local s, e = path:match("[Ss](%d+)[Ee](%d+)")
return tonumber(s) or 0, tonumber(e) or 0
end
local function now_iso()
return os.date("!%Y-%m-%dT%H:%M:%SZ")
end
local function append_play(path)
ensure_dir()
local s, e = season_episode(path)
local ev = {
ts = now_iso(), event = "play", show = show_key(path),
season = s, episode = e,
label = mp.get_property("filename/no-ext") or "", path = path,
}
local ok, line = pcall(utils.format_json, ev)
if not ok then return end
local f = io.open(WATCHLOG, "a")
if f then f:write(line .. "\n"); f:close() end
end
local function read_resume_map()
local f = io.open(RESUME, "r")
if not f then return {} end
local txt = f:read("*a"); f:close()
local t = utils.parse_json(txt or "")
return (type(t) == "table") and t or {}
end
local function save_position()
local path = mp.get_property("path")
local pos = mp.get_property_number("time-pos")
if not path or not pos then return end
ensure_dir()
local m = read_resume_map()
local s, e = season_episode(path)
m[show_key(path)] = { path = path, season = s, episode = e, seconds = pos, ts = now_iso() }
local ok, body = pcall(utils.format_json, m)
if not ok then return end
local tmp = RESUME .. ".tmp"
local f = io.open(tmp, "w")
if f then f:write(body); f:close(); os.rename(tmp, RESUME) end
end
mp.register_event("file-loaded", function()
local path = mp.get_property("path")
if path then append_play(path) end
if resume_pending and resume_pending >= 0 then
mp.commandv("seek", tostring(resume_pending), "absolute", "exact")
resume_pending = -1 -- first file only
end
end)
mp.add_periodic_timer(opts.save_interval, save_position)
mp.register_event("end-file", save_position)
mp.register_event("shutdown", save_position)

View file

@ -24,7 +24,12 @@ SOCK="/tmp/mpv.sock"
PLAYLIST="/tmp/net-tv.m3u"
CONNECTOR="HDMI-A-1" # the Samsung TV; DVI-I-1 is a spurious phantom
AUDIO_DEV="alsa/hdmi:CARD=NVidia,DEV=0" # sets HDMI IEC958 bits; plughw does not
DEFAULT_VOL=50
DEFAULT_VOL="${BLACKTV_VOLUME:-100}"
# black-local playback state (mpv hook writes it; never crosses to plum)
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/black-tv"
WATCHLOG="$STATE_DIR/watched.jsonl"
RESUME="$STATE_DIR/resume.json"
LUA="/usr/local/share/black-tv/black-tv-watch.lua" # mpv persistence hook
# whole-path, case-insensitive video match (GNU find)
VIDEO_RE='.*\.\(mkv\|mp4\|avi\|m4v\|webm\|ts\)$'
@ -77,13 +82,21 @@ kill_existing() {
rm -f "$SOCK" 2>/dev/null || true
sleep 1
}
launch() { # launch <playlist-file>
launch() { # launch <playlist-file> [resume_seconds]
ensure_display
kill_existing
mkdir -p "$STATE_DIR" 2>/dev/null || true
local resume="${2:--1}"
# the persistence hook records history + position and self-seeks the first
# file to <resume_seconds>; if the hook isn't deployed, mpv still plays.
local hook=()
[ -f "$LUA" ] && hook=(--script="$LUA" \
--script-opts="blacktv-state_dir=$STATE_DIR,blacktv-media_root=$MEDIA_ROOT,blacktv-resume_seconds=$resume")
sudo systemd-run --unit="$UNIT" --collect \
mpv --vo=drm --drm-connector="$CONNECTOR" \
--ao=alsa --audio-device="$AUDIO_DEV" --audio-channels=stereo \
--volume="$DEFAULT_VOL" --input-ipc-server="$SOCK" \
--no-resume-playback "${hook[@]}" \
--fs --really-quiet --playlist="$1"
}
@ -131,6 +144,28 @@ build_show_playlist() { # <showdir> <season?> <episode?> -> writes $PLAYLIST
rm -f "$PLAYLIST.all"
}
read_resume_for_key() { # <show-dir-basename> -> "<path>\t<seconds>" (empty if none)
[ -f "$RESUME" ] || return 0
python3 - "$RESUME" "$1" <<'PY' 2>/dev/null || true
import json, sys
try:
e = json.load(open(sys.argv[1])).get(sys.argv[2])
if e and e.get("path"):
print("%s\t%s" % (e["path"], e.get("seconds", 0)))
except Exception:
pass
PY
}
build_resume_playlist() { # <showdir> <resume-path> -> writes $PLAYLIST from that ep onward
local showdir="$1" rpath="$2" base start
base=$(pick_release_root "$showdir")
find "$base" -type f -iregex "$VIDEO_RE" ! -ipath '*/Specials/*' ! -ipath '*/Extras/*' \
| sort > "$PLAYLIST.all"
start=$(grep -nF "$rpath" "$PLAYLIST.all" | head -1 | cut -d: -f1)
if [ -n "$start" ]; then tail -n +"$start" "$PLAYLIST.all" > "$PLAYLIST"; else cp "$PLAYLIST.all" "$PLAYLIST"; fi
rm -f "$PLAYLIST.all"
}
status_json() {
if [ ! -S "$SOCK" ]; then echo '{"playing":false}'; return; fi
printf '{"playing":true,"paused":%s,"title":%s,"volume":%s,"position":%s,"duration":%s,"playlist_pos":%s,"playlist_count":%s}\n' \
@ -159,6 +194,40 @@ case "$cmd" in
[ "${n:-0}" -gt 0 ] || die "no episodes found for: $1"
launch "$PLAYLIST"
echo "playing $n episode(s) from $(basename "$showdir")${2:+ starting S${2}${3:+E${3}}}" ;;
resume-show)
[ $# -ge 1 ] || die "usage: black-tv resume-show <query>"
showdir=$(resolve_show "$1") || true
[ -n "$showdir" ] || die "show not found: $1"
rdata=$(read_resume_for_key "$(basename "$showdir")")
if [ -z "$rdata" ]; then
build_show_playlist "$showdir"
n=$(wc -l < "$PLAYLIST"); [ "${n:-0}" -gt 0 ] || die "no episodes found for: $1"
launch "$PLAYLIST"
echo "no saved position for $(basename "$showdir") — playing $n from start"
else
rpath=$(printf '%s' "$rdata" | cut -f1); rsec=$(printf '%s' "$rdata" | cut -f2)
build_resume_playlist "$showdir" "$rpath"
n=$(wc -l < "$PLAYLIST"); [ "${n:-0}" -gt 0 ] || die "could not rebuild playlist for: $1"
launch "$PLAYLIST" "$rsec"
echo "resuming $(basename "$showdir") at $(basename "$rpath") +${rsec%.*}s ($n eps queued)"
fi ;;
enqueue)
[ $# -ge 1 ] || die "usage: black-tv enqueue <file|dir|show-query>"
[ -S "$SOCK" ] || die "nothing playing — use play/play-show first"
if [ -e "$1" ]; then
if [ -d "$1" ]; then build_dir_playlist "$1" >/dev/null; else printf '%s\n' "$1" > "$PLAYLIST"; fi
else
sd=$(resolve_show "$1") || true; [ -n "$sd" ] || die "not found: $1"; build_show_playlist "$sd"
fi
n=$(wc -l < "$PLAYLIST"); [ "${n:-0}" -gt 0 ] || die "nothing to enqueue"
ipc "{\"command\":[\"loadlist\",\"$PLAYLIST\",\"append\"]}" >/dev/null
echo "enqueued $n item(s)" ;;
goto-ep)
[ $# -ge 1 ] || die "usage: black-tv goto-ep <index 0-based>"
setprop playlist-pos "$1"; echo "playlist-pos=$(getprop playlist-pos)" ;;
watched)
[ -f "$WATCHLOG" ] || { echo "[]"; exit 0; }
if [ $# -ge 1 ]; then grep -iF "$1" "$WATCHLOG" | tail -100; else tail -100 "$WATCHLOG"; fi ;;
pause) setprop pause true; echo paused ;;
resume) setprop pause false; echo resumed ;;
toggle)
@ -170,5 +239,5 @@ case "$cmd" in
stop) sudo systemctl stop "$UNIT" 2>/dev/null || true; sudo pkill -x mpv 2>/dev/null || true; rm -f "$SOCK"; echo stopped ;;
status) status_json ;;
ensure-display) ensure_display; echo "display ready: $(cat /sys/class/drm/card0-${CONNECTOR}/status 2>/dev/null)" ;;
*) die "usage: black-tv {play <path>|play-show <q> [S] [E]|pause|resume|toggle|vol N|seek S|next|prev|stop|status|ensure-display}" ;;
*) die "usage: black-tv {play <path>|play-show <q> [S] [E]|resume-show <q>|enqueue <x>|goto-ep N|pause|resume|toggle|vol N|seek S|next|prev|stop|status|watched [q]|ensure-display}" ;;
esac

View file

@ -33,6 +33,16 @@ export interface BlackStatus {
playlist_count?: number;
}
export interface WatchEvent {
ts: string;
event: string;
show: string;
season: number;
episode: number;
label: string;
path: string;
}
export function blackPlayShow(show: string, season?: number, episode?: number): string {
const args = [show];
if (season !== undefined) {
@ -42,6 +52,29 @@ export function blackPlayShow(show: string, season?: number, episode?: number):
return run("play-show", args);
}
export function blackResumeShow(show: string): string {
return run("resume-show", [show]);
}
export function blackEnqueue(target: string): string {
return run("enqueue", [target]);
}
export function blackPlayIndex(index: number): string {
return run("goto-ep", [String(index)]);
}
export function blackWatched(show?: string): WatchEvent[] {
const out = run("watched", show ? [show] : []);
if (out.trim() === "[]") return [];
const events: WatchEvent[] = [];
for (const line of out.split("\n")) {
if (line.trim().length === 0) continue;
try { events.push(JSON.parse(line) as WatchEvent); } catch { /* skip non-JSON */ }
}
return events;
}
export function blackPlayFile(path: string): string {
return run("play", [path]);
}

View file

@ -1,14 +1,18 @@
import {
blackEnqueue,
blackNext,
blackPlayFile,
blackPlayIndex,
blackPlayShow,
blackPrevious,
blackResume,
blackResumeShow,
blackSeekRelative,
blackSetVolume,
blackStatus,
blackStop,
blackTogglePause,
blackWatched,
} from "./client.ts";
// MCP tool definitions for the HDMI TV attached to black. Unlike the vlc_*
@ -35,6 +39,17 @@ export const BLACKTV_TOOLS = [
required: ["show"],
},
},
{
name: "black_resume_show",
description: "Resume a show on black's TV from where it was last stopped — the exact episode AND second (position is persisted on black per-show). Falls back to playing from the start if there's no saved position. This is the 'continue watching' verb; use black_play_show to deliberately start over.",
inputSchema: {
type: "object" as const,
properties: {
show: { type: "string" as const, description: "Show name or substring, e.g. 'psych'." },
},
required: ["show"],
},
},
{
name: "black_play_file",
description: "Play a single file or directory on black's TV by absolute path on black's filesystem (e.g. /bigdisk/_/media/...). A directory becomes an ordered playlist of the video files under it.",
@ -46,6 +61,38 @@ export const BLACKTV_TOOLS = [
required: ["path"],
},
},
{
name: "black_enqueue",
description: "Append to the current playlist on black's TV without interrupting playback. Accepts an absolute path (file or directory) on black, or a show name/substring (whole show is appended).",
inputSchema: {
type: "object" as const,
properties: {
target: { type: "string" as const, description: "Absolute path on black, or a show name/substring." },
},
required: ["target"],
},
},
{
name: "black_play_index",
description: "Jump to a specific entry in black's TV playlist by 0-based index (e.g. 0 = first episode). Live via mpv IPC.",
inputSchema: {
type: "object" as const,
properties: {
index: { type: "number" as const, description: "0-based playlist index." },
},
required: ["index"],
},
},
{
name: "black_watched",
description: "black's local watch history (every episode played on the TV), newest last. Optionally filter by a substring. Returns parsed watch events; empty array if nothing has been recorded yet.",
inputSchema: {
type: "object" as const,
properties: {
show: { type: "string" as const, description: "Optional case-insensitive substring filter." },
},
},
},
{
name: "black_play_pause",
description: "Toggle play/pause on black's TV (live, via mpv IPC — does not restart playback).",
@ -103,9 +150,24 @@ export function dispatchBlacktv(name: string, args: Record<string, unknown>): un
return blackPlayShow(show, season, episode);
}
case "black_resume_show":
return blackResumeShow(strArg(args, "show"));
case "black_play_file":
return blackPlayFile(strArg(args, "path"));
case "black_enqueue":
return blackEnqueue(strArg(args, "target"));
case "black_play_index": {
const index = numArg(args, "index");
if (index < 0 || !Number.isInteger(index)) throw new Error("index must be a non-negative integer");
return blackPlayIndex(index);
}
case "black_watched":
return blackWatched(args["show"] === undefined ? undefined : strArg(args, "show"));
case "black_play_pause":
return blackTogglePause();