diff --git a/README.md b/README.md index a399e73..dd472c9 100644 --- a/README.md +++ b/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 diff --git a/src/blacktv/black-tv-watch.lua b/src/blacktv/black-tv-watch.lua new file mode 100644 index 0000000..10edf40 --- /dev/null +++ b/src/blacktv/black-tv-watch.lua @@ -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 // (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) diff --git a/src/blacktv/black-tv.sh b/src/blacktv/black-tv.sh index 472bbe7..ecb1489 100644 --- a/src/blacktv/black-tv.sh +++ b/src/blacktv/black-tv.sh @@ -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 +launch() { # launch [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 ; 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() { # -> writes $PLAYLIST rm -f "$PLAYLIST.all" } +read_resume_for_key() { # -> "\t" (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() { # -> 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 " + 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 " + [ -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 " + 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 |play-show [S] [E]|pause|resume|toggle|vol N|seek S|next|prev|stop|status|ensure-display}" ;; + *) die "usage: black-tv {play |play-show [S] [E]|resume-show |enqueue |goto-ep N|pause|resume|toggle|vol N|seek S|next|prev|stop|status|watched [q]|ensure-display}" ;; esac diff --git a/src/blacktv/client.ts b/src/blacktv/client.ts index a7e16e8..85e6242 100644 --- a/src/blacktv/client.ts +++ b/src/blacktv/client.ts @@ -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]); } diff --git a/src/blacktv/tools.ts b/src/blacktv/tools.ts index a54f9e3..f1882aa 100644 --- a/src/blacktv/tools.ts +++ b/src/blacktv/tools.ts @@ -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): 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();