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>
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).
| Script | What it does | Mutates? |
|---|---|---|
| bin/plex-health | Read-only health report: server, updates, libraries, unmatched media, Butler tasks, active streams | no |
| bin/poster-audit | Finds items whose poster is missing or does not resolve | no |
| bin/poster-repair | Re-selects the poster for items the audit flags | yes |
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
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-repaironly touches items whose poster does not currently resolve, so a second run repairs nothing.plex-healthnever writes. - Destructive operations: none.
poster-repairselects 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-remoteis 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-healthis 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 nothumbfield at all.BROKEN— the item has athumbfield, 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.