Compare commits

..
Author SHA1 Message Date
Lucas BergerandClaude Opus 5 eaf91b157b Fix poster scan losing items whose title contains a quote
posters_scan passed each line to the worker with `xargs -I{}`, which splices
the text straight into the shell command. Any title containing an apostrophe
or a quote became a shell syntax error and that item was dropped — silently,
since the error went to stderr while the audit counted only what came back.

No show title happened to trigger it, so this survived the show-level work.
The first episode-level scan hit it immediately: titles like "That's a shirt?"
and "WAIT A MINUTE, ..." produced `unexpected EOF while looking for matching
quote`, which means that episode run reported nothing trustworthy.

Lines are now passed as a positional argument, so the shell never parses their
content. Regression-tested with titles containing apostrophes, double quotes,
commas, backticks and $(...) — all handled, and the $(...) case confirmed not
to execute.

Also tightens the check itself: a poster is OK only if the response is 200
*and* an image content-type. Plex answers some paths with 200 and an XML body,
which the status-only check counted as a working poster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:40:06 -04:00
Lucas BergerandClaude Opus 5 29476235ab Reuse the health check's temp dir instead of orphaning it
check_posters unconditionally re-made WORKDIR, so when check_unmatched had
already created one the first was left behind — cleanup only removes the last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:28:59 -04:00
Lucas BergerandClaude Opus 5 829bf009df Check unmatched media at season and episode level
Checking only shows and movies was hiding the larger problem. Battlestar
Galactica has a real plex:// GUID, a poster, and passes a show-level audit,
while all 74 of its episodes and all 5 of its seasons carry local:// GUIDs and
display as "Episode 1", "Episode 2" with no titles or artwork.

Surveying every level found 97 unmatched episodes of 11531 and 7 unmatched
seasons of 954, across four shows — none of it visible before.

Also adds known-issues.conf, an accepted-findings list. The unmatched movie in
the Movies library is the first entry: the 2003 BSG miniseries is catalogued as
television, so the movie agent has no record to match it against and the item
cannot be fixed in place. Listed entries are still printed, as [info] rather
than [warn], so they stop counting as unresolved faults without ever being
silently suppressed.

The episode list is a ~30MB response, so it is written to a temp file before
parsing rather than piped into jq.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:27:56 -04:00
Lucas BergerandClaude Opus 5 3df1cca91c 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>
2026-08-15 21:15:06 -04:00
Lucas BergerandClaude Opus 5 28e0fdb631 Ignore .env files at every depth, and their variants
A pattern with no leading slash already matched at any depth, but the intent
was not obvious from the rules. State it explicitly and widen to `.env.*` so
variants like .env.local or .env.unraid-api cannot slip through, while keeping
.env.example and .env.<name>.example tracked.

Verified with git check-ignore at the repo root, one level down, and three
levels down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:14:56 -04:00
luckberg 4260951546 Merge pull request 'Add CLAUDE.md, collection scaffold, and verified odin access docs' (#1) from docs/repo-scaffold-and-claude-md into main
Reviewed-on: #1
2026-08-15 20:34:24 -04:00
12 changed files with 1111 additions and 4 deletions
+7 -3
View File
@@ -1,7 +1,11 @@
# Secrets — every collection keeps its own .env beside its .env.example # Secrets — every collection keeps its own .env beside its .env.example.
# A pattern with no leading slash matches at EVERY depth, so these cover any
# .env anywhere in the repo, present or future. `.env.*` catches variants like
# .env.local / .env.unraid-api; the negation keeps the committed examples.
.env .env
**/.env .env.*
!**/.env.example !.env.example
!.env.*.example
# MemPalace per-project files (issue #185). # MemPalace per-project files (issue #185).
# `mempalace init` ignores both by default. We deliberately track mempalace.yaml — # `mempalace init` ignores both by default. We deliberately track mempalace.yaml —
+3 -1
View File
@@ -11,7 +11,9 @@ See [CLAUDE.md](CLAUDE.md) for the environment details and repo conventions, and
## Collections ## Collections
_None yet._ | Collection | What it does | Runs on |
| --- | --- | --- |
| [plex](plex/) | Health checks and library-artwork repair for the Plex server | this workstation, targeting odin |
<!-- Add one line per collection: <!-- Add one line per collection:
| [name](name/) | What it does | Runs on | | [name](name/) | What it does | Runs on |
+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=
+116
View File
@@ -0,0 +1,116 @@
# 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** |
[known-issues.conf](known-issues.conf) lists findings that have been reviewed and
accepted. `plex-health` still reports them, but as `[info]` rather than `[warn]`, so a
known-and-accepted item stops making the whole run read as failing. Nothing is ever
suppressed silently.
## 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. Every other library was later checked in full (not sampled) and came
back 100% clean. Worth re-checking rather than assuming if this recurs.
**A matched show can be full of unmatched episodes.** Checking only the top level of a
library is not enough: Battlestar Galactica has a real `plex://show/...` GUID and looks
healthy in a show-level audit, while all 74 of its episodes carry `local://` GUIDs and
display as "Episode 1", "Episode 2"… with no titles or artwork. `plex-health` therefore
checks show, season, *and* episode level. The episode list for a library this size is a
~30 MB response — it is written to a temp file before parsing, never piped into `jq`.
**The 2-minute tool timeout will kill a full repair run.** Run it with `nohup ... &` and
tail the log, or use `--limit`.
+298
View File
@@ -0,0 +1,298 @@
#!/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")
}
# Is this ratingKey listed in known-issues.conf? Echoes the reason if so.
known_issue_reason() {
local rk="${1:?}"
local conf="$COLLECTION_DIR/known-issues.conf"
[[ -f "$conf" ]] || return 1
awk -v rk="$rk" '
/^[[:space:]]*(#|$)/ { next }
$1 == rk { $1=""; sub(/^[[:space:]]+/, ""); print; found=1; exit }
END { exit !found }
' "$conf"
}
# An unmatched item keeps a local:// guid instead of a real agent guid, so it
# will never receive metadata or artwork. Checked at every level: a show can be
# matched while all of its episodes are not.
check_unmatched() {
section "Unmatched media"
local sections
sections="$(plex_get /library/sections)"
[[ -n "$WORKDIR" ]] || WORKDIR="$(mktemp -d)"
local unmatched_filter='.MediaContainer.Metadata[]?
|select((.guid//"")|test("^(local://|com\\.plexapp\\.agents\\.none)"))'
local key title type code
while IFS=$'\t' read -r key title type; do
case "$type" in
movie) code=1 ;;
show) code=2 ;;
*) info "${title}: skipped (${type} libraries are not match-checked)"; continue ;;
esac
# ── top level: movies or shows, reported per item ────────────────────────
plex_get "/library/sections/${key}/all" --get --data-urlencode "type=${code}" \
-o "$WORKDIR/top.json" || { warn "${title}: could not list items"; continue; }
jq -r "${unmatched_filter}|\"\(.ratingKey)\t\(.title)\"" "$WORKDIR/top.json" > "$WORKDIR/top_unmatched.tsv"
local n_top rk t reason accepted=0 flagged=0
n_top="$(wc -l < "$WORKDIR/top_unmatched.tsv" | tr -d ' ')"
if [[ "$n_top" == "0" ]]; then
ok "${title}: all $(jq -r '.MediaContainer.Metadata|length' "$WORKDIR/top.json") items matched"
else
while IFS=$'\t' read -r rk t; do
if reason="$(known_issue_reason "$rk")"; then
info "${title}: '${t}' (${rk}) unmatched — accepted: ${reason:0:96}"
accepted=$((accepted + 1))
else
warn "${title}: '${t}' (${rk}) is unmatched — it will never get metadata or artwork"
flagged=$((flagged + 1))
fi
done < "$WORKDIR/top_unmatched.tsv"
(( flagged == 0 )) && ok "${title}: ${accepted} unmatched item(s), all accepted in known-issues.conf"
fi
# ── seasons and episodes, reported per parent show ───────────────────────
[[ "$code" == "2" ]] || continue
local level lcode total bad
for level in season:3 episode:4; do
lcode="${level##*:}"
# Large response (the episode list runs to tens of MB) — write to a file
# rather than piping it into jq.
plex_get "/library/sections/${key}/all" --get --data-urlencode "type=${lcode}" \
-o "$WORKDIR/lvl.json" || { warn "${title}: could not list ${level%%:*}s"; continue; }
total="$(jq -r '.MediaContainer.Metadata|length' "$WORKDIR/lvl.json")"
bad="$(jq -r "[${unmatched_filter}]|length" "$WORKDIR/lvl.json")"
if [[ "$bad" == "0" ]]; then
ok "${title}: all ${total} ${level%%:*}s matched"
else
warn "${title}: ${bad}/${total} ${level%%:*}s unmatched, by show:"
jq -r "${unmatched_filter}|.grandparentTitle // .parentTitle // .title" "$WORKDIR/lvl.json" \
| sort | uniq -c | sort -rn \
| awk '{c=$1; $1=""; sub(/^[[:space:]]+/,""); printf " %-40s %s\n", $0, c}'
fi
done
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
# Reuse the dir check_unmatched may already have made; overwriting WORKDIR
# here would orphan it, since cleanup only removes the last one.
[[ -n "$WORKDIR" ]] || 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.
+75
View File
@@ -0,0 +1,75 @@
# Unmatched media (surveyed 2026-08-15)
An unmatched item carries a `local://` GUID instead of a real agent GUID
(`plex://movie/...`, `plex://episode/...`). Plex will never give it a title, summary, or
artwork, and no amount of poster repair will help — there is no metadata record behind it.
## Why the top level is not enough
**A matched parent can be full of unmatched children.** This is the finding that matters:
- `Battlestar Galactica` the *show* has a real GUID, a poster, and looks completely
healthy in a show-level audit.
- All **74 of its episodes** and all **5 of its seasons** carry `local://` GUIDs. They
display as "Episode 1", "Episode 2"… with no titles and no artwork.
An audit scoped to shows and movies reports this library as clean. `plex-health` checks
show, season, and episode level for exactly this reason.
## Current state
| Level | Unmatched | Total |
| --- | --- | --- |
| Movies | 1 | 1059 |
| Movies 4k | 0 | 79 |
| Shows | 0 | 283 |
| Seasons | 7 | 954 |
| Episodes | 97 | 11531 |
Episodes by show:
| Show | Unmatched | Of total | Note |
| --- | --- | --- | --- |
| Battlestar Galactica | 74 | 74 | every episode |
| Louis Theroux | 19 | 19 | every episode |
| Hellsing Ultimate | 3 | 13 | partial |
| Ancient Aliens | 1 | 205 | single episode |
## Battlestar Galactica: three copies, none of them right
Worth understanding as a whole before changing anything:
1. **`Movies``Battlestar Galactica the Mini Series`** (`19798`, `local://19798`)
`/data/Movies/Battlestar Galactica - The Mini-Series (2003)/…Bluray-1080p.mkv`
94 min, 1080p, 11.06 GB. **Unmatchable in a Movies library** — Plex's movie agent has
no entry for the 2003 miniseries, which is catalogued as television. All 20 candidates
it offers are documentaries or the Razor/Plan films. Accepted in
[../known-issues.conf](../known-issues.conf); the structural fix is to move the file
into the TV library, which needs filesystem access to odin.
2. **`TV Shows``Battlestar Galactica (2003)``Season 2003`** (2 episodes)
`/data/TV/Battlestar Galactica (Miniseries)/Season 2003/…S2003E01…`
576p, ~2 GB per part, 94 + 89 min. The same miniseries, at lower quality but complete.
The `S2003E01` filename is what created the bogus "Season 2003" — the agent expects
specials as season 0 (`S00E01`).
3. **`TV Shows``Battlestar Galactica (2003)` → Seasons 14** (74 episodes)
Filenames are correct and carry real episode titles
(`… - S01E01 - 33.mkv`, `… - S01E02 - Water.mkv`), yet every episode is `local://`.
The show matched; its episodes never did.
Note that (1) and (2) are **not** interchangeable copies: the Movies file is higher
resolution but 94 minutes against the TV pair's 183, so it is likely part one only rather
than a complete alternative.
## Not yet attempted
Re-matching the 97 episodes. A forced metadata refresh
(`PUT /library/metadata/<key>/refresh?force=1`) is the obvious lever, and for a show whose
filenames are already correct it would probably match them.
**It carries a specific risk here:** a forced refresh rebuilds the item's metadata,
which is precisely the operation implicated in the poster failure documented in
[tv-poster-incident.md](tv-poster-incident.md). Running it across the library could undo
the poster repair. If it is attempted, do it on **one** show first, then re-run
`bin/poster-audit` to confirm the posters survived before going wider.
+11
View File
@@ -0,0 +1,11 @@
# Accepted findings — plex-health downgrades these from [warn] to [info].
#
# Format: <ratingKey> <reason>
# Blank lines and #-comments are ignored. A ratingKey listed here is still
# reported, so it never disappears silently; it just stops counting as an
# unresolved fault. Write the reason only — the title is read from Plex.
#
# Review this file when a library is rebuilt: ratingKeys are not stable across
# a library delete-and-rescan.
19798 Plex's movie agent has no entry for the 2003 miniseries (it is catalogued as television), so it cannot be matched in a Movies library. Structural fix is to move the file into the TV library beside the existing show. Accepted 2026-08-15.
+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
}
+135
View File
@@ -0,0 +1,135 @@
# 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"
# Each line is passed to the worker as a positional ARGUMENT ($1), never
# substituted into the command text. `xargs -I{}` would splice the title
# straight into the shell string, so a title containing an apostrophe or a
# quote — "That's", 'MINUTE,' — becomes a syntax error and that item is
# silently lost. Real titles at episode scale are full of them.
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' -n 1 bash -c '
IFS=" " read -r rk thumb title <<<"$1"
flags=(--silent --show-error --max-time 30)
[[ "${PLEX_INSECURE:-0}" == "1" ]] && flags+=(--insecure)
# Status alone is not proof of a poster: Plex answers some paths with
# HTTP 200 and an XML or HTML body. Require an image content-type.
read -r code ctype < <(curl "${flags[@]}" -o /dev/null \
-w "%{http_code} %{content_type}" \
-H "X-Plex-Token: ${PLEX_TOKEN}" "${PLEX_URL%/}${thumb}")
if [[ "$code" == "200" && "$ctype" == image/* ]]; 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" ]]
}