feat(@applications/plum-control-mcp): update black-tv script to handle restart via IPC

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 22:16:11 -07:00
parent 4c8b5702f9
commit e633952787
2 changed files with 112 additions and 17 deletions

View file

@ -1,12 +1,14 @@
#!/usr/bin/env bash
# black-tv — drive mpv on black's HDMI TV (NVIDIA GTX 660 Ti / nouveau / DRM).
#
# Source of truth: plum-control-mcp/src/blacktv/black-tv.sh
# Deployed to /usr/local/bin/black-tv on black; invoked over SSH by the
# plum-control MCP `blacktv` module (mirrors transmission-remote-over-ssh).
# Vendored identically in tv-anarchy (mcp/src/blacktv) and plum-control-mcp
# (src/blacktv); deployed to /usr/local/bin/black-tv on black and invoked over
# SSH by the plum-control MCP `blacktv` module. Keep all three copies in sync —
# the tv-anarchy app's Devices tab flags a deploy as stale by comparing shas.
#
# One long-lived mpv instance plays to the TV; every verb except `play`/`stop`
# goes through its JSON IPC socket, so volume/seek/pause never restart playback.
# One long-lived mpv instance plays to the TV; every verb except `play`/`stop`/
# `restart` goes through its JSON IPC socket, so volume/seek/pause never restart
# playback.
# black has no graphical session — mpv renders straight to KMS (--vo=drm) and
# the GPU driver is brought up on demand (see ensure_display).
# No `pipefail`: several pipes end in `grep -q`/`head -1`, which exit early and
@ -79,7 +81,7 @@ kill_existing() {
sudo systemctl stop "$UNIT" psych-mpv 2>/dev/null || true # psych-mpv = legacy ad-hoc unit
sudo systemctl reset-failed "$UNIT" psych-mpv 2>/dev/null || true
sudo pkill -x mpv 2>/dev/null || true
rm -f "$SOCK" 2>/dev/null || true
sudo rm -f "$SOCK" 2>/dev/null || true # root-owned socket in sticky /tmp — plain rm can't
sleep 1
}
launch() { # launch <playlist-file> [resume_seconds]
@ -99,6 +101,40 @@ launch() { # launch <playlist-file> [resume_seconds]
--no-resume-playback "${hook[@]}" \
--fs --really-quiet --playlist="$1"
}
# Live state for `restart`: first line = position seconds, then the LIVE playlist
# from the current entry onward — read over IPC, not from $PLAYLIST, because an
# IPC-built queue (the app's enqueue) never touches that file. Empty output when
# idle or unreadable. timeout-guarded: a hung mpv is the main reason to restart,
# so the capture itself must never hang.
capture_state() {
[ -S "$SOCK" ] || return 0
printf '%s\n%s\n' \
'{"command":["get_property","playlist"],"request_id":1}' \
'{"command":["get_property","time-pos"],"request_id":2}' \
| sudo timeout 5 socat - "$SOCK" 2>/dev/null \
| python3 -c '
import json, sys
pl, secs = None, None
for line in sys.stdin:
try:
o = json.loads(line)
except ValueError:
continue
if o.get("error") != "success":
continue
if o.get("request_id") == 1: pl = o.get("data")
if o.get("request_id") == 2: secs = o.get("data")
if not pl:
sys.exit(0)
cur = next((i for i, e in enumerate(pl) if e.get("current")), None)
if cur is None:
sys.exit(0)
print(int(secs or 0))
for e in pl[cur:]:
if e.get("filename"):
print(e["filename"])
' 2>/dev/null || true
}
# --- playlist building ------------------------------------------------------
build_dir_playlist() { # <dir> -> writes $PLAYLIST, echoes count
@ -106,10 +142,26 @@ build_dir_playlist() { # <dir> -> writes $PLAYLIST, echoes count
| sort > "$PLAYLIST"
wc -l < "$PLAYLIST"
}
resolve_show() { # <query> -> shortest-named matching show dir under tv/cartoons/anime
find "$MEDIA_ROOT"/tv "$MEDIA_ROOT"/cartoons "$MEDIA_ROOT"/anime \
# Some shows are stored as one sibling dir per season ("Foo Season 3 Complete
# 720p ..."), which a single substring match can't tell apart. names_season
# tests whether a dir name designates a season: "Season 3", "Season 03", "S03".
names_season() { # <name> <season> -> exit 0 when the name designates that season
printf '%s' "$1" | grep -qiE "(season[ ._-]*|s)0*$2([^0-9]|\$)"
}
resolve_show() { # <query> [season] -> best matching show dir under tv/cartoons/anime
local matches d
matches=$(find "$MEDIA_ROOT"/tv "$MEDIA_ROOT"/cartoons "$MEDIA_ROOT"/anime \
-mindepth 1 -maxdepth 1 -type d -iname "*$1*" 2>/dev/null \
| awk '{ print length, $0 }' | sort -n | cut -d' ' -f2- | head -1
| awk '{ print length, $0 }' | sort -n | cut -d' ' -f2-)
[ -n "$matches" ] || return 0
# With per-season sibling dirs, a dir naming the requested season beats the
# generic shortest-name pick (which lands on whichever season sorts first).
if [ -n "${2:-}" ]; then
while IFS= read -r d; do
if names_season "$(basename "$d")" "$2"; then printf '%s\n' "$d"; return 0; fi
done <<<"$matches"
fi
head -1 <<<"$matches"
}
# Some show dirs hold several self-contained releases side by side (e.g. a full
# 1080p series, a 720p series, and standalone movies). Pick the versioned
@ -137,7 +189,16 @@ build_show_playlist() { # <showdir> <season?> <episode?> -> writes $PLAYLIST
local sxe start
sxe=$(printf 'S%02dE%02d' "$season" "${ep:-1}")
start=$(grep -in "$sxe" "$PLAYLIST.all" | head -1 | cut -d: -f1)
if [ -n "$start" ]; then tail -n +"$start" "$PLAYLIST.all" > "$PLAYLIST"; else cp "$PLAYLIST.all" "$PLAYLIST"; fi
if [ -n "$start" ]; then
tail -n +"$start" "$PLAYLIST.all" > "$PLAYLIST"
elif names_season "$(basename "$showdir")" "$season"; then
# Per-season dir with nonstandard episode names — the whole dir IS the
# requested season, so start from its beginning.
cp "$PLAYLIST.all" "$PLAYLIST"
else
rm -f "$PLAYLIST.all"
die "no $sxe under $(basename "$showdir") — refusing to start the wrong season"
fi
else
cp "$PLAYLIST.all" "$PLAYLIST"
fi
@ -235,11 +296,30 @@ status_json() {
"$(getprop playlist-pos)" "$(getprop playlist-count)"
}
# Dependent-service report for the app's Devices tab: facts only — the app
# decides what's "interesting" (transmission down, disk filling up, TV
# unplugged, stale socket). All cheap probes, no sudo.
deps_json() {
local tr unit sock mroot disp dfree dpct
tr=$(systemctl is-active transmission-daemon 2>/dev/null || true)
unit=$(systemctl is-active "$UNIT" 2>/dev/null || true)
[ -S "$SOCK" ] && sock=true || sock=false
[ -d "$MEDIA_ROOT" ] && mroot=true || mroot=false
disp=$(cat /sys/class/drm/card0-${CONNECTOR}/status 2>/dev/null || echo unknown)
set -- $(df -BG --output=avail,pcent "$MEDIA_ROOT" 2>/dev/null | tail -1 | tr -d 'G%')
dfree=${1:-null}; dpct=${2:-null}
printf '{"transmission":"%s","mpv_unit":"%s","mpv_socket":%s,"media_root":%s,"display":"%s","disk_free_gb":%s,"disk_used_pct":%s}' \
"${tr:-unknown}" "${unit:-unknown}" "$sock" "$mroot" "$disp" "$dfree" "$dpct"
}
# Host load: load averages + mpv's instantaneous %CPU (100 = one core). The
# decode cost is what changes with quality; computed from a 0.25s /proc delta so
# it's "right now", not the lifetime average ps reports. No sudo needed.
# helper_sha = sha256 of this very script, so the app's Devices tab can compare
# the deployed copy against the repo's vendored source and flag stale deploys.
stats_json() {
local l1 l5 l15 cores pid mcpu="null" t1 t2 p1 p2
local l1 l5 l15 cores pid mcpu="null" t1 t2 p1 p2 hsha
hsha=$(sha256sum "$0" 2>/dev/null | awk '{print $1}')
read -r l1 l5 l15 _ < /proc/loadavg
cores=$(nproc 2>/dev/null || echo 1)
pid=$(pgrep -x mpv | head -1)
@ -254,8 +334,8 @@ stats_json() {
'BEGIN{printf "%.1f", 100.0*n*(b-a)/(d-c)}')
fi
fi
printf '{"load1":%s,"load5":%s,"load15":%s,"cores":%s,"mpv_cpu":%s}\n' \
"$l1" "$l5" "$l15" "$cores" "$mcpu"
printf '{"load1":%s,"load5":%s,"load15":%s,"cores":%s,"mpv_cpu":%s,"helper_sha":"%s","deps":%s}\n' \
"$l1" "$l5" "$l15" "$cores" "$mcpu" "$hsha" "$(deps_json)"
}
# --- dispatch ---------------------------------------------------------------
@ -286,7 +366,7 @@ case "$cmd" in
launch "$PLAYLIST"; echo "playing $n item(s)" ;;
play-show)
[ $# -ge 1 ] || die "usage: black-tv play-show <query> [season] [episode]"
showdir=$(resolve_show "$1") || true
showdir=$(resolve_show "$1" "${2:-}") || true
[ -n "$showdir" ] || die "show not found: $1"
build_show_playlist "$showdir" "${2:-}" "${3:-}"
n=$(wc -l < "$PLAYLIST")
@ -361,9 +441,24 @@ case "$cmd" in
seek) [ $# -ge 1 ] || die "usage: black-tv seek <seconds>"; ipc "{\"command\":[\"seek\",$1]}" >/dev/null; echo "seek ${1}s" ;;
next) ipc '{"command":["playlist-next"]}' >/dev/null; echo next ;;
prev) ipc '{"command":["playlist-prev"]}' >/dev/null; echo prev ;;
stop) sudo systemctl stop "$UNIT" 2>/dev/null || true; sudo pkill -x mpv 2>/dev/null || true; rm -f "$SOCK"; echo stopped ;;
stop) sudo systemctl stop "$UNIT" 2>/dev/null || true; sudo pkill -x mpv 2>/dev/null || true; sudo rm -f "$SOCK" 2>/dev/null || true; echo stopped ;;
restart)
# Hard-restart the player service: tear down the unit and, if something was
# playing, relaunch the remaining playlist resuming at the captured position.
# Idle / hung-unreadable mpv → clean teardown only (a fresh slate to play into).
state=$(capture_state)
if [ -n "$state" ]; then
secs=$(head -1 <<<"$state")
tail -n +2 <<<"$state" > "$PLAYLIST"
launch "$PLAYLIST" "$secs"
echo "restarted: resumed at ${secs}s"
else
kill_existing
echo "restarted: nothing playing — unit/socket cleaned up"
fi
;;
status) status_json ;;
stats) stats_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]|resume-show <q>|enqueue <x>|goto-ep N|releases|resolve-release <rel>|switch <rel>|pause|resume|toggle|vol N|seek S|next|prev|stop|status|stats|watched [q]|ensure-display}" ;;
*) die "usage: black-tv {play <path>|play-show <q> [S] [E]|resume-show <q>|enqueue <x>|goto-ep N|releases|resolve-release <rel>|switch <rel>|pause|resume|toggle|vol N|seek S|next|prev|stop|restart|status|stats|watched [q]|ensure-display}" ;;
esac

View file

@ -28,7 +28,7 @@ export const BLACKTV_TOOLS = [
},
{
name: "black_play_show",
description: "Play a show on black's TV. Resolves a show directory under black's local library (tv/cartoons/anime) by case-insensitive substring, builds an ordered playlist (preferring a 1080p release when several exist), and plays it through to the end. Brings up the display driver automatically. NOTE: the TV must be powered on physically — there is no HDMI-CEC.",
description: "Play a show on black's TV. Resolves a show directory under black's local library (tv/cartoons/anime) by case-insensitive substring, builds an ordered playlist (preferring a 1080p release when several exist), and plays it through to the end. When a show is stored as one directory per season, a requested season selects the directory naming that season (playback then covers that season); if the requested season can't be located, the call errors instead of starting the wrong season. Brings up the display driver automatically. NOTE: the TV must be powered on physically — there is no HDMI-CEC.",
inputSchema: {
type: "object" as const,
properties: {