Add Radarr/Movies coverage to arr_corruption_scan.sh

Extends the Sonarr-only corruption scan into a generic per-arr loop (same
pattern as arr_full_rescan.sh) instead of a second script, since the
scan/strike/remediate logic is identical and only the API shape differs.
Radarr's moviefile list is fetched batched to include secondary/alternate-
edition files, not just each movie's primary file.

Also fixes two bugs found while testing: build_arr_path_map()'s internal
non-local `for key in ...` loop was clobbering the per-arr API key
variable, and arr_api()'s error output (stdout, not stderr) was getting
appended into the batch fetch file on any single failed call, corrupting
jq's parse of the whole batch and silently zeroing out that arr's results.
This commit is contained in:
Gmer4Lfe
2026-07-21 18:05:26 -04:00
parent b2d6e1bcfd
commit 5e92d9920a
+245 -106
View File
@@ -5,9 +5,9 @@
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Scans Sonarr's tracked video files for corrupt headers (ffprobe-based, same detection
# method as the third-party Healarr tool) and, in --remediate mode, deletes the bad file
# from Sonarr and explicitly triggers an EpisodeSearch to replace it.
# Scans Sonarr's and Radarr's tracked video files for corrupt headers (ffprobe-based, same
# detection method as the third-party Healarr tool) and, in --remediate mode, deletes the bad
# file from the owning arr and explicitly triggers a search to replace it.
#
# Built after Healarr crashed mid-scan on a genuine Go concurrency bug (unsynchronized
# map access when multiple corruption events land at once — confirmed via its own crash
@@ -16,25 +16,35 @@
# strictly sequential, so the race condition that killed Healarr can't happen — there's
# nothing running concurrently to race.
#
# Sonarr-only originally (2026-07-18/19); Radarr/Movies coverage added 2026-07-21 as a second
# arr in the same per-file scan/strike/remediate loop, not a separate script — the detection,
# strike, and state-file logic is identical, only the API shape (episodefile vs moviefile,
# EpisodeSearch vs MoviesSearch) differs. Radarr's moviefile list is fetched batched
# (movieId=... query params, BATCH_SIZE at a time) rather than off the movie list's embedded
# .movieFile alone — Radarr supports a second tracked file per movie (alternate editions/
# extras) that never shows up there, same gap radarr_cleanup.sh hit and fixed 2026-07-19;
# reusing that batched-fetch shape here instead of the simpler single-file read so a
# corruption scan doesn't silently skip every alternate edition in the library.
#
# ==============================================================================================
# WHY A SEPARATE CONTAINER FOR FFPROBE
# ==============================================================================================
#
# Neither Sonarr nor Radarr bundle ffprobe. ffprobe runs via `docker exec` into a
# different container that does — confirmed live 2026-07-18:
# Jellyfin — working ffprobe, but only mounts Tv_Shows + Movies
# Emby — mounts everything (kids/anime shares too), but its bundled
# ffprobe binary is broken (2017-dated, fails to exec — likely a
# missing dynamic linker dependency, not something to fix here)
# Jellyfin is what's configured (HOST*_FFPROBE_CONTAINER) — matches the current TV-share
# testing scope. Kids/anime coverage needs a working ffprobe source with those mounts
# before this script can cover those shares; not solved yet.
# Jellyfin — working ffprobe, mounts every share Emby does (Tv_Shows, Movies, kids/
# anime shares, standup) as of 2026-07-18
# Emby — mounts everything too, but its bundled ffprobe binary is broken
# (2017-dated, fails to exec — likely a missing dynamic linker
# dependency, not something to fix here)
# Jellyfin is what's configured (HOST*_FFPROBE_CONTAINER).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# One file at a time, in this order per file:
# Runs Sonarr then Radarr, sequentially, never parallel — one arr failing/unconfigured never
# blocks the other. Within each arr, one file at a time, in this order per file:
# 1. Skip if unchanged (mtime+size) since the last time it verified clean — state file
# avoids re-probing the entire library every run, which would take far too long at
# this library size (90k+ tracked files).
@@ -42,51 +52,55 @@
# Anything else = corrupt (same signature as Healarr: "Invalid data found when
# processing input", EBML header errors, etc.)
# 3. Report-only by default. --remediate additionally:
# a. DELETE the specific episodefile record via Sonarr's API
# a. DELETE the specific episodefile/moviefile record via the arr's API
# b. Verify hasFile flipped false (never trust the DELETE response alone)
# c. Explicitly trigger EpisodeSearch for that episode — this is deliberate, not
# left to Sonarr's own background missing-search cycle, because that cycle
# skips unmonitored episodes entirely. An explicit EpisodeSearch call does not
# have that restriction (confirmed live: two unmonitored episodes Healarr
# healed both still got successfully re-grabbed via this exact same kind of
# search call, logged in Sonarr's history as "UserInvokedSearch").
# c. Explicitly trigger EpisodeSearch/MoviesSearch for that episode/movie — this is
# deliberate, not left to the arr's own background missing-search cycle, because
# that cycle skips unmonitored items entirely. An explicit search call does not
# have that restriction (confirmed live: two unmonitored episodes Healarr healed
# both still got successfully re-grabbed via this exact same kind of search call,
# logged in Sonarr's history as "UserInvokedSearch").
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# SONARR_URL / SONARR_API_KEY / SONARR_PATH_MAP — existing, aliased by detect_hosts()
# SONARR_URL / SONARR_API_KEY, RADARR_URL / RADARR_API_KEY — existing, aliased by
# detect_hosts(). Either arr missing its URL/key is skipped, not fatal.
# HOST*_FFPROBE_CONTAINER — container name with a working ffprobe binary
# HOST*_FFPROBE_BIN — full path to that binary inside the container
# HOST*_FFPROBE_PATH_MAP — host path prefix → that container's internal path prefix
# (separate from the arr's own path map — the ffprobe
# (separate from the arrs' own path maps — the ffprobe
# container almost certainly mounts shares differently)
#
# master.conf
# CORRUPTION_SCAN_STATE_FILE — path to the clean-file skip-cache (default in DATA_DIR)
# CORRUPTION_SCAN_STATE_FILE — path to the clean-file skip-cache (default in DATA_DIR),
# shared across both arrs — keyed by host path, which never
# collides between a Sonarr and a Radarr share
# CORRUPTION_SCAN_STRIKES_FILE — path to the consecutive-corrupt-detection counter (default
# in DATA_DIR), keyed by host path
# CORRUPTION_SCAN_STRIKE_LIMIT — consecutive corrupt detections required before --remediate
# acts on a file (default 2) — guards against a one-off
# ffprobe hiccup (mid-write file, NFS blip) triggering an
# unnecessary delete+re-search. Resets on a clean re-probe.
# SONARR_VERSION_MAJOR — reused from sonarr_cleanup.sh for the API version check
# SONARR_VERSION_MAJOR / RADARR_VERSION_MAJOR — reused from sonarr_cleanup.sh/
# radarr_cleanup.sh for the API version check
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# arr_corruption_scan.sh — report-only, scans everything not yet
# verified clean
# verified clean (Sonarr then Radarr)
# arr_corruption_scan.sh --remediate — delete + re-search on every corrupt file found
# arr_corruption_scan.sh --limit=50 — cap this run to 50 newly-probed files (state
# file makes repeat runs cheap regardless, but
# useful for a bounded first test)
# arr_corruption_scan.sh --limit=50 — cap EACH arr to 50 newly-probed files this run
# (state file makes repeat runs cheap regardless,
# but useful for a bounded first test)
# arr_corruption_scan.sh --log — verbose (prints every clean file too)
# arr_corruption_scan.sh --status — show config and exit
# arr_corruption_scan.sh --filter=Becker — only consider paths containing this substring
# (testing/targeting a specific show; state
# (testing/targeting a specific show/movie; state
# file and everything else behaves normally)
#
# ==============================================================================================
@@ -120,7 +134,7 @@ if [[ "$EUID" -ne 0 ]]; then
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for Sonarr API calls"
error "curl not found — required for arr API calls"
exit 1
fi
@@ -141,13 +155,6 @@ trap "_release_all_locks; rm -rf $TMP_DIR" EXIT
detect_hosts
if [[ -z "${SONARR_URL:-}" ]] || [[ -z "${SONARR_API_KEY:-}" ]]; then
info "Sonarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
build_arr_path_map "SONARR"
# FFPROBE_* aren't part of the shared arr alias set in detect_hosts() — resolve them here,
# same eval-based pattern build_arr_path_map() uses for the associative array.
FFPROBE_CONTAINER_VAR="${MY_ID}_FFPROBE_CONTAINER"
@@ -201,34 +208,40 @@ reset_scan_strikes() {
[[ -n "$current" && "$current" != "0" ]] && set_scan_strikes "$1" 0
}
# Per-arr API shape differences — everything else in the scan/strike/remediate loop below is
# identical between Sonarr and Radarr.
declare -A ARR_FILE_ENDPOINT=( [sonarr]="episodefile" [radarr]="moviefile" )
declare -A ARR_PARENT_ENDPOINT=( [sonarr]="episode" [radarr]="movie" )
declare -A ARR_SEARCH_COMMAND=( [sonarr]="EpisodeSearch" [radarr]="MoviesSearch" )
declare -A ARR_SEARCH_ID_FIELD=( [sonarr]="episodeIds" [radarr]="movieIds" )
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Sonarr URL: $SONARR_URL"
for arr in sonarr radarr; do
url_var="${arr^^}_URL"
echo "$ICON_GEAR ${arr^} URL: ${!url_var:-not configured}"
done
echo "$ICON_GEAR FFprobe container: $FFPROBE_CONTAINER"
echo "$ICON_GEAR FFprobe binary: $FFPROBE_BIN"
echo "$ICON_GEAR FFprobe path map: ${#FFPROBE_PATH_MAP[@]} entries"
echo "$ICON_GEAR State file: $CORRUPTION_SCAN_STATE_FILE"
echo "$ICON_GEAR Strike limit: $CORRUPTION_SCAN_STRIKE_LIMIT"
echo "$ICON_GEAR Remediate: $REMEDIATE"
echo "$ICON_GEAR Scan limit: ${SCAN_LIMIT:-unlimited}"
echo "$ICON_GEAR Scan limit: ${SCAN_LIMIT:-unlimited} (per arr)"
echo "$ICON_GEAR Path filter: ${PATH_FILTER:-none}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo " $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL"
[[ "$REMEDIATE" == true ]] && warn "REMEDIATE MODE — corrupt files will be deleted and re-searched" \
|| info "Report-only — pass --remediate to act"
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
check_container_health "$FFPROBE_CONTAINER" 15 "Corruption Scan"
check_container_health "Sonarr" 15 "Corruption Scan"
check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "Sonarr" || exit 1
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
@@ -261,6 +274,53 @@ probe_file() {
fi
}
# Appends one arr_api() call's output to a batch file, but ONLY on success. arr_api() prints
# its own error message via error() (a plain `echo`, i.e. stdout, not stderr) on any non-200
# response — appending its raw output unconditionally means a single failed batch call (one
# bad seriesId/movieId batch out of hundreds) mixes a plain-text error line into what's
# otherwise a stream of valid JSON arrays, and `jq -s` then fails to parse the WHOLE file,
# turning one bad batch into zero usable files for the entire arr. Confirmed live 2026-07-21:
# Sonarr seriesId=650 returned HTTP 404 (stale/deleted series reference) mid-walk, and that
# single 404's error text corrupted the full 138MB/1177-series concatenated batch, silently
# zeroing out the whole Sonarr scan for that run. Capturing output first and gating the
# append on the actual exit code isolates one bad call to just that call.
_arr_api_append_on_success() {
local out
out=$(arr_api "$1" "$2" "$3" "$4" "$5" 2>/dev/null)
[[ $? -eq 0 ]] && echo "$out" >> "$6"
}
# Fetches every movie's file(s) via Radarr's moviefile endpoint, batched (movieId=X repeated
# query param, BATCH_SIZE at a time — a single whole-library request 414s, confirmed live by
# radarr_cleanup.sh 2026-07-19). Deliberately not just the movie list's embedded .movieFile —
# that only ever has the primary file, missing Radarr's second-tracked-file-per-movie feature
# (alternate editions/extras). Echoes the raw moviefile JSON array (id/movieId/path per item).
fetch_radarr_items() {
local movies_now
movies_now=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr" 2>/dev/null)
local _ids=() _id _qs="" _batch_count=0
local BATCH_SIZE=200 # 250 confirmed working live 2026-07-19 by radarr_cleanup.sh, margin kept
mapfile -t _ids < <(echo "$movies_now" | jq -r '.[] | select(.hasFile==true) | .id' 2>/dev/null)
local all_tmp; all_tmp=$(mktemp)
for _id in "${_ids[@]}"; do
_qs+="movieId=${_id}&"
(( _batch_count++ ))
if [[ "$_batch_count" -ge "$BATCH_SIZE" ]]; then
_arr_api_append_on_success "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" "$all_tmp"
_qs=""
_batch_count=0
fi
done
if [[ -n "$_qs" ]]; then
_arr_api_append_on_success "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" "$all_tmp"
fi
jq -s 'add // []' "$all_tmp" 2>/dev/null
rm -f "$all_tmp"
}
# ==============================================================================================
# ━━━ Load clean-file state (skip cache) ━━━
# ==============================================================================================
@@ -271,63 +331,121 @@ done < "$CORRUPTION_SCAN_STATE_FILE"
unset _s_path _s_stamp
info "Loaded ${#CLEAN_STATE[@]} previously-verified-clean entries"
# ==============================================================================================
# ━━━ Fetch Sonarr's tracked episode files ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Fetching Sonarr Tracked Files ━━━"
# Merges a fresh-clean-stamps temp file into the persistent state file, newest wins per path —
# reading fresh entries first (before the old base file) means the first occurrence tac/awk
# keeps is always the newest one for any path re-verified this run. Called once per arr
# (immediately after that arr's scan, not batched to the very end of the whole script) so a
# hard-exit partway through the NEXT arr — check_container_health()/check_arr_version() both
# exit 1 directly on a real failure, not just return — can never wipe out the previous arr's
# already-computed clean state for this run.
merge_clean_state() {
local fresh_tmp="$1" state_tmp
state_tmp=$(mktemp)
cat "$fresh_tmp" "$CORRUPTION_SCAN_STATE_FILE" | awk -F'\t' '!seen[$1]++' | sort > "$state_tmp"
mv "$state_tmp" "$CORRUPTION_SCAN_STATE_FILE"
}
# Prefer the shared per-episode-file cache written by sonarr_cleanup.sh (has id/episodeId/
# seriesId/path already) — falls back to a live per-series walk only on a genuine miss.
EPISODE_FILES=$(arr_get_cached_items "sonarr" 14400)
if [[ -z "$EPISODE_FILES" || "$EPISODE_FILES" == "null" ]]; then
TOTAL_SCANNED=0
TOTAL_CORRUPT=0
TOTAL_REMEDIATED=0
TOTAL_REMEDIATE_FAILED=0
declare -A ARR_SCANNED ARR_SKIPPED_CACHED ARR_SKIPPED_UNMAPPED ARR_CORRUPT ARR_STRIKE_HELD ARR_REMEDIATED ARR_REMEDIATE_FAILED
for arr in sonarr radarr; do
url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY"
# Named arr_url/arr_key, not url/key — build_arr_path_map() below uses a non-local
# `for key in ...` loop internally (iterating FFPROBE/path-map prefixes) and would
# silently clobber a plain $key with its last loop value otherwise. Confirmed live
# 2026-07-21: this exact collision fed a path-map prefix ("/ext-anime-shows") to Sonarr's
# API calls as the X-Api-Key header instead of the real key, making every Sonarr call
# this loop made fail with 401 while looking like a connectivity problem.
arr_url="${!url_var:-}"; arr_key="${!key_var:-}"
if [[ -z "$arr_url" || -z "$arr_key" ]]; then
info "${arr^} not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
continue
fi
echo ""
echo " ${arr^}$arr_url"
build_arr_path_map "${arr^^}"
check_container_health "${arr^}" 15 "Corruption Scan"
ver_var="${arr^^}_VERSION_MAJOR"
check_arr_version "$arr_url" "$arr_key" "v3" "${!ver_var}" "${arr^}" || {
warn "${arr^} version check failed — skipping this arr"
continue
}
# ──────────────────────────────────────────────────────────────────────────────────────
# Fetch tracked files, normalized to {path, file_id, parent_id, title} regardless of arr —
# everything past this point is arr-agnostic.
# ──────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SYNC Fetching ${arr^} Tracked Files ━━━"
if [[ "$arr" == "sonarr" ]]; then
# Prefer the shared per-episode-file cache written by sonarr_cleanup.sh (has
# id/episodeId/seriesId/path already) — falls back to a live per-series walk only on
# a genuine miss.
RAW_ITEMS=$(arr_get_cached_items "sonarr" 14400)
if [[ -z "$RAW_ITEMS" || "$RAW_ITEMS" == "null" ]]; then
info "No fresh cached episode-file data — fetching live (this is the slow path)"
SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || {
error "Failed to fetch series from Sonarr"
exit 1
SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$arr_url" "$arr_key" "v3") || {
error "Failed to fetch series from Sonarr — skipping this arr"
continue
}
SERIES_IDS=$(echo "$SERIES_RESPONSE" | jq -r '.[].id')
all_tmp=$(mktemp)
while IFS= read -r sid; do
[[ -z "$sid" ]] && continue
arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "episodefile?seriesId=${sid}" "Sonarr" 2>/dev/null \
>> "$all_tmp"
_arr_api_append_on_success "$arr_url" "$arr_key" "v3" "episodefile?seriesId=${sid}" "Sonarr" "$all_tmp"
done <<< "$SERIES_IDS"
EPISODE_FILES=$(jq -s 'add // []' "$all_tmp" 2>/dev/null)
RAW_ITEMS=$(jq -s 'add // []' "$all_tmp" 2>/dev/null)
rm -f "$all_tmp"
arr_item_cache_write "sonarr" "$EPISODE_FILES"
fi
arr_item_cache_write "sonarr" "$RAW_ITEMS"
fi
ITEMS=$(echo "$RAW_ITEMS" | jq -c \
'[.[] | {path, file_id:.id, parent_id:.episodeId, title:(.sceneName // .relativePath // .path)}]')
else
RAW_ITEMS=$(arr_get_cached_items "radarr" 14400)
if [[ -z "$RAW_ITEMS" || "$RAW_ITEMS" == "null" ]]; then
info "No fresh cached movie-file data — fetching live (this is the slow path)"
RAW_ITEMS=$(fetch_radarr_items)
arr_item_cache_write "radarr" "$RAW_ITEMS"
fi
ITEMS=$(echo "$RAW_ITEMS" | jq -c \
'[.[] | {path, file_id:.id, parent_id:.movieId, title:(.sceneName // .relativePath // .path)}]')
fi
FILE_COUNT=$(echo "$EPISODE_FILES" | jq 'length' 2>/dev/null)
if [[ -z "$FILE_COUNT" || "$FILE_COUNT" -eq 0 ]]; then
error "0 tracked episode files — aborting"
exit 1
fi
info "$FILE_COUNT tracked episode files"
ITEM_COUNT=$(echo "$ITEMS" | jq 'length' 2>/dev/null)
if [[ -z "$ITEM_COUNT" || "$ITEM_COUNT" -eq 0 ]]; then
error "0 tracked files for ${arr^} — skipping this arr"
continue
fi
info "$ITEM_COUNT tracked files"
# ==============================================================================================
# ━━━ Scan ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning ━━━"
# ──────────────────────────────────────────────────────────────────────────────────────
# Scan
# ──────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CLEAN Scanning ━━━"
SCANNED=0
SKIPPED_CACHED=0
SKIPPED_UNMAPPED=0
CORRUPT_COUNT=0
STRIKE_HELD=0
REMEDIATED=0
REMEDIATE_FAILED=0
SCANNED=0
SKIPPED_CACHED=0
SKIPPED_UNMAPPED=0
CORRUPT_COUNT=0
STRIKE_HELD=0
REMEDIATED=0
REMEDIATE_FAILED=0
# Fresh clean-file stamps are appended here as they're found (O(1) per file) rather than
# rewritten into the growing state file on every hit — at this library's scale (90k+ files)
# a per-file rewrite-the-whole-file approach would be O(n²) and far too slow.
FRESH_CLEAN_TMP=$(mktemp)
FRESH_CLEAN_TMP=$(mktemp)
while IFS= read -r item; do
while IFS= read -r item; do
api_path=$(echo "$item" | jq -r '.path')
episode_file_id=$(echo "$item" | jq -r '.id')
series_id=$(echo "$item" | jq -r '.seriesId')
file_id=$(echo "$item" | jq -r '.file_id')
parent_id=$(echo "$item" | jq -r '.parent_id')
host_path=$(translate_path "$api_path")
[[ -f "$host_path" ]] || continue
@@ -380,12 +498,11 @@ while IFS= read -r item; do
fi
reset_scan_strikes "$host_path"
episode_id=$(echo "$item" | jq -r '.episodeId // empty')
title=$(echo "$item" | jq -r '.sceneName // .relativePath // .path')
title=$(echo "$item" | jq -r '.title')
http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
--max-time 15 -H "X-Api-Key: $SONARR_API_KEY" \
"${SONARR_URL}/api/v3/episodefile/${episode_file_id}" 2>/dev/null)
--max-time 15 -H "X-Api-Key: $arr_key" \
"${arr_url}/api/v3/${ARR_FILE_ENDPOINT[$arr]}/${file_id}" 2>/dev/null)
if [[ "$http_code" != "200" ]]; then
error "$title — delete failed (HTTP $http_code)"
@@ -394,7 +511,7 @@ while IFS= read -r item; do
fi
sleep 2
verify_hasfile=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "episode/${episode_id}" "Sonarr" 2>/dev/null \
verify_hasfile=$(arr_api "$arr_url" "$arr_key" "v3" "${ARR_PARENT_ENDPOINT[$arr]}/${parent_id}" "${arr^}" 2>/dev/null \
| jq -r '.hasFile // "unknown"')
if [[ "$verify_hasfile" != "false" ]]; then
@@ -404,9 +521,9 @@ while IFS= read -r item; do
fi
search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
--max-time 30 -H "X-Api-Key: $SONARR_API_KEY" -H "Content-Type: application/json" \
-d "{\"name\":\"EpisodeSearch\",\"episodeIds\":[${episode_id}]}" \
"${SONARR_URL}/api/v3/command" 2>/dev/null)
--max-time 30 -H "X-Api-Key: $arr_key" -H "Content-Type: application/json" \
-d "{\"name\":\"${ARR_SEARCH_COMMAND[$arr]}\",\"${ARR_SEARCH_ID_FIELD[$arr]}\":[${parent_id}]}" \
"${arr_url}/api/v3/command" 2>/dev/null)
if [[ "$search_code" == "200" || "$search_code" == "201" ]]; then
echo " $ICON_SUCCESS $title — deleted, verified, search triggered"
@@ -415,15 +532,24 @@ while IFS= read -r item; do
warn " $title — deleted and verified, but search trigger returned HTTP $search_code"
(( REMEDIATE_FAILED++ ))
fi
done < <(echo "$EPISODE_FILES" | jq -c '.[]')
done < <(echo "$ITEMS" | jq -c '.[]')
# Merge fresh clean-file stamps with the existing state, newest wins per path — reading
# fresh entries first (before the old base file) means the first occurrence tac/awk keeps
# is always the newest one for any path re-verified this run.
STATE_TMP=$(mktemp)
cat "$FRESH_CLEAN_TMP" "$CORRUPTION_SCAN_STATE_FILE" | awk -F'\t' '!seen[$1]++' | sort > "$STATE_TMP"
mv "$STATE_TMP" "$CORRUPTION_SCAN_STATE_FILE"
rm -f "$FRESH_CLEAN_TMP"
merge_clean_state "$FRESH_CLEAN_TMP"
rm -f "$FRESH_CLEAN_TMP"
ARR_SCANNED[$arr]=$SCANNED
ARR_SKIPPED_CACHED[$arr]=$SKIPPED_CACHED
ARR_SKIPPED_UNMAPPED[$arr]=$SKIPPED_UNMAPPED
ARR_CORRUPT[$arr]=$CORRUPT_COUNT
ARR_STRIKE_HELD[$arr]=$STRIKE_HELD
ARR_REMEDIATED[$arr]=$REMEDIATED
ARR_REMEDIATE_FAILED[$arr]=$REMEDIATE_FAILED
(( TOTAL_SCANNED += SCANNED ))
(( TOTAL_CORRUPT += CORRUPT_COUNT ))
(( TOTAL_REMEDIATED += REMEDIATED ))
(( TOTAL_REMEDIATE_FAILED += REMEDIATE_FAILED ))
done
# ==============================================================================================
# ━━━ Summary ━━━
@@ -431,14 +557,27 @@ rm -f "$FRESH_CLEAN_TMP"
echo ""
echo "━━━━━ $ICON_SUMMARY CORRUPTION SCAN SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Newly scanned: $SCANNED"
echo "$ICON_SUCCESS Skipped (cached): $SKIPPED_CACHED"
echo "$ICON_WARN Skipped (unmapped): $SKIPPED_UNMAPPED"
echo "$ICON_ERROR Corrupt found: $CORRUPT_COUNT"
for arr in sonarr radarr; do
[[ -z "${ARR_SCANNED[$arr]:-}" ]] && continue
echo ""
echo " ${arr^}:"
echo " $ICON_SYNC Newly scanned: ${ARR_SCANNED[$arr]}"
echo " $ICON_SUCCESS Skipped (cached): ${ARR_SKIPPED_CACHED[$arr]}"
echo " $ICON_WARN Skipped (unmapped): ${ARR_SKIPPED_UNMAPPED[$arr]}"
echo " $ICON_ERROR Corrupt found: ${ARR_CORRUPT[$arr]}"
if [[ "$REMEDIATE" == true ]]; then
echo " $ICON_WARN Held (strikes): ${ARR_STRIKE_HELD[$arr]}"
echo " $ICON_SUCCESS Remediated: ${ARR_REMEDIATED[$arr]}"
echo " $ICON_ERROR Remediation failed: ${ARR_REMEDIATE_FAILED[$arr]}"
fi
done
echo ""
echo " Total:"
echo " $ICON_SYNC Newly scanned: $TOTAL_SCANNED"
echo " $ICON_ERROR Corrupt found: $TOTAL_CORRUPT"
if [[ "$REMEDIATE" == true ]]; then
echo "$ICON_WARN Held (strikes): $STRIKE_HELD"
echo "$ICON_SUCCESS Remediated: $REMEDIATED"
echo "$ICON_ERROR Remediation failed: $REMEDIATE_FAILED"
echo " $ICON_SUCCESS Remediated: $TOTAL_REMEDIATED"
echo " $ICON_ERROR Remediation failed: $TOTAL_REMEDIATE_FAILED"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"