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
+33
View File
@@ -0,0 +1,33 @@
# Copy to .env and fill in. Never commit the filled-in .env.
# The repo .gitignore ignores `.env` and `**/.env` everywhere — verify with:
# git check-ignore -v plex/.env
# ── Plex HTTP API (required) ─────────────────────────────────────────────────
# Base URL of the Plex Media Server. Resolves to odin (192.168.90.103) and routes
# over the tailnet subnet route. Use https:// — plain http 302-redirects.
# TLS verifies cleanly from `dev` here, unlike odin's myunraid.net vhost.
PLEX_URL=https://plex.bergerhouse.net
# X-Plex-Token for an admin account on this server.
# Plex Web -> any library item -> ... -> Get Info -> View XML, then copy the
# X-Plex-Token query parameter out of the URL that opens.
PLEX_TOKEN=replace-me
# Set to 1 to skip TLS verification (not needed for PLEX_URL above; here in case
# the collection is pointed at odin's myunraid.net vhost instead).
PLEX_INSECURE=0
# ── odin filesystem access (optional — only for the deeper "under the hood" checks) ──
# Needed to read Plex's logs, Preferences.xml, and the metadata/media appdata
# dirs directly. Leave blank until an SSH key for this box is authorized on odin.
ODIN_SSH_HOST=100.101.253.105
ODIN_SSH_USER=root
ODIN_SSH_KEY=
# Path to the Plex appdata dir on odin, as seen from the Unraid host (not the
# container). Confirm before use — not yet verified.
ODIN_PLEX_APPDATA=
# ── Unraid GraphQL API (optional — host-level health only) ───────────────────
# See ../docs/odin-access.md. Base URL WITHOUT the /graphql suffix.
UNRAID_HOST=https://192-168-90-103.87c90a69c53e7197560c778a63f483e675130f2a.myunraid.net:8443
UNRAID_API_KEY=
+104
View File
@@ -0,0 +1,104 @@
# plex
Operational scripts for the Plex Media Server running as a container on **odin**.
Reached at `https://plex.bergerhouse.net` (DNS resolves to odin's LAN address
`192.168.90.103`, routed over the tailnet — see [../docs/odin-access.md](../docs/odin-access.md)).
| Script | What it does | Mutates? |
| --- | --- | --- |
| [bin/plex-health](bin/plex-health) | Read-only health report: server, updates, libraries, unmatched media, Butler tasks, active streams | no |
| [bin/poster-audit](bin/poster-audit) | Finds items whose poster is missing or does not resolve | no |
| [bin/poster-repair](bin/poster-repair) | Re-selects the poster for items the audit flags | **yes** |
## Prerequisites
- **Where it runs:** this workstation (`dev`), targeting odin over Tailscale. Nothing
needs to be installed on odin itself — everything goes through the Plex HTTP API.
- **Interpreters:** bash 4+
- **External tools:** `curl`, `jq`
- **Access needed:** an admin `X-Plex-Token`. No SSH to odin is required.
Unlike odin's `myunraid.net` vhost, `plex.bergerhouse.net` presents a TLS chain that
verifies cleanly from `dev``PLEX_INSECURE` stays `0` and no `-k` is needed.
## Configuration
Copy `.env.example` to `.env` and fill it in. `.env` is gitignored repo-wide.
| Variable | Required | Description |
| --- | --- | --- |
| `PLEX_URL` | yes | Base URL of the server. Use `https://` — plain HTTP 302-redirects. |
| `PLEX_TOKEN` | yes | Admin `X-Plex-Token`. |
| `PLEX_INSECURE` | no | `1` to skip TLS verification. Not needed for `PLEX_URL` above. |
| `PLEX_TIMEOUT` | no | Per-request timeout in seconds (default 30). |
| `POSTER_JOBS` | no | Parallel HTTP checks during a poster scan (default 8). |
To get a token: Plex Web → any library item → **⋯ → Get Info → View XML**, then copy the
`X-Plex-Token` query parameter out of the URL that opens.
The remaining `ODIN_*` and `UNRAID_*` variables in `.env.example` are placeholders for
future scripts that need filesystem or host-level access. Nothing here uses them yet.
## Usage
```bash
bin/plex-health # health report (exit 0 ok / 1 warn / 2 fail)
bin/plex-health --posters # also check every poster (slow: 1 request per item)
bin/poster-audit # what is broken in TV Shows
bin/poster-audit --section Movies # any library, by title or id
bin/poster-audit --json # machine-readable
bin/poster-repair --dry-run # preview, changes nothing
bin/poster-repair # apply
bin/poster-repair --limit 5 # apply to the first 5 only
```
A full TV Shows scan makes ~283 HTTP requests and takes a couple of minutes. The scan
dominates the runtime, not the repair.
## Behaviour notes
- **Idempotent:** yes. `poster-repair` only touches items whose poster does not currently
resolve, so a second run repairs nothing. `plex-health` never writes.
- **Destructive operations:** none. `poster-repair` selects a poster that is *already in
the item's metadata bundle* — no image is downloaded, deleted, or overwritten, and no
other metadata field is touched. Items with a working poster are skipped, so a
manually-chosen poster is never replaced.
- **`--allow-remote` is the one exception:** for an item with no locally-cached poster it
pulls one from the metadata agent over the internet, which may select a different image
than the one originally chosen. Off by default.
- **Scheduling:** manual for now. `plex-health` is cron-safe and exit-code driven if you
want it on a timer.
## Gotchas
**A missing poster has two distinct causes, and the Plex UI shows them identically.**
Checking metadata alone will tell you the library is fine when it is not:
- `MISSING` — the item has no `thumb` field at all.
- `BROKEN` — the item *has* a `thumb` field, but fetching that URL returns **404**. This
was 94 of the 100 affected shows. Any audit that only looks for absent fields misses
almost all of them.
**The failure is a lost database pointer, not a lost file.** For a broken item,
`/library/metadata/<key>/posters` still lists all the candidates — often 85+ of them —
with the agent's own poster already downloaded into the bundle as a `metadata://` entry.
What is gone is the `selected: true` flag: every candidate reads `selected: false`, so
the `thumb` URL resolves to nothing. A working item has exactly one candidate selected.
Re-selecting the local candidate is therefore instant and needs no internet access.
**Diagnose with the `selected` count, not the poster list length.** An affected show has
plenty of poster candidates; the count of *selected* ones is the signal.
**`PUT /library/metadata/<key>/poster?url=<candidate>` needs the candidate's `ratingKey`,**
which for a local candidate is the `metadata://posters/...` string, not the `/library/...`
path from the same object's `key` field.
**Season and episode posters were not affected** — only show-level ones, and only in the
TV Shows library. The movie libraries sampled clean. Worth re-checking rather than
assuming if this recurs.
**The 2-minute tool timeout will kill a full repair run.** Run it with `nohup ... &` and
tail the log, or use `--limit`.
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# Read-only health report for the Plex Media Server.
#
# Exit status: 0 = all checks passed, 1 = at least one WARN, 2 = at least one FAIL.
# Safe to run from cron; makes no changes.
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly COLLECTION_DIR="$(dirname "$SCRIPT_DIR")"
# shellcheck source=../lib/plex-api.sh
. "$COLLECTION_DIR/lib/plex-api.sh"
# shellcheck source=../lib/posters.sh
. "$COLLECTION_DIR/lib/posters.sh"
WARNS=0
FAILS=0
CHECK_POSTERS=0
# Global, not local: an EXIT trap fires after the owning function has returned,
# by which point a local would be out of scope and `set -u` would abort cleanup.
WORKDIR=""
cleanup() { [[ -n "$WORKDIR" ]] && rm -rf "$WORKDIR"; return 0; }
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage: plex-health [--posters] [--help]
--posters Also run a full poster-integrity check on every show/movie
library. Costs one HTTP request per item, so it is off by
default; without it the report notes that it was skipped.
--help
Exit status: 0 ok, 1 warnings, 2 failures.
EOF
}
ok() { printf ' [ ok ] %s\n' "$*"; }
warn() { printf ' [warn] %s\n' "$*"; WARNS=$((WARNS + 1)); }
fail() { printf ' [FAIL] %s\n' "$*"; FAILS=$((FAILS + 1)); }
info() { printf ' [info] %s\n' "$*"; }
section() { printf '\n%s\n' "$*"; }
# ── checks ───────────────────────────────────────────────────────────────────
check_server() {
section "Server"
local root
root="$(plex_get /)" || { fail "cannot read server root"; return; }
local version platform pv user signin sub
version="$(jq -r '.MediaContainer.version // "?"' <<<"$root")"
platform="$(jq -r '.MediaContainer.platform // "?"' <<<"$root")"
pv="$(jq -r '.MediaContainer.platformVersion // "?"' <<<"$root")"
user="$(jq -r '.MediaContainer.myPlexUsername // "?"' <<<"$root")"
signin="$(jq -r '.MediaContainer.myPlexSigninState // "?"' <<<"$root")"
sub="$(jq -r '.MediaContainer.myPlexSubscription // false' <<<"$root")"
ok "Plex ${version} on ${platform} ${pv}"
[[ "$signin" == "ok" ]] && ok "myPlex sign-in: ${user}" || fail "myPlex sign-in state: ${signin}"
[[ "$sub" == "true" ]] && ok "Plex Pass subscription active" || info "no active Plex Pass subscription"
}
check_updates() {
section "Updates"
local st
st="$(plex_get /updater/status)" || { warn "updater status unavailable"; return; }
local size can
size="$(jq -r '.MediaContainer.size // 0' <<<"$st")"
can="$(jq -r '.MediaContainer.canInstall // false' <<<"$st")"
if [[ "$size" == "0" ]]; then
ok "no server update pending"
else
info "update available (canInstall=${can}) — this server updates via its Docker image, not in-app"
fi
}
check_remote_access() {
section "Remote access"
local acct
acct="$(plex_get /myplex/account)" || { warn "cannot read myPlex account"; return; }
local mstate merr
mstate="$(jq -r '.MyPlex.mappingState // "?"' <<<"$acct")"
merr="$(jq -r '.MyPlex.mappingError // ""' <<<"$acct")"
if [[ -z "$merr" ]]; then
ok "port mapping: ${mstate}"
else
# odin sits behind a Pangolin/Newt tunnel with no open inbound ports, so an
# "unreachable" mapping is the expected steady state, not a fault.
info "port mapping ${mstate}/${merr} — expected: odin has no open inbound ports (tunnel ingress)"
fi
}
check_libraries() {
section "Libraries"
local sections
sections="$(plex_get /library/sections)" || { fail "cannot list library sections"; return; }
local key title type refreshing code count
while IFS=$'\t' read -r key title type refreshing; do
# Top-level item count: movies for a movie library, shows for a show
# library. Music sections are counted by artist.
case "$type" in
movie) code=1 ;;
show) code=2 ;;
artist) code=8 ;;
*) code=1 ;;
esac
count="$(plex_get "/library/sections/${key}/all" --get --data-urlencode "type=${code}" \
| jq -r '.MediaContainer.totalSize // .MediaContainer.size // "?"' 2>/dev/null || echo '?')"
if [[ "$refreshing" == "true" ]]; then
info "${title} (id ${key}, ${type}): ${count} items — currently refreshing"
else
ok "${title} (id ${key}, ${type}): ${count} items"
fi
done < <(jq -r '.MediaContainer.Directory[]|"\(.key)\t\(.title)\t\(.type)\t\(.refreshing)"' <<<"$sections")
}
check_unmatched() {
section "Unmatched media"
local sections
sections="$(plex_get /library/sections)"
local key title type code items unmatched
while IFS=$'\t' read -r key title type; do
case "$type" in
movie) code=1 ;;
show) code=2 ;;
*) continue ;;
esac
items="$(plex_get "/library/sections/${key}/all" --get --data-urlencode "type=${code}")" || continue
# An unmatched item keeps a local:// guid instead of a real agent guid.
unmatched="$(jq -r '[.MediaContainer.Metadata[]?
|select((.guid//"")|test("^(local://|com\\.plexapp\\.agents\\.none)"))]|length' <<<"$items")"
if [[ "$unmatched" == "0" ]]; then
ok "${title}: all items matched to an agent"
else
warn "${title}: ${unmatched} unmatched item(s) — they will never get metadata or artwork"
fi
done < <(jq -r '.MediaContainer.Directory[]|"\(.key)\t\(.title)\t\(.type)"' <<<"$sections")
}
check_posters() {
section "Poster integrity"
if (( ! CHECK_POSTERS )); then
info "skipped (one request per item) — re-run with --posters, or use bin/poster-audit"
return
fi
local sections
sections="$(plex_get /library/sections)"
local key title type code bad total
WORKDIR="$(mktemp -d)"
while IFS=$'\t' read -r key title type; do
case "$type" in
movie) code=1 ;;
show) code=2 ;;
*) continue ;;
esac
posters_fetch_items "$key" "$code" "$WORKDIR/items.json"
posters_scan "$WORKDIR/items.json" > "$WORKDIR/scan.tsv"
total="$(wc -l < "$WORKDIR/scan.tsv" | tr -d ' ')"
bad="$(awk -F'\t' '$2!="OK"' "$WORKDIR/scan.tsv" | wc -l | tr -d ' ')"
if [[ "$bad" == "0" ]]; then
ok "${title}: ${total}/${total} posters resolve"
else
warn "${title}: ${bad}/${total} posters missing or broken — run bin/poster-repair --section '${title}'"
fi
done < <(jq -r '.MediaContainer.Directory[]|"\(.key)\t\(.title)\t\(.type)"' <<<"$sections")
}
check_butler() {
section "Scheduled maintenance (Butler)"
local b
b="$(plex_get /butler)" || { warn "cannot read butler tasks"; return; }
local backup optimize
backup="$(jq -r '.ButlerTasks.ButlerTask[]|select(.name=="BackupDatabase")|.enabled' <<<"$b")"
optimize="$(jq -r '.ButlerTasks.ButlerTask[]|select(.name=="OptimizeDatabase")|.enabled' <<<"$b")"
[[ "$backup" == "true" ]] && ok "database backup enabled" || warn "database backup DISABLED — no automatic recovery point"
[[ "$optimize" == "true" ]] && ok "database optimize enabled" || warn "database optimize disabled"
local disabled
disabled="$(jq -r '[.ButlerTasks.ButlerTask[]|select(.enabled==false)|.name]|join(", ")' <<<"$b")"
[[ -n "$disabled" ]] && info "disabled tasks: ${disabled}"
}
check_activity() {
section "Current activity"
local s a
s="$(plex_get /status/sessions)" || { warn "cannot read sessions"; return; }
local streams transcodes
streams="$(jq -r '.MediaContainer.size // 0' <<<"$s")"
transcodes="$(jq -r '[.MediaContainer.Metadata[]?|select((.TranscodeSession//empty)!=empty)]|length' <<<"$s")"
ok "${streams} active stream(s), ${transcodes} transcoding"
a="$(plex_get /activities)" || return
local acts
acts="$(jq -r '.MediaContainer.size // 0' <<<"$a")"
if [[ "$acts" == "0" ]]; then
ok "no background activities running"
else
info "${acts} background activity/activities: $(jq -r '[.MediaContainer.Activity[]?|.title]|join(", ")' <<<"$a")"
fi
}
main() {
while (( $# )); do
case "$1" in
--posters) CHECK_POSTERS=1 ;;
--help|-h) usage; exit 0 ;;
*) plex_die "unknown argument: $1 (try --help)" ;;
esac
shift
done
plex_require_deps
plex_load_env "$COLLECTION_DIR/.env"
printf 'Plex health check — %s\n' "${PLEX_URL:-unset}"
plex_check_auth
printf ' [ ok ] reachable and authenticated\n'
check_server
check_updates
check_remote_access
check_libraries
check_unmatched
check_posters
check_butler
check_activity
section "Result"
if (( FAILS > 0 )); then
printf ' %d failure(s), %d warning(s)\n' "$FAILS" "$WARNS"; exit 2
elif (( WARNS > 0 )); then
printf ' %d warning(s)\n' "$WARNS"; exit 1
fi
printf ' all checks passed\n'
}
main "$@"
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Report items whose poster is missing or does not resolve. Read-only.
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly COLLECTION_DIR="$(dirname "$SCRIPT_DIR")"
# shellcheck source=../lib/plex-api.sh
. "$COLLECTION_DIR/lib/plex-api.sh"
# shellcheck source=../lib/posters.sh
. "$COLLECTION_DIR/lib/posters.sh"
SECTION="TV Shows"
TYPE=2
FORMAT=text
LIST_OK=0
# Global, not local to main(): an EXIT trap fires after main() returns, by which
# point a local would be out of scope and `set -u` would abort the cleanup.
WORKDIR=""
cleanup() { [[ -n "$WORKDIR" ]] && rm -rf "$WORKDIR"; return 0; }
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage: poster-audit [--section <id|title>] [--type <n>] [--json] [--list-ok]
--section Library section, by id or exact title. Default: "TV Shows".
--type Plex type code: 1=movie 2=show 3=season 4=episode. Default: 2.
--json Emit JSON instead of a human report.
--list-ok Also list items whose poster is fine.
--jobs N Parallel HTTP checks (env POSTER_JOBS, default 8).
--help
Statuses:
MISSING no thumb field on the item at all
BROKEN thumb field present, but the URL returns non-200
Read-only: makes no changes. Use poster-repair to fix what this finds.
EOF
}
main() {
while (( $# )); do
case "$1" in
--section) SECTION="${2:?--section needs a value}"; shift ;;
--type) TYPE="${2:?--type needs a value}"; shift ;;
--json) FORMAT=json ;;
--list-ok) LIST_OK=1 ;;
--jobs) POSTER_JOBS="${2:?--jobs needs a value}"; export POSTER_JOBS; shift ;;
--help|-h) usage; exit 0 ;;
*) plex_die "unknown argument: $1 (try --help)" ;;
esac
shift
done
plex_require_deps
plex_load_env "$COLLECTION_DIR/.env"
plex_check_auth
local section_id
section_id="$(posters_resolve_section "$SECTION")"
WORKDIR="$(mktemp -d)"
posters_fetch_items "$section_id" "$TYPE" "$WORKDIR/items.json"
posters_scan "$WORKDIR/items.json" | sort -t$'\t' -k3 > "$WORKDIR/scan.tsv"
local total missing broken ok
total="$(wc -l < "$WORKDIR/scan.tsv" | tr -d ' ')"
missing="$(awk -F'\t' '$2=="MISSING"' "$WORKDIR/scan.tsv" | wc -l | tr -d ' ')"
broken="$(awk -F'\t' '$2=="BROKEN"' "$WORKDIR/scan.tsv" | wc -l | tr -d ' ')"
ok="$(awk -F'\t' '$2=="OK"' "$WORKDIR/scan.tsv" | wc -l | tr -d ' ')"
if [[ "$FORMAT" == json ]]; then
jq -Rn --arg section "$SECTION" --arg id "$section_id" --arg type "$TYPE" \
--argjson total "$total" --argjson missing "$missing" \
--argjson broken "$broken" --argjson ok "$ok" \
--rawfile scan "$WORKDIR/scan.tsv" \
'{section:$section, sectionId:$id, type:$type,
totals:{total:$total, ok:$ok, missing:$missing, broken:$broken},
items:[$scan|split("\n")[]|select(length>0)|split("\t")
|{ratingKey:.[0], status:.[1], title:.[2]}]
| map(select(.status != "OK"))}'
return 0
fi
plex_log "Poster audit — section '${SECTION}' (id ${section_id}, type ${TYPE})"
plex_log " total ${total} ok ${ok} broken ${broken} missing ${missing}"
if (( broken + missing > 0 )); then
plex_log ""
plex_log "Needs repair:"
awk -F'\t' '$2!="OK" {printf " %-8s %-8s %s\n", $1, $2, $3}' "$WORKDIR/scan.tsv"
plex_log ""
plex_log "Run: bin/poster-repair --section '${SECTION}' --dry-run"
else
plex_log ""
plex_log "All posters resolve."
fi
if (( LIST_OK )); then
plex_log ""
plex_log "OK:"
awk -F'\t' '$2=="OK" {printf " %-8s %s\n", $1, $3}' "$WORKDIR/scan.tsv"
fi
}
main "$@"
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Re-select a poster for items whose poster is missing or does not resolve.
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly COLLECTION_DIR="$(dirname "$SCRIPT_DIR")"
# shellcheck source=../lib/plex-api.sh
. "$COLLECTION_DIR/lib/plex-api.sh"
# shellcheck source=../lib/posters.sh
. "$COLLECTION_DIR/lib/posters.sh"
SECTION="TV Shows"
TYPE=2
DRY_RUN=0
LIMIT=0
export POSTER_ALLOW_REMOTE=0
# Global, not local to main(): an EXIT trap fires after main() returns, by which
# point a local would be out of scope and `set -u` would abort the cleanup.
WORKDIR=""
cleanup() { [[ -n "$WORKDIR" ]] && rm -rf "$WORKDIR"; return 0; }
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage: poster-repair [--dry-run] [--section <id|title>] [--type <n>]
[--limit N] [--allow-remote]
--dry-run Show what would be selected; change nothing.
--section Library section, by id or exact title. Default: "TV Shows".
--type Plex type code: 1=movie 2=show 3=season 4=episode. Default: 2.
--limit N Repair at most N items (0 = no limit).
--allow-remote Also repair items with no locally-cached poster by pulling
one from the metadata agent. Off by default: it re-downloads
from the internet and may pick a different image than the one
that was originally chosen.
--jobs N Parallel HTTP checks during the scan (default 8).
--help
What it does: for each broken item it selects the poster already sitting in
that item's metadata bundle. The image is not re-downloaded and no other
metadata is touched, so this does not overwrite manually-chosen artwork —
items that still have a working poster are skipped entirely.
Idempotent: re-running repairs nothing once every poster resolves.
EOF
}
main() {
while (( $# )); do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--section) SECTION="${2:?--section needs a value}"; shift ;;
--type) TYPE="${2:?--type needs a value}"; shift ;;
--limit) LIMIT="${2:?--limit needs a value}"; shift ;;
--allow-remote) POSTER_ALLOW_REMOTE=1 ;;
--jobs) POSTER_JOBS="${2:?--jobs needs a value}"; export POSTER_JOBS; shift ;;
--help|-h) usage; exit 0 ;;
*) plex_die "unknown argument: $1 (try --help)" ;;
esac
shift
done
plex_require_deps
plex_load_env "$COLLECTION_DIR/.env"
plex_check_auth
local section_id
section_id="$(posters_resolve_section "$SECTION")"
WORKDIR="$(mktemp -d)"
plex_log "Scanning section '${SECTION}' (id ${section_id}, type ${TYPE})..."
posters_fetch_items "$section_id" "$TYPE" "$WORKDIR/items.json"
posters_scan "$WORKDIR/items.json" | awk -F'\t' '$2!="OK"' | sort -t$'\t' -k3 > "$WORKDIR/broken.tsv"
local count
count="$(wc -l < "$WORKDIR/broken.tsv" | tr -d ' ')"
if (( count == 0 )); then
plex_log "Nothing to repair — every poster resolves."
return 0
fi
plex_log "Found ${count} item(s) needing repair."
(( DRY_RUN )) && plex_log "[dry-run] no changes will be made."
plex_log ""
local repaired=0 skipped=0 failed=0 processed=0
local rk status title candidate
while IFS=$'\t' read -r rk status title; do
if (( LIMIT > 0 && processed >= LIMIT )); then
plex_log "Reached --limit ${LIMIT}; stopping."
break
fi
processed=$((processed + 1))
candidate="$(posters_candidate "$rk")" || candidate=""
if [[ -z "$candidate" ]]; then
plex_warn "SKIP ${title} (${rk}, ${status}) — no locally-cached poster; retry with --allow-remote"
skipped=$((skipped + 1))
continue
fi
if (( DRY_RUN )); then
plex_log "[dry-run] would select for ${title} (${rk}): ${candidate:0:72}"
repaired=$((repaired + 1))
continue
fi
if ! posters_select "$rk" "$candidate"; then
plex_warn "FAIL ${title} (${rk}) — poster selection rejected"
failed=$((failed + 1))
continue
fi
if posters_verify "$rk"; then
plex_log "OK ${title} (${rk})"
repaired=$((repaired + 1))
else
plex_warn "FAIL ${title} (${rk}) — selected, but thumb still does not resolve"
failed=$((failed + 1))
fi
done < "$WORKDIR/broken.tsv"
plex_log ""
if (( DRY_RUN )); then
plex_log "Summary (dry-run): ${repaired} would be repaired, ${skipped} skipped."
else
plex_log "Summary: ${repaired} repaired, ${skipped} skipped, ${failed} failed."
fi
(( failed == 0 ))
}
main "$@"
+95
View File
@@ -0,0 +1,95 @@
# TV Shows: 101 missing posters (investigated 2026-08-15)
What the symptom looked like, what it actually was, and what is still unknown.
## Symptom
Grey placeholder posters scattered through the **TV Shows** library in the Plex UI.
## Scope
| Library | Type | Affected |
| --- | --- | --- |
| TV Shows (id 2) | shows | **101 of 283** (36%) |
| TV Shows (id 2) | seasons | 0 of 954 |
| Movies (id 1) | movies | 0 (sampled 60 of 1059) |
| Movies 4k (id 3) | movies | 0 (sampled 40 of 79) |
Only show-level posters, only in one library. Seasons and episodes were untouched, which
is why the library still looked half-normal.
## The two failure modes
They are indistinguishable in the UI, and only one is visible to a metadata-only audit:
| Mode | Count | What it looks like in the API |
| --- | --- | --- |
| `MISSING` | 6 | the item has no `thumb` field at all |
| `BROKEN` | 95 | the item **has** a `thumb` field, but fetching it returns **404** |
The 95 BROKEN shows are the trap. `/library/sections/2/all` reports a perfectly
well-formed `thumb` URL for each one — e.g. `/library/metadata/13343/thumb/1785398727`
and only an actual HTTP request reveals it serves an 85-byte HTML 404 page.
## Root cause: a lost selection pointer, not a lost file
For every affected show, `/library/metadata/<key>/posters` still returned a full
candidate list — 85 to 158 entries — **including the agent's own poster already
downloaded into the item's metadata bundle** as a `metadata://posters/...` entry.
What was gone was the selection:
```
affected shows with zero `selected: true` candidates: 101 / 101
control sample of working shows, selected count: 1 each (15 / 15)
affected shows with the poster still in the bundle: 100 / 101
```
So the images were never deleted. The database simply no longer marked *any* candidate as
selected for those items, and a show with nothing selected has no poster to serve.
Because the file was still local, the repair was a pure database operation — instant, no
internet round trip, no re-download:
```bash
PUT /library/metadata/<key>/poster?url=metadata://posters/tv.plex.agents.series_<hash>
```
Validated on one show (Severance, `13343`) before touching the rest: `thumb` went from
`404` to `200 image/jpeg`, 533 KB.
## Hypotheses tested and rejected
- **Unmatched media.** Rejected: all 283 shows had real `plex://show/...` GUIDs; none had
a `local://` GUID. The affected shows also had full season and episode counts.
- **A bad bulk metadata refresh.** A refresh clearly did run — 77 shows share an
`updatedAt` inside a 35-second window (~2026-07-30). But it does not correlate with the
damage: of the items in that window 54 are fine and 23 are broken, while 72 broken items
sit outside it entirely. Rejected as *the* cause.
- **A stale/expired thumb URL format.** Rejected: broken and working items had
structurally identical `thumb` URLs with timestamps from the same refresh.
## Still unknown
**Which event cleared the selection flags.** Establishing that needs the Plex server logs
on odin (`Plex Media Server.log`), which requires filesystem access to the container's
appdata — not available from `dev` at the time of writing (no SSH key is authorized on
odin). The `CleanOldBundles` Butler task is enabled on a 7-day interval and is the obvious
suspect to check first, but note that it does **not** fit the evidence cleanly: the poster
files survived in the bundles: only the database pointers were lost.
Worth watching: if posters rot again on roughly a 7-day cadence, that points at a Butler
task; if it correlates with a Plex version bump, it points at a migration.
## Repair record
| Step | Result |
| --- | --- |
| Validation on Severance | poster restored, verified `200 image/jpeg` |
| Bulk repair, local candidates | 100 shows restored |
| True Detective (no local candidate) | restored via `--allow-remote` from the agent |
| Final audit | **283 / 283 posters resolve** |
Tooling built from this: [../bin/poster-audit](../bin/poster-audit) and
[../bin/poster-repair](../bin/poster-repair). Re-run the audit if the symptom returns —
it is read-only and will say in a couple of minutes whether it is the same failure.
+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" ]]
}