Files
scripts/plex/bin/plex-health
T
Lucas BergerandClaude Opus 5 829bf009df Check unmatched media at season and episode level
Checking only shows and movies was hiding the larger problem. Battlestar
Galactica has a real plex:// GUID, a poster, and passes a show-level audit,
while all 74 of its episodes and all 5 of its seasons carry local:// GUIDs and
display as "Episode 1", "Episode 2" with no titles or artwork.

Surveying every level found 97 unmatched episodes of 11531 and 7 unmatched
seasons of 954, across four shows — none of it visible before.

Also adds known-issues.conf, an accepted-findings list. The unmatched movie in
the Movies library is the first entry: the 2003 BSG miniseries is catalogued as
television, so the movie agent has no record to match it against and the item
cannot be fixed in place. Listed entries are still printed, as [info] rather
than [warn], so they stop counting as unresolved faults without ever being
silently suppressed.

The episode list is a ~30MB response, so it is written to a temp file before
parsing rather than piped into jq.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:27:56 -04:00

297 lines
11 KiB
Bash
Executable File

#!/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
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 "$@"