Files
scripts/plex/bin/poster-repair
T
Lucas BergerandClaude Opus 5 3df1cca91c Add plex collection: health check and poster repair
Plex on odin had 101 of 283 TV shows showing grey placeholder posters. The
cause was not missing images: for every affected show the poster was still
downloaded in the item's metadata bundle, but no candidate was marked
`selected` in the database, so the thumb URL resolved to a 404. Working shows
had exactly one selected candidate; all 101 affected had zero.

Two details make this easy to misdiagnose, so both are captured in the docs:
only 6 items lacked a `thumb` field outright, while 95 advertised a
well-formed thumb URL that 404s on fetch — a metadata-only audit reports the
library as healthy. And a bulk metadata refresh, the obvious suspect, does not
correlate with the damage.

Adds three scripts against the Plex HTTP API (no SSH to odin needed):

  plex-health    read-only report — server, updates, libraries, unmatched
                 media, Butler tasks, active streams; exit 0/1/2
  poster-audit   read-only; fetches every thumb to find the 404s
  poster-repair  re-selects the locally-cached poster; --dry-run, idempotent,
                 skips items whose poster already works so manually-chosen
                 artwork is never replaced

All 283 TV Shows posters now resolve. The health check also surfaced one
unmatched movie, Battlestar Galactica the Mini Series.

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

135 lines
4.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Re-select a poster for items whose poster is missing or does not resolve.
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"
SECTION="TV Shows"
TYPE=2
DRY_RUN=0
LIMIT=0
export POSTER_ALLOW_REMOTE=0
# Global, not local to main(): an EXIT trap fires after main() returns, by which
# point a local would be out of scope and `set -u` would abort the cleanup.
WORKDIR=""
cleanup() { [[ -n "$WORKDIR" ]] && rm -rf "$WORKDIR"; return 0; }
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage: poster-repair [--dry-run] [--section <id|title>] [--type <n>]
[--limit N] [--allow-remote]
--dry-run Show what would be selected; change nothing.
--section Library section, by id or exact title. Default: "TV Shows".
--type Plex type code: 1=movie 2=show 3=season 4=episode. Default: 2.
--limit N Repair at most N items (0 = no limit).
--allow-remote Also repair items with no locally-cached poster by pulling
one from the metadata agent. Off by default: it re-downloads
from the internet and may pick a different image than the one
that was originally chosen.
--jobs N Parallel HTTP checks during the scan (default 8).
--help
What it does: for each broken item it selects the poster already sitting in
that item's metadata bundle. The image is not re-downloaded and no other
metadata is touched, so this does not overwrite manually-chosen artwork —
items that still have a working poster are skipped entirely.
Idempotent: re-running repairs nothing once every poster resolves.
EOF
}
main() {
while (( $# )); do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--section) SECTION="${2:?--section needs a value}"; shift ;;
--type) TYPE="${2:?--type needs a value}"; shift ;;
--limit) LIMIT="${2:?--limit needs a value}"; shift ;;
--allow-remote) POSTER_ALLOW_REMOTE=1 ;;
--jobs) POSTER_JOBS="${2:?--jobs needs a value}"; export POSTER_JOBS; shift ;;
--help|-h) usage; exit 0 ;;
*) plex_die "unknown argument: $1 (try --help)" ;;
esac
shift
done
plex_require_deps
plex_load_env "$COLLECTION_DIR/.env"
plex_check_auth
local section_id
section_id="$(posters_resolve_section "$SECTION")"
WORKDIR="$(mktemp -d)"
plex_log "Scanning section '${SECTION}' (id ${section_id}, type ${TYPE})..."
posters_fetch_items "$section_id" "$TYPE" "$WORKDIR/items.json"
posters_scan "$WORKDIR/items.json" | awk -F'\t' '$2!="OK"' | sort -t$'\t' -k3 > "$WORKDIR/broken.tsv"
local count
count="$(wc -l < "$WORKDIR/broken.tsv" | tr -d ' ')"
if (( count == 0 )); then
plex_log "Nothing to repair — every poster resolves."
return 0
fi
plex_log "Found ${count} item(s) needing repair."
(( DRY_RUN )) && plex_log "[dry-run] no changes will be made."
plex_log ""
local repaired=0 skipped=0 failed=0 processed=0
local rk status title candidate
while IFS=$'\t' read -r rk status title; do
if (( LIMIT > 0 && processed >= LIMIT )); then
plex_log "Reached --limit ${LIMIT}; stopping."
break
fi
processed=$((processed + 1))
candidate="$(posters_candidate "$rk")" || candidate=""
if [[ -z "$candidate" ]]; then
plex_warn "SKIP ${title} (${rk}, ${status}) — no locally-cached poster; retry with --allow-remote"
skipped=$((skipped + 1))
continue
fi
if (( DRY_RUN )); then
plex_log "[dry-run] would select for ${title} (${rk}): ${candidate:0:72}"
repaired=$((repaired + 1))
continue
fi
if ! posters_select "$rk" "$candidate"; then
plex_warn "FAIL ${title} (${rk}) — poster selection rejected"
failed=$((failed + 1))
continue
fi
if posters_verify "$rk"; then
plex_log "OK ${title} (${rk})"
repaired=$((repaired + 1))
else
plex_warn "FAIL ${title} (${rk}) — selected, but thumb still does not resolve"
failed=$((failed + 1))
fi
done < "$WORKDIR/broken.tsv"
plex_log ""
if (( DRY_RUN )); then
plex_log "Summary (dry-run): ${repaired} would be repaired, ${skipped} skipped."
else
plex_log "Summary: ${repaired} repaired, ${skipped} skipped, ${failed} failed."
fi
(( failed == 0 ))
}
main "$@"