Both drivers' help text recommended `--after "$(stat -c %Y <journal>)"`. Measured
2026-11-04 that epoch is answered by whatever scan finished before the question was
asked: on Jellyfin it reads an earlier task's EndTimeUtc, and the Plex door cannot be
a waiter at all, since PMS stamps section_locations.scanned_at when it accepts the
request. The laws are on the vault pages (main 146c28d); this puts the warning at
point of use, in the text an agent reads before calling the door.
131 lines
5.9 KiB
Bash
Executable File
131 lines
5.9 KiB
Bash
Executable File
#!/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 — which makes it a census of the last RECORDED scan, never a waiter: PMS
|
|
# stamps that epoch when it accepts the request, so the door answers `settled` in the
|
|
# same second its own `trigger` ran (measured 2026-11-04; no epoch survives it).
|
|
# `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
|