# shellcheck shell=bash # Poster integrity helpers for the Plex library. # Sourced by bin/poster-audit and bin/poster-repair. Requires lib/plex-api.sh # to be sourced first. # # The distinction that matters here: a poster can be missing in two ways, and # they look identical in the Plex UI. # # MISSING the item has no `thumb` field at all # BROKEN the item has a `thumb` URL, but fetching it returns 404 # # BROKEN is the common one: the poster image is still present in the item's # metadata bundle, but the database no longer marks any candidate as selected, # so the thumb URL resolves to nothing. # Resolve a section by numeric id or by exact title. Echoes the id. posters_resolve_section() { local want="${1:?posters_resolve_section needs an id or title}" local sections sections="$(plex_get /library/sections)" || plex_die "could not list library sections" if [[ "$want" =~ ^[0-9]+$ ]]; then jq -e --arg k "$want" '.MediaContainer.Directory[]|select(.key==$k)|.key' <<<"$sections" -r \ || plex_die "no library section with id ${want}" return fi local matches matches="$(jq -r --arg t "$want" '[.MediaContainer.Directory[]|select(.title==$t)|.key]|join(" ")' <<<"$sections")" case "$(wc -w <<<"$matches")" in 0) plex_die "no library section titled '${want}' — have: $(jq -r '[.MediaContainer.Directory[].title]|join(", ")' <<<"$sections")" ;; 1) printf '%s\n' "$matches" ;; *) plex_die "library title '${want}' is ambiguous (ids: ${matches}) — pass the id instead" ;; esac } # Fetch every item of in
into a JSON file. # Plex type codes: 1=movie 2=show 3=season 4=episode. posters_fetch_items() { local section="${1:?}" type="${2:?}" out="${3:?}" plex_get "/library/sections/${section}/all" --get --data-urlencode "type=${type}" -o "$out" \ || plex_die "could not list items for section ${section} type ${type}" jq -e '.MediaContainer|has("Metadata") or .size==0' "$out" >/dev/null \ || plex_die "unexpected response listing section ${section} (not a library container?)" } # Emit "ratingKeySTATUStitle" for every item, checking each thumb URL # with a real HTTP request. Runs POSTER_JOBS requests in parallel. # # The 404 check is the whole point: Plex happily reports a thumb field for # items whose poster does not resolve, so a metadata-only audit reports a # clean library while the UI shows grey placeholders. posters_scan() { local items_json="${1:?}" local jobs="${POSTER_JOBS:-8}" # Items with no thumb field at all need no HTTP request. jq -r '.MediaContainer.Metadata[]?|select(has("thumb")|not)|"\(.ratingKey)\tMISSING\t\(.title)"' "$items_json" # Each line is passed to the worker as a positional ARGUMENT ($1), never # substituted into the command text. `xargs -I{}` would splice the title # straight into the shell string, so a title containing an apostrophe or a # quote — "That's", 'MINUTE,' — becomes a syntax error and that item is # silently lost. Real titles at episode scale are full of them. jq -r '.MediaContainer.Metadata[]?|select(has("thumb"))|"\(.ratingKey)\t\(.thumb)\t\(.title)"' "$items_json" \ | PLEX_URL="$PLEX_URL" PLEX_TOKEN="$PLEX_TOKEN" PLEX_INSECURE="${PLEX_INSECURE:-0}" \ xargs -P "$jobs" -d '\n' -n 1 bash -c ' IFS=" " read -r rk thumb title <<<"$1" flags=(--silent --show-error --max-time 30) [[ "${PLEX_INSECURE:-0}" == "1" ]] && flags+=(--insecure) # Status alone is not proof of a poster: Plex answers some paths with # HTTP 200 and an XML or HTML body. Require an image content-type. read -r code ctype < <(curl "${flags[@]}" -o /dev/null \ -w "%{http_code} %{content_type}" \ -H "X-Plex-Token: ${PLEX_TOKEN}" "${PLEX_URL%/}${thumb}") if [[ "$code" == "200" && "$ctype" == image/* ]]; then printf "%s\tOK\t%s\n" "$rk" "$title" else printf "%s\tBROKEN\t%s\n" "$rk" "$title" fi' _ } # Echo the best poster candidate URL for an item, or nothing if there is none. # # Prefers a metadata:// candidate — that image is already downloaded into the # item's bundle, so selecting it is instant and needs no internet round trip. # Falls back to a remote provider URL only when POSTER_ALLOW_REMOTE=1, since # that re-downloads from the agent and can fail or pick a different image. posters_candidate() { local rk="${1:?posters_candidate needs a ratingKey}" local list list="$(plex_get "/library/metadata/${rk}/posters")" || return 1 local local_candidate local_candidate="$(jq -r '[.MediaContainer.Metadata[]?|select(.ratingKey|startswith("metadata://"))][0].ratingKey // empty' <<<"$list")" if [[ -n "$local_candidate" ]]; then printf '%s\n' "$local_candidate" return 0 fi if [[ "${POSTER_ALLOW_REMOTE:-0}" == "1" ]]; then jq -r '[.MediaContainer.Metadata[]?|select(.ratingKey|startswith("http"))][0].ratingKey // empty' <<<"$list" return 0 fi return 0 } # How many candidates are currently marked selected. 0 is the broken state. posters_selected_count() { local rk="${1:?}" plex_get "/library/metadata/${rk}/posters" \ | jq -r '[.MediaContainer.Metadata[]?|select(.selected==true)]|length' } # Select as the poster for . Returns non-zero on HTTP failure. posters_select() { local rk="${1:?}" url="${2:?}" local code code="$(plex_put "/library/metadata/${rk}/poster" -o /dev/null -w '%{http_code}' \ --get --data-urlencode "url=${url}")" [[ "$code" == "200" ]] || { plex_warn " PUT poster for ${rk} returned HTTP ${code}"; return 1; } } # Re-read the item and confirm its thumb now actually resolves. posters_verify() { local rk="${1:?}" local thumb thumb="$(plex_get "/library/metadata/${rk}" | jq -r '.MediaContainer.Metadata[0].thumb // empty')" [[ -n "$thumb" ]] || return 1 local flags; _plex_curl_flags flags local code code="$(curl "${flags[@]}" -o /dev/null -w '%{http_code}' \ -H "X-Plex-Token: ${PLEX_TOKEN}" "${PLEX_URL%/}${thumb}")" [[ "$code" == "200" ]] }