posters_scan passed each line to the worker with `xargs -I{}`, which splices
the text straight into the shell command. Any title containing an apostrophe
or a quote became a shell syntax error and that item was dropped — silently,
since the error went to stderr while the audit counted only what came back.
No show title happened to trigger it, so this survived the show-level work.
The first episode-level scan hit it immediately: titles like "That's a shirt?"
and "WAIT A MINUTE, ..." produced `unexpected EOF while looking for matching
quote`, which means that episode run reported nothing trustworthy.
Lines are now passed as a positional argument, so the shell never parses their
content. Regression-tested with titles containing apostrophes, double quotes,
commas, backticks and $(...) — all handled, and the $(...) case confirmed not
to execute.
Also tightens the check itself: a poster is OK only if the response is 200
*and* an image content-type. Plex answers some paths with 200 and an XML body,
which the status-only check counted as a working poster.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
136 lines
6.0 KiB
Bash
136 lines
6.0 KiB
Bash
# 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 <type> in <section> 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 "ratingKey<TAB>STATUS<TAB>title" 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 <url> as the poster for <ratingKey>. 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" ]]
|
|
}
|