#!/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//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