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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3df1cca91c
commit
829bf009df
+14
-2
@@ -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-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** |
|
| [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
|
## Prerequisites
|
||||||
|
|
||||||
- **Where it runs:** this workstation (`dev`), targeting odin over Tailscale. Nothing
|
- **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.
|
path from the same object's `key` field.
|
||||||
|
|
||||||
**Season and episode posters were not affected** — only show-level ones, and only in the
|
**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
|
TV Shows library. Every other library was later checked in full (not sampled) and came
|
||||||
assuming if this recurs.
|
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
|
**The 2-minute tool timeout will kill a full repair run.** Run it with `nohup ... &` and
|
||||||
tail the log, or use `--limit`.
|
tail the log, or use `--limit`.
|
||||||
|
|||||||
+63
-9
@@ -117,26 +117,80 @@ check_libraries() {
|
|||||||
done < <(jq -r '.MediaContainer.Directory[]|"\(.key)\t\(.title)\t\(.type)\t\(.refreshing)"' <<<"$sections")
|
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() {
|
check_unmatched() {
|
||||||
section "Unmatched media"
|
section "Unmatched media"
|
||||||
local sections
|
local sections
|
||||||
sections="$(plex_get /library/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
|
while IFS=$'\t' read -r key title type; do
|
||||||
case "$type" in
|
case "$type" in
|
||||||
movie) code=1 ;;
|
movie) code=1 ;;
|
||||||
show) code=2 ;;
|
show) code=2 ;;
|
||||||
*) continue ;;
|
*) info "${title}: skipped (${type} libraries are not match-checked)"; continue ;;
|
||||||
esac
|
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.
|
# ── top level: movies or shows, reported per item ────────────────────────
|
||||||
unmatched="$(jq -r '[.MediaContainer.Metadata[]?
|
plex_get "/library/sections/${key}/all" --get --data-urlencode "type=${code}" \
|
||||||
|select((.guid//"")|test("^(local://|com\\.plexapp\\.agents\\.none)"))]|length' <<<"$items")"
|
-o "$WORKDIR/top.json" || { warn "${title}: could not list items"; continue; }
|
||||||
if [[ "$unmatched" == "0" ]]; then
|
jq -r "${unmatched_filter}|\"\(.ratingKey)\t\(.title)\"" "$WORKDIR/top.json" > "$WORKDIR/top_unmatched.tsv"
|
||||||
ok "${title}: all items matched to an agent"
|
|
||||||
|
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
|
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
|
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")
|
done < <(jq -r '.MediaContainer.Directory[]|"\(.key)\t\(.title)\t\(.type)"' <<<"$sections")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user