Files
scripts/plex/bin/plex-health
T
Lucas BergerandClaude Opus 5 29476235ab Reuse the health check's temp dir instead of orphaning it
check_posters unconditionally re-made WORKDIR, so when check_unmatched had
already created one the first was left behind — cleanup only removes the last.

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

299 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
# Reuse the dir check_unmatched may already have made; overwriting WORKDIR
# here would orphan it, since cleanup only removes the last one.
[[ -n "$WORKDIR" ]] || 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 "$@"