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