diff --git a/plex/README.md b/plex/README.md index 08cafab..898b0b7 100644 --- a/plex/README.md +++ b/plex/README.md @@ -11,6 +11,11 @@ Reached at `https://plex.bergerhouse.net` (DNS resolves to odin's LAN address | [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 @@ -97,8 +102,15 @@ which for a local candidate is the `metadata://posters/...` string, not the `/li path from the same object's `key` field. **Season and episode posters were not affected** — only show-level ones, and only in the -TV Shows library. The movie libraries sampled clean. Worth re-checking rather than -assuming if this recurs. +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`. diff --git a/plex/bin/plex-health b/plex/bin/plex-health index 717390f..eaed72f 100755 --- a/plex/bin/plex-health +++ b/plex/bin/plex-health @@ -117,26 +117,80 @@ check_libraries() { 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)" - local key title type code items unmatched + [[ -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 ;; - *) continue ;; + *) info "${title}: skipped (${type} libraries are not match-checked)"; continue ;; esac - items="$(plex_get "/library/sections/${key}/all" --get --data-urlencode "type=${code}")" || continue - # An unmatched item keeps a local:// guid instead of a real agent guid. - unmatched="$(jq -r '[.MediaContainer.Metadata[]? - |select((.guid//"")|test("^(local://|com\\.plexapp\\.agents\\.none)"))]|length' <<<"$items")" - if [[ "$unmatched" == "0" ]]; then - ok "${title}: all items matched to an agent" + + # ── 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 - warn "${title}: ${unmatched} unmatched item(s) — they will never get metadata or artwork" + 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") } diff --git a/plex/docs/unmatched-media.md b/plex/docs/unmatched-media.md new file mode 100644 index 0000000..610699c --- /dev/null +++ b/plex/docs/unmatched-media.md @@ -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//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. diff --git a/plex/known-issues.conf b/plex/known-issues.conf new file mode 100644 index 0000000..acc7690 --- /dev/null +++ b/plex/known-issues.conf @@ -0,0 +1,11 @@ +# Accepted findings — plex-health downgrades these from [warn] to [info]. +# +# Format: +# 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.