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
}
+127
View File
@@ -0,0 +1,127 @@
# shellcheck shell=bash
# Poster integrity helpers for the Plex library.
# Sourced by bin/poster-audit and bin/poster-repair. Requires lib/plex-api.sh
# to be sourced first.
#
# The distinction that matters here: a poster can be missing in two ways, and
# they look identical in the Plex UI.
#
# MISSING the item has no `thumb` field at all
# BROKEN the item has a `thumb` URL, but fetching it returns 404
#
# BROKEN is the common one: the poster image is still present in the item's
# metadata bundle, but the database no longer marks any candidate as selected,
# so the thumb URL resolves to nothing.
# Resolve a section by numeric id or by exact title. Echoes the id.
posters_resolve_section() {
local want="${1:?posters_resolve_section needs an id or title}"
local sections
sections="$(plex_get /library/sections)" || plex_die "could not list library sections"
if [[ "$want" =~ ^[0-9]+$ ]]; then
jq -e --arg k "$want" '.MediaContainer.Directory[]|select(.key==$k)|.key' <<<"$sections" -r \
|| plex_die "no library section with id ${want}"
return
fi
local matches
matches="$(jq -r --arg t "$want" '[.MediaContainer.Directory[]|select(.title==$t)|.key]|join(" ")' <<<"$sections")"
case "$(wc -w <<<"$matches")" in
0) plex_die "no library section titled '${want}' — have: $(jq -r '[.MediaContainer.Directory[].title]|join(", ")' <<<"$sections")" ;;
1) printf '%s\n' "$matches" ;;
*) plex_die "library title '${want}' is ambiguous (ids: ${matches}) — pass the id instead" ;;
esac
}
# Fetch every item of <type> in <section> into a JSON file.
# Plex type codes: 1=movie 2=show 3=season 4=episode.
posters_fetch_items() {
local section="${1:?}" type="${2:?}" out="${3:?}"
plex_get "/library/sections/${section}/all" --get --data-urlencode "type=${type}" -o "$out" \
|| plex_die "could not list items for section ${section} type ${type}"
jq -e '.MediaContainer|has("Metadata") or .size==0' "$out" >/dev/null \
|| plex_die "unexpected response listing section ${section} (not a library container?)"
}
# Emit "ratingKey<TAB>STATUS<TAB>title" for every item, checking each thumb URL
# with a real HTTP request. Runs POSTER_JOBS requests in parallel.
#
# The 404 check is the whole point: Plex happily reports a thumb field for
# items whose poster does not resolve, so a metadata-only audit reports a
# clean library while the UI shows grey placeholders.
posters_scan() {
local items_json="${1:?}"
local jobs="${POSTER_JOBS:-8}"
# Items with no thumb field at all need no HTTP request.
jq -r '.MediaContainer.Metadata[]?|select(has("thumb")|not)|"\(.ratingKey)\tMISSING\t\(.title)"' "$items_json"
jq -r '.MediaContainer.Metadata[]?|select(has("thumb"))|"\(.ratingKey)\t\(.thumb)\t\(.title)"' "$items_json" \
| PLEX_URL="$PLEX_URL" PLEX_TOKEN="$PLEX_TOKEN" PLEX_INSECURE="${PLEX_INSECURE:-0}" \
xargs -P "$jobs" -d '\n' -I{} bash -c '
IFS=$'"'"'\t'"'"' read -r rk thumb title <<<"{}"
flags=(--silent --show-error --max-time 30)
[[ "${PLEX_INSECURE:-0}" == "1" ]] && flags+=(--insecure)
code=$(curl "${flags[@]}" -o /dev/null -w "%{http_code}" \
-H "X-Plex-Token: ${PLEX_TOKEN}" "${PLEX_URL%/}${thumb}")
if [[ "$code" == "200" ]]; then
printf "%s\tOK\t%s\n" "$rk" "$title"
else
printf "%s\tBROKEN\t%s\n" "$rk" "$title"
fi'
}
# Echo the best poster candidate URL for an item, or nothing if there is none.
#
# Prefers a metadata:// candidate — that image is already downloaded into the
# item's bundle, so selecting it is instant and needs no internet round trip.
# Falls back to a remote provider URL only when POSTER_ALLOW_REMOTE=1, since
# that re-downloads from the agent and can fail or pick a different image.
posters_candidate() {
local rk="${1:?posters_candidate needs a ratingKey}"
local list
list="$(plex_get "/library/metadata/${rk}/posters")" || return 1
local local_candidate
local_candidate="$(jq -r '[.MediaContainer.Metadata[]?|select(.ratingKey|startswith("metadata://"))][0].ratingKey // empty' <<<"$list")"
if [[ -n "$local_candidate" ]]; then
printf '%s\n' "$local_candidate"
return 0
fi
if [[ "${POSTER_ALLOW_REMOTE:-0}" == "1" ]]; then
jq -r '[.MediaContainer.Metadata[]?|select(.ratingKey|startswith("http"))][0].ratingKey // empty' <<<"$list"
return 0
fi
return 0
}
# How many candidates are currently marked selected. 0 is the broken state.
posters_selected_count() {
local rk="${1:?}"
plex_get "/library/metadata/${rk}/posters" \
| jq -r '[.MediaContainer.Metadata[]?|select(.selected==true)]|length'
}
# Select <url> as the poster for <ratingKey>. Returns non-zero on HTTP failure.
posters_select() {
local rk="${1:?}" url="${2:?}"
local code
code="$(plex_put "/library/metadata/${rk}/poster" -o /dev/null -w '%{http_code}' \
--get --data-urlencode "url=${url}")"
[[ "$code" == "200" ]] || { plex_warn " PUT poster for ${rk} returned HTTP ${code}"; return 1; }
}
# Re-read the item and confirm its thumb now actually resolves.
posters_verify() {
local rk="${1:?}"
local thumb
thumb="$(plex_get "/library/metadata/${rk}" | jq -r '.MediaContainer.Metadata[0].thumb // empty')"
[[ -n "$thumb" ]] || return 1
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%/}${thumb}")"
[[ "$code" == "200" ]]
}