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
|
#!/usr/bin/env bash
|
||||||
# black-tv — drive mpv on black's HDMI TV (NVIDIA GTX 660 Ti / nouveau / DRM).
|
# 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
|
# Vendored identically in tv-anarchy (mcp/src/blacktv) and plum-control-mcp
|
||||||
# Deployed to /usr/local/bin/black-tv on black; invoked over SSH by the
|
# (src/blacktv); deployed to /usr/local/bin/black-tv on black and invoked over
|
||||||
# plum-control MCP `blacktv` module (mirrors transmission-remote-over-ssh).
|
# 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`
|
# 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.
|
# `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
|
# black has no graphical session — mpv renders straight to KMS (--vo=drm) and
|
||||||
# the GPU driver is brought up on demand (see ensure_display).
|
# 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
|
# 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 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 systemctl reset-failed "$UNIT" psych-mpv 2>/dev/null || true
|
||||||
sudo pkill -x 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
|
sleep 1
|
||||||
}
|
}
|
||||||
launch() { # launch <playlist-file> [resume_seconds]
|
launch() { # launch <playlist-file> [resume_seconds]
|
||||||
|
|
@ -99,6 +101,40 @@ launch() { # launch <playlist-file> [resume_seconds]
|
||||||
--no-resume-playback "${hook[@]}" \
|
--no-resume-playback "${hook[@]}" \
|
||||||
--fs --really-quiet --playlist="$1"
|
--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 ------------------------------------------------------
|
# --- playlist building ------------------------------------------------------
|
||||||
build_dir_playlist() { # <dir> -> writes $PLAYLIST, echoes count
|
build_dir_playlist() { # <dir> -> writes $PLAYLIST, echoes count
|
||||||
|
|
@ -106,10 +142,26 @@ build_dir_playlist() { # <dir> -> writes $PLAYLIST, echoes count
|
||||||
| sort > "$PLAYLIST"
|
| sort > "$PLAYLIST"
|
||||||
wc -l < "$PLAYLIST"
|
wc -l < "$PLAYLIST"
|
||||||
}
|
}
|
||||||
resolve_show() { # <query> -> shortest-named matching show dir under tv/cartoons/anime
|
# Some shows are stored as one sibling dir per season ("Foo Season 3 Complete
|
||||||
find "$MEDIA_ROOT"/tv "$MEDIA_ROOT"/cartoons "$MEDIA_ROOT"/anime \
|
# 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 \
|
-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
|
# 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
|
# 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
|
local sxe start
|
||||||
sxe=$(printf 'S%02dE%02d' "$season" "${ep:-1}")
|
sxe=$(printf 'S%02dE%02d' "$season" "${ep:-1}")
|
||||||
start=$(grep -in "$sxe" "$PLAYLIST.all" | head -1 | cut -d: -f1)
|
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
|
else
|
||||||
cp "$PLAYLIST.all" "$PLAYLIST"
|
cp "$PLAYLIST.all" "$PLAYLIST"
|
||||||
fi
|
fi
|
||||||
|
|
@ -235,11 +296,30 @@ status_json() {
|
||||||
"$(getprop playlist-pos)" "$(getprop playlist-count)"
|
"$(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
|
# 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
|
# 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.
|
# 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() {
|
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
|
read -r l1 l5 l15 _ < /proc/loadavg
|
||||||
cores=$(nproc 2>/dev/null || echo 1)
|
cores=$(nproc 2>/dev/null || echo 1)
|
||||||
pid=$(pgrep -x mpv | head -1)
|
pid=$(pgrep -x mpv | head -1)
|
||||||
|
|
@ -254,8 +334,8 @@ stats_json() {
|
||||||
'BEGIN{printf "%.1f", 100.0*n*(b-a)/(d-c)}')
|
'BEGIN{printf "%.1f", 100.0*n*(b-a)/(d-c)}')
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
printf '{"load1":%s,"load5":%s,"load15":%s,"cores":%s,"mpv_cpu":%s}\n' \
|
printf '{"load1":%s,"load5":%s,"load15":%s,"cores":%s,"mpv_cpu":%s,"helper_sha":"%s","deps":%s}\n' \
|
||||||
"$l1" "$l5" "$l15" "$cores" "$mcpu"
|
"$l1" "$l5" "$l15" "$cores" "$mcpu" "$hsha" "$(deps_json)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- dispatch ---------------------------------------------------------------
|
# --- dispatch ---------------------------------------------------------------
|
||||||
|
|
@ -286,7 +366,7 @@ case "$cmd" in
|
||||||
launch "$PLAYLIST"; echo "playing $n item(s)" ;;
|
launch "$PLAYLIST"; echo "playing $n item(s)" ;;
|
||||||
play-show)
|
play-show)
|
||||||
[ $# -ge 1 ] || die "usage: black-tv play-show <query> [season] [episode]"
|
[ $# -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"
|
[ -n "$showdir" ] || die "show not found: $1"
|
||||||
build_show_playlist "$showdir" "${2:-}" "${3:-}"
|
build_show_playlist "$showdir" "${2:-}" "${3:-}"
|
||||||
n=$(wc -l < "$PLAYLIST")
|
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" ;;
|
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 ;;
|
next) ipc '{"command":["playlist-next"]}' >/dev/null; echo next ;;
|
||||||
prev) ipc '{"command":["playlist-prev"]}' >/dev/null; echo prev ;;
|
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 ;;
|
status) status_json ;;
|
||||||
stats) stats_json ;;
|
stats) stats_json ;;
|
||||||
ensure-display) ensure_display; echo "display ready: $(cat /sys/class/drm/card0-${CONNECTOR}/status 2>/dev/null)" ;;
|
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
|
esac
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export const BLACKTV_TOOLS = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "black_play_show",
|
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: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue