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:
parent
4c8b5702f9
commit
e633952787
2 changed files with 112 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue