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