Add plex collection: health check and poster repair

Plex on odin had 101 of 283 TV shows showing grey placeholder posters. The
cause was not missing images: for every affected show the poster was still
downloaded in the item's metadata bundle, but no candidate was marked
`selected` in the database, so the thumb URL resolved to a 404. Working shows
had exactly one selected candidate; all 101 affected had zero.

Two details make this easy to misdiagnose, so both are captured in the docs:
only 6 items lacked a `thumb` field outright, while 95 advertised a
well-formed thumb URL that 404s on fetch — a metadata-only audit reports the
library as healthy. And a bulk metadata refresh, the obvious suspect, does not
correlate with the damage.

Adds three scripts against the Plex HTTP API (no SSH to odin needed):

  plex-health    read-only report — server, updates, libraries, unmatched
                 media, Butler tasks, active streams; exit 0/1/2
  poster-audit   read-only; fetches every thumb to find the 404s
  poster-repair  re-selects the locally-cached poster; --dry-run, idempotent,
                 skips items whose poster already works so manually-chosen
                 artwork is never replaced

All 283 TV Shows posters now resolve. The health check also surfaced one
unmatched movie, Battlestar Galactica the Mini Series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-08-15 21:15:06 -04:00
co-authored by Claude Opus 5
parent 28e0fdb631
commit 3df1cca91c
9 changed files with 942 additions and 1 deletions
+95
View File
@@ -0,0 +1,95 @@
# shellcheck shell=bash
# Sourced helper for talking to the Plex Media Server HTTP API.
# Not executable — source it: . "$COLLECTION_DIR/lib/plex-api.sh"
#
# Provides: plex_load_env, plex_require_deps, plex_get, plex_get_raw, plex_put,
# plex_check_auth, plex_log, plex_warn, plex_die
plex_log() { printf '%s\n' "$*"; }
plex_warn() { printf '%s\n' "$*" >&2; }
plex_die() { plex_warn "error: $*"; exit 1; }
# Load the collection's .env into the environment. Silent if absent — the
# caller's :? checks produce the actionable message.
plex_load_env() {
local env_file="${1:?plex_load_env needs a path}"
[[ -f "$env_file" ]] || return 0
set -a; . "$env_file"; set +a
}
plex_require_deps() {
local missing=()
local dep
for dep in curl jq; do
command -v "$dep" >/dev/null 2>&1 || missing+=("$dep")
done
(( ${#missing[@]} == 0 )) || plex_die "missing required tools: ${missing[*]}"
}
# Assemble the curl flags shared by every request. PLEX_INSECURE=1 adds -k;
# plex.bergerhouse.net verifies cleanly from `dev`, so it should stay 0.
_plex_curl_flags() {
local -n out=$1
out=(--silent --show-error --location-trusted --max-time "${PLEX_TIMEOUT:-30}")
[[ "${PLEX_INSECURE:-0}" == "1" ]] && out+=(--insecure)
return 0
}
# plex_get <path> [query...] -> JSON on stdout
# Path is relative to PLEX_URL and must start with '/'. Extra args are passed to
# curl, so use --get --data-urlencode 'k=v' for query parameters.
plex_get() {
local path="${1:?plex_get needs a path}"; shift
local flags; _plex_curl_flags flags
curl "${flags[@]}" \
-H 'Accept: application/json' \
-H "X-Plex-Token: ${PLEX_TOKEN}" \
"$@" \
"${PLEX_URL%/}${path}"
}
# Same as plex_get but returns the server's default XML — a few endpoints
# (notably /library/sections/<id>/all with includeGuids) are richer in XML.
plex_get_raw() {
local path="${1:?plex_get_raw needs a path}"; shift
local flags; _plex_curl_flags flags
curl "${flags[@]}" \
-H "X-Plex-Token: ${PLEX_TOKEN}" \
"$@" \
"${PLEX_URL%/}${path}"
}
# plex_put <path> [curl args...] — mutating request. Callers are responsible for
# honouring --dry-run before calling this.
plex_put() {
local path="${1:?plex_put needs a path}"; shift
local flags; _plex_curl_flags flags
curl "${flags[@]}" -X PUT \
-H 'Accept: application/json' \
-H "X-Plex-Token: ${PLEX_TOKEN}" \
"$@" \
"${PLEX_URL%/}${path}"
}
# Fail fast with a useful message rather than letting every later call return
# an empty body. Plex answers 401 with an HTML body for a bad/absent token.
plex_check_auth() {
: "${PLEX_URL:?PLEX_URL is not set — copy .env.example to .env}"
: "${PLEX_TOKEN:?PLEX_TOKEN is not set — copy .env.example to .env}"
[[ "$PLEX_TOKEN" != "replace-me" ]] || plex_die "PLEX_TOKEN is still the placeholder value"
local flags; _plex_curl_flags flags
local code
code="$(curl "${flags[@]}" -o /dev/null -w '%{http_code}' \
-H "X-Plex-Token: ${PLEX_TOKEN}" "${PLEX_URL%/}/identity")" \
|| plex_die "cannot reach ${PLEX_URL} (Tailscale down?)"
[[ "$code" == "200" ]] || plex_die "${PLEX_URL}/identity returned HTTP ${code}"
code="$(curl "${flags[@]}" -o /dev/null -w '%{http_code}' \
-H "X-Plex-Token: ${PLEX_TOKEN}" "${PLEX_URL%/}/library/sections")"
case "$code" in
200) return 0 ;;
401) plex_die "PLEX_TOKEN rejected (HTTP 401) — the token is wrong or expired" ;;
*) plex_die "/library/sections returned HTTP ${code}" ;;
esac
}