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>
105 lines
5.1 KiB
Markdown
105 lines
5.1 KiB
Markdown
# 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`.
|