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:
Lucas Berger
2026-08-15 21:27:56 -04:00
co-authored by Claude Opus 5
parent 3df1cca91c
commit 829bf009df
4 changed files with 163 additions and 11 deletions
+63 -9
View File
@@ -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")
}