feat(scripts): the two media scan doors become commands
Both media servers' scan procedure lived only as a hand-written curl block in the vault, and that block has bitten this fleet twice: the escaped-quote form that reads exactly like a dead API key, and a config `url` line meeting a command-line URL so curl quietly performs a second transfer and dumps the whole task table on your stdout. Jellyfin's door is now `scripts/media-refresh-jellyfin.py` (trigger / status / wait) and Plex's is `scripts/media-refresh-plex.sh` (trigger / status); `tests/media_refresh.bats` pins the contracts an operator observes, against a stub and with no live server. The Jellyfin `wait` encodes the settle law instead of a poll count: the Scan Media Library task must be seen Running — or finish while watching, or already pass the `--after` it is given — before any quiet read counts toward settled. A watcher that saw no Running otherwise reports the pre-scan library as the fresh one, which is how a rename looks applied while nothing has rescanned. Plex's `status` needs no token at all: `section_locations.scanned_at` carries the same epoch the API prints as `scannedAt` (measured on section 1 today). Neither driver lets a secret reach argv or a URL: the Jellyfin key rides a mode-600 curl config as the quoted MediaBrowser form, and the Plex token is read inside the container, where the HTTPD actually answers, into a config file that is shredded there.
This commit is contained in:
1 parent
e1bf1d6e80
commit
e7d7c6eea2
3 files changed
+710
No files matched your search
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trigger and poll ONE Plex library section — the step that makes a media move visible.
|
||||
#
|
||||
# media-refresh-plex.sh trigger --library KEY
|
||||
# media-refresh-plex.sh status --library KEY [--after EPOCH]
|
||||
#
|
||||
# One section only: refreshing all five costs a full walk of each and buys
|
||||
# nothing for a rename that touched one tree. And a rename on the array is
|
||||
# invisible until the section is rescanned — the server is bound at the SSD
|
||||
# metadata view, so an array-side move is not an event its watcher ever sees.
|
||||
#
|
||||
# Two host realities shape this script:
|
||||
# * PMS's HTTPD closes without a single byte on a request that arrives from
|
||||
# another netns (the host and the 10.89.x.x container address both read as
|
||||
# "empty reply"), and it serves TLS to remote clients. So the request rides
|
||||
# plain http://127.0.0.1:32400 INSIDE the container, shipped in as a file —
|
||||
# a three-layer ssh/podman/sh quoting chain only produces parse errors.
|
||||
# * The token is `PlexOnlineToken` in the container's Preferences.xml (not
|
||||
# PlexHTTPToken, which a claimed server may not carry). It is read inside
|
||||
# the container and used from a curl config there: never argv (any process
|
||||
# on the host reads /proc/<pid>/cmdline), never a URL (proxies log URLs).
|
||||
#
|
||||
# Progress needs no token and no container: `section_locations.scanned_at` in
|
||||
# the library database is the same epoch the API prints as `scannedAt`
|
||||
# (verified 2026-11-04), so `status` reads the database and compares it against
|
||||
# --after. `trigger` answering 000 means PMS is still warming — retry, never
|
||||
# restart the service over it. Proof that a rename actually landed is
|
||||
# scripts/media-verify.py replaying the journal; this script only moves the
|
||||
# scanner along.
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER=plex
|
||||
URL="http://127.0.0.1:32400"
|
||||
PREFS='/config/Library/Application Support/Plex Media Server/Preferences.xml'
|
||||
DB="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
|
||||
SECTION=""
|
||||
AFTER=""
|
||||
TRIES=6
|
||||
WARM_SLEEP=10
|
||||
|
||||
die() { echo "$*" >&2; exit 1; }
|
||||
|
||||
CMD="${1:-}"; shift || true
|
||||
case "$CMD" in
|
||||
trigger|status) ;;
|
||||
*) die "usage: $(basename "$0") trigger|status --library KEY [--after EPOCH] [--container NAME] [--db PATH]" ;;
|
||||
esac
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--library) SECTION="${2:-}"; shift 2 ;;
|
||||
--after) AFTER="${2:-}"; shift 2 ;;
|
||||
--tries) TRIES="${2:-}"; shift 2 ;;
|
||||
--warm-sleep) WARM_SLEEP="${2:-}"; shift 2 ;;
|
||||
--container) CONTAINER="${2:-}"; shift 2 ;;
|
||||
--url) URL="${2:-}"; shift 2 ;;
|
||||
--prefs) PREFS="${2:-}"; shift 2 ;;
|
||||
--db) DB="${2:-}"; shift 2 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$SECTION" ] || die "--library is required (the section id from GET /library/sections; Movies is 1)"
|
||||
command -v python3 >/dev/null || die "python3 is required to read the library database"
|
||||
|
||||
if [ "$CMD" = "status" ]; then
|
||||
[ -r "$DB" ] || die "no readable Plex library database at $DB"
|
||||
python3 - "$DB" "$SECTION" "$AFTER" <<'PY'
|
||||
import datetime, sqlite3, sys
|
||||
|
||||
db, section, after = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
rows = con.execute(
|
||||
"SELECT root_path, available, scanned_at FROM section_locations "
|
||||
"WHERE library_section_id = ? ORDER BY id", (section,)).fetchall()
|
||||
if not rows:
|
||||
sys.exit(f"section {section} has no location in {db}; is the id right?")
|
||||
worst = min(r[2] or 0 for r in rows)
|
||||
for root, available, at in rows:
|
||||
iso = (datetime.datetime.fromtimestamp(at, datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if at else "never")
|
||||
print(f" available={available} scanned_at={iso} {root}")
|
||||
print(f"section={section} scanned_at={worst}")
|
||||
if after:
|
||||
if worst < int(after):
|
||||
sys.exit(f"section {section} was last scanned before {after}; a refresh is still owed")
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v podman >/dev/null || die "podman is required to reach PMS inside its container"
|
||||
|
||||
INNER="$(mktemp -t pxscan.XXXXXX)"
|
||||
trap 'rm -f "$INNER"' EXIT
|
||||
cat > "$INNER" <<'EOS'
|
||||
# Runs INSIDE the plex container: $1 section key, $2 base URL, $3 Preferences.xml.
|
||||
key="$1"; url="$2"; prefs="$3"
|
||||
[ -r "$prefs" ] || { echo "no readable $prefs inside the container" >&2; exit 3; }
|
||||
T=$(sed -n 's/.*PlexOnlineToken="\([^"]*\)".*/\1/p' "$prefs" | head -1)
|
||||
[ -n "$T" ] || { echo "no PlexOnlineToken in $prefs" >&2; exit 3; }
|
||||
cfg=$(mktemp)
|
||||
( umask 077
|
||||
{ printf 'header = "X-Plex-Token: %s"\n' "$T"
|
||||
printf 'silent\nshow-error\nrequest = "POST"\n'
|
||||
printf 'url = "%s/library/sections/%s/refresh"\n' "$url" "$key"; } > "$cfg" )
|
||||
code=$(curl -K "$cfg" -o /dev/null -w '%{http_code}' || true)
|
||||
shred -u "$cfg" 2>/dev/null || rm -f "$cfg"
|
||||
[ "$code" = "401" ] && { echo "section $key: HTTP 401 — the token in $prefs is not accepted" >&2; exit 4; }
|
||||
echo "$code"
|
||||
EOS
|
||||
|
||||
attempt=1
|
||||
while :; do
|
||||
podman cp "$INNER" "$CONTAINER:/tmp/media-refresh-plex.sh" \
|
||||
|| die "could not copy the runner into container $CONTAINER"
|
||||
code="$(podman exec "$CONTAINER" sh /tmp/media-refresh-plex.sh "$SECTION" "$URL" "$PREFS" || true)"
|
||||
podman exec "$CONTAINER" rm -f /tmp/media-refresh-plex.sh >/dev/null 2>&1 || true
|
||||
case "$code" in
|
||||
204|200) echo "refresh accepted ($code) for section $SECTION"; exit 0 ;;
|
||||
000)
|
||||
[ "$attempt" -ge "$TRIES" ] && die "PMS never answered after $TRIES attempts (000); it may still be warming — do not restart the service over it"
|
||||
echo "section $SECTION: no answer yet ($attempt/$TRIES), PMS is warming" >&2 ;;
|
||||
*) die "refresh of section $SECTION failed: HTTP ${code:-curl-error}" ;;
|
||||
esac
|
||||
attempt=$((attempt + 1))
|
||||
sleep "$WARM_SLEEP"
|
||||
done
|
||||
Reference in new issue
Block a user