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
Lucas BergerandClaude Opus 5 dad8774ed9 Document verified odin access path and Unraid API
Replace the assumed/unconfirmed environment notes with facts verified from
this box, and add docs/odin-access.md as the access reference.

Verified:
- Tailscale is the only path from dev to odin. dev (100.94.16.46) is a VPS
  with a public IP, not a LAN machine; odin is 100.101.253.105, and a subnet
  route puts 192.168.90.0/24 over tailscale0 at the same ~34ms RTT.
- The myunraid.net host is the canonical nginx vhost, not a cloud relay --
  DNS resolves it to the private 192.168.90.103, so traffic stays on the
  tailnet. The bare IP 404s on /graphql, and plain HTTP 302-redirects to the
  myunraid host while stripping the x-api-key header.
- GraphQL is live: unauthenticated POSTs return HTTP 200 with an
  UNAUTHENTICATED error body, so 200 must never be read as success.

Corrects unraid-docker-manager's claim that no SSL ignore is needed: TLS
verification fails from dev because ca-certificates 20250419 cannot chain
Let's Encrypt intermediate YR1. The cert is genuine and the claim likely
holds on odin itself, but curl needs -k here.

Container-polling field behaviours (UPPERCASE state, /-prefixed names,
PrefixedID ids, no isUpdateAvailable in 7.2) are carried over from
unraid-docker-manager, which is credited as the authoritative source.

No API key is present on this box; .env.unraid-api is gitignored there and
absent from the clone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 20:33:29 -04:00
Lucas BergerandClaude Opus 5 c6be0dc9d6 Add CLAUDE.md, collection scaffold, and MemPalace wing
Establish this repo as a collection of independent script folders for the
self-hosted environment (odin, an Unraid host).

- CLAUDE.md: repo conventions, the odin stack, and MemPalace usage. The
  infrastructure facts are carried over from the FamilySync project, where
  they are documented and verified; the mapping of the name "odin" to that
  host is assumed and flagged for confirmation, along with the SSH/deploy
  gaps marked "?".
- Core rule: each collection is a self-contained top-level folder owning its
  own docs, config, and dependencies. No shared/ or utils/ at the root —
  duplication is preferred over coupling so a collection stays independently
  deletable.
- _template/: scaffold making that rule concrete. The bash entrypoint ships
  strict mode, --dry-run, and a required-env guard (all four paths tested).
- mempalace.yaml: wing "odin-scripts", set explicitly because basename
  auto-detection would produce the colliding wing "scripts". Tracked rather
  than gitignored so a fresh clone keeps the config; entities.json stays
  ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 20:24:16 -04:00
18 changed files with 1627 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# 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.example
!.env.*.example
# MemPalace per-project files (issue #185).
# `mempalace init` ignores both by default. We deliberately track mempalace.yaml —
# it is just wing/room config, and losing it on a fresh clone would make mining
# fall back to basename auto-detection (wing "scripts", which collides).
# entities.json stays ignored: it holds detected people/entities.
entities.json
+212
View File
@@ -0,0 +1,212 @@
# scripts
Operational scripts for Luc's self-hosted environment. Each script collection is an
independent, self-documenting folder at the repo root. There is no shared runtime, no
build step, and no top-level package manifest — this is a repo of standalone tools, not
an application.
---
## The one hard rule: collections are self-contained
Every script collection lives in **its own top-level folder** and carries **all of its own
documentation, configuration, and dependencies**. A collection must be understandable,
runnable, and deletable on its own.
```
scripts/
├── CLAUDE.md # this file — repo-wide context
├── README.md # human-facing index of collections
├── _template/ # scaffold for a new collection (leading _ = not a collection)
└── <collection>/
├── README.md # REQUIRED — what it does, prerequisites, usage
├── CLAUDE.md # OPTIONAL — agent context, only if non-obvious
├── .env.example # REQUIRED if the collection reads secrets
├── bin/ # executable entrypoints (chmod +x, shebang)
├── lib/ # sourced helpers, not directly executable
└── docs/ # anything longer than the README warrants
```
**Do not** create shared/, common/, or utils/ at the repo root. If two collections need
the same helper, copy it. Duplication across collections is explicitly preferred over
coupling — a collection that depends on a sibling is no longer independently deletable,
and these scripts get deployed to different places at different times.
Folders prefixed with `_` are repo infrastructure, not collections.
### Adding a collection
Copy `_template/` to the new name, fill in its README, and add one line to the root
`README.md` index. That is the whole process.
---
## The environment
**odin** — the Unraid server; the single host everything self-hosted runs on. Reached from
this box over **Tailscale** (`100.101.253.105`, LAN `192.168.90.103`).
> **See [docs/odin-access.md](docs/odin-access.md)** for the access path, the WebGUI/API
> endpoint, TLS caveats, and the Unraid GraphQL API — all verified from this box.
Verified characteristics:
| Aspect | Detail |
| --- | --- |
| Host OS | Unraid |
| Container runtime | Docker + Docker Compose (stacks defined per-app) |
| Database | MariaDB 11.x — **PostgreSQL is not available**, treat as a hard constraint |
| Cache / queue | Redis (available, used optionally) |
| Auth | Authelia — OIDC/OAuth2 provider for internal apps |
| Ingress | Pangolin/Newt tunnel — **no open inbound ports** |
| DNS | Split-DNS on `bergerhouse.net`; private IPs resolve internally |
| Git forge | Self-hosted Gitea/Forgejo at `git.bergerhouse.net` (user `luckberg`, SSH port 222) |
| CI | Gitea Actions with a self-hosted runner |
| Mail / calendar | Fastmail (paid) — JMAP/CalDAV, the source of truth for calendars |
| Remote access | Tailscale; odin `100.101.253.105`, subnet route for `192.168.90.0/24` |
| Unraid API | GraphQL at `{myunraid.net host}:8443/graphql`, `x-api-key` auth (Unraid 7.2+) |
| Automation | n8n on odin — drives the Docker-manager Telegram bot |
Known service hostnames: `git.bergerhouse.net`, `familysync.bergerhouse.net`,
`familysync-dev.bergerhouse.net`.
**Not on odin:** GitHub (`gh` is not installed — use `tea` for the Gitea forge),
PostgreSQL, any cloud provider.
### This box vs. odin
The machine this repo is edited on is `dev` (Tailscale `100.94.16.46`) — a **separate
workstation**, and in fact a VPS with a public IP, not a machine on the home LAN. Scripts
written here usually *target* odin. Never assume a script runs on the same host it was
authored on: take the target as a parameter or read it from config.
Tailscale is the **only** path from `dev` to odin — if it is down, nothing below resolves.
`?` — still unconfirmed: the SSH login to odin (no `~/.ssh/config` entry on this box) and
where deployed scripts are expected to live on the Unraid host.
### Sibling repos on `git.bergerhouse.net`
`luckberg/` holds `scripts` (this repo), `familysync`, `unraid-docker-manager`,
`azure-epac`, `_cargo-index`. Two are load-bearing context:
- **`unraid-docker-manager`** — n8n + Telegram bot managing odin's containers. The
authoritative source on the Unraid GraphQL API and container polling; distilled into
[docs/odin-access.md](docs/odin-access.md).
- **`familysync`** — the origin of most of the stack facts in the table above.
---
## Prior context: MemPalace
Much of what is known about this environment lives in **MemPalace**, a local semantic
memory store — not in this repo. Query it before asking Luc to re-explain something, and
before assuming an infrastructure detail is undocumented.
| Aspect | Detail |
| --- | --- |
| Version | `mempalace` v3.4.1, installed as a `uv` tool |
| Palace path | `/home/luc/.mempalace/palace` |
| Backend | ChromaDB (vector search) + SQLite (metadata); fully local, no API key |
| Access | MCP server (`mempalace-mcp`) → 19 `mempalace_*` tools; plus the `mempalace` CLI |
| Size | ~32k drawers |
**Read the docs, don't guess the CLI.** MemPalace ships its own instructions:
```bash
mempalace instructions <help|init|mine|search|status>
```
Structure is **Wings → Rooms → Closets → Drawers** — wings are projects/people, rooms are
topics, closets are summaries, drawers are verbatim memories. *Halls* connect rooms within
a wing; *tunnels* connect rooms across wings.
Existing wings: `familysync` (~21.6k), `sessions` (~10.6k — mined agent transcripts),
`wing_familysync`.
### This repo's wing
Initialized with `mempalace init . --yes` and mined with `mempalace mine .`.
- **Wing: `odin-scripts`** — set explicitly in `mempalace.yaml`, *not* auto-detected.
Auto-detection uses the directory basename, and a wing called `scripts` would collide
with any other corpus in a folder of that name.
- **Rooms:** `environment`, `documentation`, `collections`, `general`.
- Room routing matches keywords against the file **path**, so never use `scripts` as a
room keyword here — it matches this repo's own path and swallows every file. (Learned
the hard way; the comment in `mempalace.yaml` says so too.)
- `mempalace.yaml` and `entities.json` are gitignored — that is MemPalace's own
convention (issue #185), applied by `init`.
Re-mine after adding a collection: `mempalace mine . --agent claude`. Mining is
idempotent — already-filed files are skipped.
Practical notes:
- `mempalace_search` is semantic — the `query` field takes **keywords only**, max 250
chars. Put background in `context`, not in the query, or the embedding gets diluted.
- Filter with `wing`/`room` when you know where the answer lives; the `sessions` wing is
raw transcript chunks and is noisy for factual lookups.
- **BM25 scores of 0 with mid-range similarity means no lexical match** — a bare-keyword
search for a proper noun that returns only semantic neighbours is a *negative* result,
not a weak positive. This is how the `odin` gap above was established.
- `mempalace_kg_query` hits the temporal knowledge graph for point-in-time facts; prefer
it over `search` for "what is X currently" questions, and use `kg_supersede` rather
than invalidate-then-add when a single-valued fact changes.
- Auto-save hooks are active: a **Stop** hook saves every 15 human messages, and a
**PreCompact** hook force-saves before context compaction.
Complementary and **separate** from MemPalace:
- **Per-project agent memory** — `~/.claude/projects/<slug>/memory/*.md`, indexed by that
directory's `MEMORY.md`. This repo's is currently empty; FamilySync's holds the CI-gate,
dev-stack, and Gitea-CI notes that much of this file's environment section derives from.
- **Project docs on disk** — `~/projects/familysync/CLAUDE.md` and `docs/ARCHITECTURE.md`
are the authoritative written source for the odin stack.
---
## Conventions
These apply to every collection unless its own README documents a deliberate exception.
### Shell
- `#!/usr/bin/env bash` and `set -euo pipefail` at the top of every bash entrypoint.
- Quote every expansion. Prefer `[[ ]]` over `[ ]`.
- Scripts must be **idempotent** — safe to re-run. Assume cron or a retry will do so.
- Accept `--dry-run` for anything that mutates state, deletes, or sends. Default to the
safe path when a flag is ambiguous.
- Log to stdout, errors to stderr. No log files unless the collection documents rotation.
### Secrets
- **Never commit secrets.** Read them from the environment or a file path passed in.
- Every collection that needs secrets ships a `.env.example` with dummy values and a
README line naming where the real values live.
- Note the FamilySync-side precedent: gitleaks runs as a blocking CI gate on that repo.
Assume any secret committed here is treated the same way — as a leak to be rotated, not
a mistake to be amended away.
### Portability
- Target the interpreters actually present: bash, Python 3, Node 22. Check before adding
a dependency on anything else.
- Unraid's userland is BusyBox-leaning in places — prefer POSIX-portable invocations of
`sed`/`awk`/`date` over GNU-only flags when the script runs on the server.
---
## Working agreements for Claude
- **Scope changes to one collection.** A request about collection X should not touch
collection Y. If it must, say so explicitly rather than doing it quietly.
- **Update the collection's README in the same change as the code.** Docs living beside
the script is the point of this repo's layout; a code change that leaves its README
stale defeats it.
- **Don't invent infrastructure.** If a script needs a host, path, port, or credential
that isn't documented above, ask — do not guess a plausible value. Wrong infra
assumptions in an ops script fail in production, not in review.
- **Destructive operations need confirmation** before they're run against odin, even when
the script itself is finished and correct. Writing the script is not authorization to
execute it.
+28
View File
@@ -0,0 +1,28 @@
# scripts
Operational scripts for my self-hosted environment (**odin**, an Unraid host running
Docker Compose behind Authelia and a Pangolin/Newt tunnel).
Each script collection lives in its own top-level folder and is fully self-contained —
its own README, config, and dependencies. Collections never import from each other.
See [CLAUDE.md](CLAUDE.md) for the environment details and repo conventions, and
[docs/odin-access.md](docs/odin-access.md) for how to reach odin and drive its API.
## Collections
| 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:
| [name](name/) | What it does | Runs on |
-->
## Adding a collection
```bash
cp -r _template <collection-name>
```
Fill in its `README.md`, then add a line to the table above.
+3
View File
@@ -0,0 +1,3 @@
# Copy to .env and fill in. Never commit the filled-in .env.
EXAMPLE_HOST=odin.bergerhouse.net
EXAMPLE_TOKEN=replace-me
+36
View File
@@ -0,0 +1,36 @@
# <collection-name>
One sentence: what this collection does and why it exists.
## Prerequisites
- Where it runs: `odin` (Unraid) / this workstation / either
- Interpreters: bash / Python 3 / Node 22
- External tools: e.g. `docker`, `tea`, `curl`
- Access needed: e.g. SSH to odin, Gitea API token
## Configuration
Copy `.env.example` to `.env` and fill it in. Real values live in `<where>`.
| Variable | Required | Description |
| --- | --- | --- |
| `EXAMPLE_HOST` | yes | Target host to operate against |
## Usage
```bash
bin/example --dry-run # preview, changes nothing
bin/example # apply
```
## Behaviour notes
- Idempotent: yes/no — and what happens on a re-run.
- Destructive operations: list them, or state "none".
- Scheduling: cron entry / Unraid User Scripts / manual only.
## Gotchas
Anything that cost time to discover. This is the section that earns the collection its
own folder — keep it honest and current.
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Template entrypoint. Copy, rename, and replace main().
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly COLLECTION_DIR="$(dirname "$SCRIPT_DIR")"
DRY_RUN=0
usage() {
cat <<'EOF'
Usage: example [--dry-run] [--help]
--dry-run Show what would happen without changing anything.
--help Show this message.
EOF
}
log() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
die() { warn "error: $*"; exit 1; }
# Run a mutating command, or describe it under --dry-run.
run() {
if (( DRY_RUN )); then
log "[dry-run] $*"
else
"$@"
fi
}
load_env() {
local env_file="$COLLECTION_DIR/.env"
[[ -f "$env_file" ]] || return 0
set -a; . "$env_file"; set +a
}
main() {
while (( $# )); do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--help|-h) usage; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
shift
done
load_env
: "${EXAMPLE_HOST:?EXAMPLE_HOST is not set — see .env.example}"
log "target: $EXAMPLE_HOST"
run true # replace with the real work
}
main "$@"
+149
View File
@@ -0,0 +1,149 @@
# Accessing odin
Reference for reaching the Unraid host (**odin**) from this workstation (`dev`) and
driving its API. Everything here was verified from `dev` on 2026-08-15; the "Verified"
column says how.
Related repo: **`luckberg/unraid-docker-manager`** on `git.bergerhouse.net` — an n8n
Telegram bot that manages odin's containers. It is the origin of most of the API detail
below and is worth reading before writing anything new against the Unraid API.
---
## Network path: Tailscale
`dev` and odin are both on the tailnet. `dev` is a **VPS** (public IP `51.222.234.162`),
*not* a machine on the home LAN — Tailscale is the only path to odin.
| Node | Tailscale IP | Notes |
| --- | --- | --- |
| `dev` | `100.94.16.46` | this workstation (linux) |
| `odin` | `100.101.253.105` | the Unraid host (linux) |
Other tailnet nodes seen: `duplicati`, `experience` (windows), `fedora`, `nusgwvm`,
`salt`, `google-pixel-8-pro`.
**A subnet route is advertised for the home LAN.** odin's LAN address `192.168.90.103` is
reachable from `dev` and routes over `tailscale0`:
```
$ ip route get 192.168.90.103
192.168.90.103 dev tailscale0 table 52 src 100.94.16.46
```
Both the tailnet IP and the LAN IP answer at ~34 ms RTT — the same path. This matters:
the `myunraid.net` URL below looks like a cloud relay, but **DNS resolves it to the
private `192.168.90.103`**, so traffic goes direct over Tailscale and never leaves the
tailnet. If Tailscale is down, none of it is reachable.
---
## WebGUI / API endpoint
```
https://192-168-90-103.87c90a69c53e7197560c778a63f483e675130f2a.myunraid.net:8443/
```
The hostname encodes odin's LAN IP with dashes, under a per-server hash on `myunraid.net`.
This is the **canonical vhost** — not merely a convenience alias. Use it for everything.
### Why the plain LAN IP does not work
Both failure modes are real and confirmed:
| Attempt | Result | Verified |
| --- | --- | --- |
| `https://192.168.90.103:8443/graphql` | **404** from nginx | curl |
| `http://192.168.90.103/graphql` | **302** → the `myunraid.net` URL | curl |
| `https://<myunraid-host>:8443/graphql` | **200**, GraphQL responds | curl |
nginx only serves `/graphql` on the `myunraid.net` `server_name`; the raw IP vhost returns
404. Plain HTTP redirects to that hostname — and **the redirect strips the `x-api-key`
header**, so a client that follows redirects authenticates as nobody. Always call the
`myunraid.net` URL directly rather than relying on a redirect.
### TLS caveat — correcting the other repo
`unraid-docker-manager` states "Valid certs via myunraid.net — no SSL ignore needed."
**That is not true from `dev`.** Verification fails here:
```
depth=1 C=US, O=Let's Encrypt, CN=YR1
verify error:num=20:unable to get local issuer certificate
depth=0 CN=*.87c90a69c53e7197560c778a63f483e675130f2a.myunraid.net
```
The leaf is a genuine Let's Encrypt wildcard, but the chain presents an intermediate
(`YR1`) that this box's CA bundle (`ca-certificates 20250419`) cannot chain to a trusted
root — either the server omits the intermediate or the bundle predates it. The cert is not
forged; the chain is incomplete *for this client*.
Practical consequence: **`curl` needs `-k` from `dev`**, and any script that talks to odin
must either pass the insecure flag or pin/supply the issuer. n8n running *on* odin may
well verify fine, which is likely why the other repo's claim held there. Prefer supplying
the missing intermediate over making `-k` a permanent habit — `-k` disables verification
entirely, which on a tailnet is a tolerable but real weakening.
---
## Unraid GraphQL API
- **Endpoint:** `{UNRAID_HOST}/graphql`, POST, where `UNRAID_HOST` is the `myunraid.net`
base URL **without** the `/graphql` suffix.
- **Auth:** `x-api-key: <key>` header.
- **Availability:** native in Unraid 7.2+ (odin is on 7.2); 6.97.1 needs the Connect plugin.
Unauthenticated requests return HTTP **200** with a GraphQL error body — not an HTTP 401:
```json
{"errors":[{"message":"Invalid CSRF token","extensions":{"code":"UNAUTHENTICATED",
"originalError":{"error":"Unauthorized","statusCode":401}}}],"data":null}
```
**Never treat HTTP 200 as success.** Always inspect `response.errors[]`.
### Polling running containers
```bash
curl -sS -k -X POST "${UNRAID_HOST}/graphql" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${UNRAID_API_KEY}" \
-d '{"query":"query { docker { containers { id names state } } }"}'
```
```json
{"data":{"docker":{"containers":[
{"id":"<server_hash>:<container_hash>","names":["/n8n"],"state":"RUNNING"}
]}}}
```
Field behaviours that bite:
- `state` is **UPPERCASE**`RUNNING`, not `running`.
- `names` is an array and entries are **`/`-prefixed** — `/n8n`, not `n8n`.
- `id` is a `PrefixedID`: `{server_hash}:{container_hash}`, two 64-char SHA-256 hex
strings joined by a colon. The server half is identical for every container on odin.
- **`isUpdateAvailable` does not exist** in the 7.2 schema, despite appearing in that
repo's earlier research. Introspect before relying on any field.
### API keys
Not present on this box — `.env.unraid-api` is gitignored and absent from the clone. Create
a key scoped to what the script actually needs:
```bash
unraid-api apikey --create --name "<name>" --permissions "DOCKER:UPDATE_ANY" --json
```
Or WebGUI → Settings → Management Access → API Keys. Store it in the collection's own
`.env`, never in git.
---
## Gotchas carried over from `unraid-docker-manager`
- **Each Bash tool call is a fresh shell.** Source the env file in the *same* command
chain as the request: `. .env.unraid-api; curl ...`. A separate `source` call is lost.
- **Large API responses:** save to a temp file before parsing; piping a 400 KB body
straight into `python3 -c` fails silently.
- Docker API `204 No Content` means success with an empty body.
+28
View File
@@ -0,0 +1,28 @@
# Wing is set explicitly, not auto-detected from the directory basename —
# "scripts" is a common folder name and would collide with other corpora.
wing: odin-scripts
rooms:
- name: environment
description: Repo-wide context — the odin/Unraid environment, conventions, agent guidance
keywords:
- environment
- odin
- unraid
- conventions
- CLAUDE
- name: documentation
description: Per-collection READMEs and docs/ content
keywords:
- documentation
- docs
- readme
# NB: do not use "scripts" as a keyword here — it matches the repo's own path
# (/home/luc/projects/scripts) and would swallow every file into this room.
- name: collections
description: Executable entrypoints and sourced helpers (bin/, lib/)
keywords:
- bin
- lib
- name: general
description: Files that don't fit other rooms
keywords: []
+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" ]]
}