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>
297 lines
11 KiB
Bash
Executable File
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 "$@"
|