Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaf91b157b | ||
|
|
29476235ab | ||
|
|
829bf009df | ||
|
|
3df1cca91c | ||
|
|
28e0fdb631 | ||
|
|
4260951546 | ||
|
|
dad8774ed9 | ||
|
|
c6be0dc9d6 |
+15
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
Executable
+55
@@ -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 "$@"
|
||||||
@@ -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.9–7.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.
|
||||||
@@ -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: []
|
||||||
@@ -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
@@ -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`.
|
||||||
Executable
+298
@@ -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 "$@"
|
||||||
Executable
+109
@@ -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 "$@"
|
||||||
Executable
+134
@@ -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 "$@"
|
||||||
@@ -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.
|
||||||
@@ -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 1–4** (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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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" ]]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user