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:
parent
845f67d34a
commit
6680cc6e7f
5 changed files with 284 additions and 4 deletions
10
README.md
10
README.md
|
|
@ -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
|
||||
|
|
|
|||
108
src/blacktv/black-tv-watch.lua
Normal file
108
src/blacktv/black-tv-watch.lua
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue