#!/bin/bash # ============================================================================================== # ============================ Arr Corruption Scan ============================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # 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 # log, not fixable from our side). The core idea (scan → delete → re-search) isn't hard to # replicate; the fix here is architectural: this script processes one file at a time, # 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, 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 # ============================================================================================== # # 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). # 2. ffprobe via `docker exec` into FFPROBE_CONTAINER. Empty stderr + exit 0 = clean. # 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/moviefile record via the arr's API # b. Verify hasFile flipped false (never trust the DELETE response alone) # 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"). # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Detect Always, Act Only on Request # A bare run probes and reports. Deleting a file the arr believes it has is a # destructive act, so it requires --remediate explicitly. The scan can be scheduled # weekly and read without any risk of it removing media on its own. # # One Probe Result Is Not Evidence # ffprobe can fail for reasons that have nothing to do with the file — a mid-write # import, an NFS blip, a container restart. Corruption must be observed # CORRUPTION_SCAN_STRIKE_LIMIT times consecutively before remediation acts, and a # single clean re-probe resets the counter. # # Skip-Cache Over Re-Probing # At 90k+ tracked files a full re-probe every run is not viable. Files unchanged by # mtime and size since they last verified clean are skipped, so each run spends its # time on what actually changed rather than re-proving the library from scratch. # # Delete the Record, Let the Arr Re-Acquire # Remediation removes the file record and explicitly triggers a search. The arr is # left to obtain a good copy through its normal path — this script never tries to # repair a file in place. # # Explicit Search, Not the Background Cycle # The re-search is triggered directly rather than left to the arr's own missing-search # cycle, because that cycle skips unmonitored items entirely and would silently leave # an unmonitored corrupt file deleted and never replaced. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Root Enforcement # docker exec into the ffprobe container requires root. # # Lock Acquisition # acquire_lock prevents overlapping runs. Two instances would both probe and could # both count a strike against the same file, reaching the limit in half the intended # number of observations. # # Host Detection # detect_hosts() aliases the arr URLs, API keys and HOST*_FFPROBE_CONTAINER. # # jq Dependency Check # Fails fast if jq is missing — the tracked-file lists and every hasFile verification # are parsed with it. # # Report-Only Default # Nothing is deleted without --remediate. # # ffprobe Configuration Check # Exits cleanly if FFPROBE_CONTAINER / FFPROBE_BIN are unconfigured for this host. # # ffprobe Container Health Check # check_container_health() verifies the container is running and healthy before any # probing. Every probe is a docker exec into it — if it is stopped or unhealthy every # exec fails, every file reads as corrupt, and two consecutive runs would clear the # strike limit and hand --remediate the whole library to delete. # # API Reachability + Version Gate # check_api then check_arr_version per arr. A version mismatch skips that arr rather # than issuing deletes against an API whose file-record endpoints may have moved. # # Per-Arr Isolation # Sonarr and Radarr run sequentially, and one failing, unconfigured or version- # mismatched arr never blocks the other. # # Unmapped Path Skip # Files whose arr-side path cannot be mapped into the ffprobe container's mount # namespace are skipped and counted, never probed through a wrong path and never # treated as corrupt because the probe could not see them. # # Strike Threshold # CORRUPTION_SCAN_STRIKE_LIMIT consecutive corrupt detections are required before # --remediate deletes anything. A transient ffprobe failure cannot trigger a delete, # and a clean re-probe clears the counter. # # Post-Delete Verification # The DELETE response is never trusted. hasFile is re-checked and must have flipped # false before the re-search is issued, so a failed delete never leaves the arr # searching for something it still believes it has. # # Targeted Deletion # Only the specific episodefile/moviefile record for the corrupt file is removed — # never the series, movie, or any sibling file. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf # 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 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), # 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 / 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 (Sonarr then Radarr) # arr_corruption_scan.sh --remediate — delete + re-search on every corrupt file found # 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/movie; state # file and everything else behaves normally) # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" # --remediate / --limit are script-local, not recognized by parse_args — check raw args # before they get filtered. REMEDIATE=false SCAN_LIMIT=0 PATH_FILTER="" for _arg in "$@"; do case "$_arg" in --remediate) REMEDIATE=true ;; --limit=*) SCAN_LIMIT="${_arg#*=}" ;; --filter=*) PATH_FILTER="${_arg#*=}" ;; esac done unset _arg parse_args "$@" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi if ! command -v curl >/dev/null 2>&1; then error "curl not found — required for arr API calls" exit 1 fi if ! command -v jq >/dev/null 2>&1; then error "jq not found — required for JSON parsing" exit 1 fi if ! command -v docker &>/dev/null; then error "Docker command not found" exit 1 fi acquire_lock "wait" TMP_DIR="/tmp/arr_corruption_scan_$$" mkdir -p "$TMP_DIR" trap "_release_all_locks; rm -rf $TMP_DIR" EXIT detect_hosts # 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" FFPROBE_CONTAINER="${!FFPROBE_CONTAINER_VAR:-}" FFPROBE_BIN_VAR="${MY_ID}_FFPROBE_BIN" FFPROBE_BIN="${!FFPROBE_BIN_VAR:-}" declare -A FFPROBE_PATH_MAP=() _fp_map_var="${MY_ID}_FFPROBE_PATH_MAP" eval "for key in \"\${!${_fp_map_var}[@]}\"; do FFPROBE_PATH_MAP[\"\$key\"]=\"\${${_fp_map_var}[\$key]}\" done" unset _fp_map_var if [[ -z "$FFPROBE_CONTAINER" || -z "$FFPROBE_BIN" ]]; then error "FFPROBE_CONTAINER/FFPROBE_BIN not configured on $MY_ID — skipping" exit 0 fi # Every probe is a docker exec into this container. If it is stopped or unhealthy, every # exec fails, every file reads as corrupt, and two such runs would clear the strike limit # and hand --remediate an entire library to delete. Abort before probing anything. check_container_health "$FFPROBE_CONTAINER" "${DOCKER_TIMEOUT:-30}" "Arr Corruption Scan" CORRUPTION_SCAN_STATE_FILE="${CORRUPTION_SCAN_STATE_FILE:-$DATA_DIR/corruption_scan_state.tsv}" mkdir -p "$(dirname "$CORRUPTION_SCAN_STATE_FILE")" touch "$CORRUPTION_SCAN_STATE_FILE" CORRUPTION_SCAN_STRIKES_FILE="${CORRUPTION_SCAN_STRIKES_FILE:-$DATA_DIR/corruption_scan_strikes.tsv}" CORRUPTION_SCAN_STRIKE_LIMIT="${CORRUPTION_SCAN_STRIKE_LIMIT:-2}" CORRUPTION_SCAN_MAX_CORRUPT_PCT="${CORRUPTION_SCAN_MAX_CORRUPT_PCT:-10}" CORRUPTION_SCAN_MAX_CONSECUTIVE="${CORRUPTION_SCAN_MAX_CONSECUTIVE:-15}" CORRUPTION_SCAN_GUARD_MIN_SCANNED="${CORRUPTION_SCAN_GUARD_MIN_SCANNED:-20}" mkdir -p "$(dirname "$CORRUPTION_SCAN_STRIKES_FILE")" touch "$CORRUPTION_SCAN_STRIKES_FILE" # Thin wrappers around common.sh's wd_state_get/wd_state_set — same shape as # stability_watchdog.sh's get_strikes/set_strikes/increment_strikes/reset_strikes, keyed here # by host path instead of a watchdog check name. Requires repeat corrupt detections across # separate scan runs before --remediate acts, so a one-off ffprobe hiccup (mid-write file, # NFS blip) can't trigger an unnecessary delete+re-search on its own. get_scan_strikes() { wd_state_get "$1" "$CORRUPTION_SCAN_STRIKES_FILE" } set_scan_strikes() { wd_state_set "$1" "$2" "$CORRUPTION_SCAN_STRIKES_FILE" } increment_scan_strikes() { local current current=$(get_scan_strikes "$1") [[ -z "$current" ]] && current=0 (( current++ )) set_scan_strikes "$1" "$current" echo "$current" } reset_scan_strikes() { local current current=$(get_scan_strikes "$1") [[ -n "$current" && "$current" != "0" ]] && set_scan_strikes "$1" 0 } # Bails out of the whole run without committing anything. Safe to call at any point before # the commit phase: strikes are queued in memory until then, so an abort leaves the strike # file exactly as the previous run left it and deletes nothing. abort_scan() { local why="$1" error "Corruption scan ABORTED — $why" error "No strikes recorded and nothing remediated this run — the library was not trusted." [[ -n "${FRESH_CLEAN_TMP:-}" ]] && rm -f "$FRESH_CLEAN_TMP" notify "Corruption scan aborted on $(hostname) ($MY_ID) — $why. Nothing deleted." \ "Arr Corruption Scan" "warning" exit 1 } # 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)" 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 Corrupt ceiling: ${CORRUPTION_SCAN_MAX_CORRUPT_PCT}% of scanned (min ${CORRUPTION_SCAN_GUARD_MIN_SCANNED} scanned)" echo "$ICON_GEAR Consecutive trip: $CORRUPTION_SCAN_MAX_CONSECUTIVE" echo "$ICON_GEAR Scan limit: ${SCAN_LIMIT:-unlimited} (per arr)" echo "$ICON_GEAR Path filter: ${PATH_FILTER:-none}" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi echo "" [[ "$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" # ============================================================================================== # ── HELPER FUNCTIONS ────────────────────────────────────────────────────────────────────────── # ============================================================================================== # Translates a host filesystem path to FFPROBE_CONTAINER's internal path via prefix match # against FFPROBE_PATH_MAP. Empty output (return 1) means this file's share isn't covered # by the ffprobe container yet — caller must skip, not guess. ffprobe_translate_path() { local host_path="$1" prefix for prefix in "${!FFPROBE_PATH_MAP[@]}"; do if [[ "$host_path" == "$prefix"/* ]]; then echo "${FFPROBE_PATH_MAP[$prefix]}${host_path#$prefix}" return 0 fi done return 1 } # Probes one file. Echoes "clean" or "corrupt:". Never trusts a truncated/garbled # stderr as automatically corrupt — only a real non-empty ffprobe stderr counts. probe_file() { local host_path="$1" container_path output rc container_path=$(ffprobe_translate_path "$host_path") || { echo "unmapped"; return; } output=$(docker exec "$FFPROBE_CONTAINER" "$FFPROBE_BIN" -v error "$container_path" 2>&1) rc=$? # docker exec writes its own failures to the same stream ffprobe uses, so a stopped # container or an unreachable daemon is otherwise indistinguishable from a corrupt # header. A stopped container exits 1 with a daemon message; a missing binary exits # 127 — neither is evidence about the file, so both must be caught. if (( rc >= 125 )) \ || [[ "$output" == "Error response from daemon:"* \ || "$output" == "Cannot connect to the Docker daemon"* \ || "$output" == "error during connect:"* ]]; then echo "probe_error:${output//$'\n'/ }" return fi if [[ -z "$output" ]]; then echo "clean" elif (( rc != 0 )); then # ffprobe could not parse the file — EBML header parsing failed, moov atom not found, # contradictionary STSC and STCO. This is the only class that may be remediated. echo "corrupt:${output//$'\n'/ }" else # Exit 0 with stderr output: a recoverable muxing complaint, most commonly # "Referenced QT chapter track not found", which many recent .mp4 releases emit and # which says nothing about playability. Equating any stderr with corruption is what # produced 103 "corrupt" files on 2026-08-23 — 28 of 43 newly scanned Radarr items. # Reported for visibility, never strike-tracked, never remediated. echo "suspect:${output//$'\n'/ }" 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) ━━━ # ============================================================================================== declare -A CLEAN_STATE while IFS=$'\t' read -r _s_path _s_stamp; do [[ -n "$_s_path" ]] && CLEAN_STATE["$_s_path"]="$_s_stamp" done < "$CORRUPTION_SCAN_STATE_FILE" unset _s_path _s_stamp info "Loaded ${#CLEAN_STATE[@]} previously-verified-clean entries" # 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" } 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_SUSPECT ARR_PROBE_ERRORS 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" "$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_append_on_success "$arr_url" "$arr_key" "v3" "episodefile?seriesId=${sid}" "Sonarr" "$all_tmp" done <<< "$SERIES_IDS" RAW_ITEMS=$(jq -s 'add // []' "$all_tmp" 2>/dev/null) rm -f "$all_tmp" 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 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 ━━━" SCANNED=0 SKIPPED_CACHED=0 SKIPPED_UNMAPPED=0 CORRUPT_COUNT=0 STRIKE_HELD=0 REMEDIATED=0 REMEDIATE_FAILED=0 PROBE_ERRORS=0 SUSPECT_COUNT=0 CONSECUTIVE_BAD=0 QUEUE_PATH=() QUEUE_STRIKES=() QUEUE_ITEM=() FRESH_CLEAN_TMP=$(mktemp) while IFS= read -r item; do api_path=$(echo "$item" | jq -r '.path') 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 [[ -n "$PATH_FILTER" && "$host_path" != *"$PATH_FILTER"* ]] && continue stamp="$(stat -c '%Y:%s' "$host_path" 2>/dev/null)" [[ -z "$stamp" ]] && continue if [[ "${CLEAN_STATE[$host_path]:-}" == "$stamp" ]]; then (( SKIPPED_CACHED++ )) continue fi (( SCANNED++ )) if [[ "$SCAN_LIMIT" -gt 0 && "$SCANNED" -gt "$SCAN_LIMIT" ]]; then (( SCANNED-- )) break fi result=$(probe_file "$host_path") if [[ "$result" == "unmapped" ]]; then (( SKIPPED_UNMAPPED++ )) [[ "$ENABLE_LOGGING" == true ]] && warn " ? $host_path — no FFPROBE_PATH_MAP entry covers this share" continue fi # A docker-level failure is not evidence about the file. Count it, never queue it. if [[ "$result" == probe_error:* ]]; then (( PROBE_ERRORS++ )) (( CONSECUTIVE_BAD++ )) warn " ? $host_path — probe failed, NOT counted as corrupt: ${result#probe_error:}" if (( CONSECUTIVE_BAD >= CORRUPTION_SCAN_MAX_CONSECUTIVE )); then abort_scan "$CONSECUTIVE_BAD files in a row failed to probe cleanly" fi continue fi if [[ "$result" == "clean" ]]; then CONSECUTIVE_BAD=0 reset_scan_strikes "$host_path" echo -e "${host_path}\t${stamp}" >> "$FRESH_CLEAN_TMP" [[ "$ENABLE_LOGGING" == true ]] && echo " $ICON_SUCCESS $host_path" continue fi # A successful probe that merely warned. Proves the container is alive, so it clears # the consecutive-failure tripwire, but it never becomes a strike. if [[ "$result" == suspect:* ]]; then CONSECUTIVE_BAD=0 (( SUSPECT_COUNT++ )) [[ "$ENABLE_LOGGING" == true ]] && warn " ~ $host_path — ffprobe warning (exit 0), NOT corrupt: ${result#suspect:}" continue fi # corrupt: — queued, NOT committed. Nothing reaches the strike file and nothing # is deleted until this arr has been fully probed and the guards below have passed. A # container that dies mid-scan makes every remaining file read as corrupt, and a delete # cannot be undone — so the destructive half has to wait until the corrupt rate for the # whole run is known. 2026-08-23: one Jellyfin restart produced 103 false positives. reason="${result#corrupt:}" (( CORRUPT_COUNT++ )) (( CONSECUTIVE_BAD++ )) prev_strikes=$(get_scan_strikes "$host_path") prev_strikes="${prev_strikes//[^0-9]/}" strikes=$(( ${prev_strikes:-0} + 1 )) QUEUE_PATH+=("$host_path") QUEUE_STRIKES+=("$strikes") QUEUE_ITEM+=("$item") echo " $ICON_ERROR CORRUPT: $host_path (strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT)" [[ "$ENABLE_LOGGING" == true ]] && echo " $reason" if (( CONSECUTIVE_BAD >= CORRUPTION_SCAN_MAX_CONSECUTIVE )); then abort_scan "$CONSECUTIVE_BAD files in a row failed to probe cleanly" fi done < <(echo "$ITEMS" | jq -c '.[]') # ━━━ False-positive guards — run before anything is committed ━━━ if (( CORRUPT_COUNT > 0 )); then # The pre-flight check only proves the container was up when the scan started. # Re-check now: a mid-scan death is exactly what this guard exists to catch. check_container_health "$FFPROBE_CONTAINER" "${DOCKER_TIMEOUT:-30}" "Arr Corruption Scan" if (( SCANNED >= CORRUPTION_SCAN_GUARD_MIN_SCANNED )); then corrupt_pct=$(( CORRUPT_COUNT * 100 / SCANNED )) if (( corrupt_pct >= CORRUPTION_SCAN_MAX_CORRUPT_PCT )); then abort_scan "$CORRUPT_COUNT of $SCANNED probed files (${corrupt_pct}%) read as corrupt — at or above the ${CORRUPTION_SCAN_MAX_CORRUPT_PCT}% ceiling" fi fi fi # ━━━ Guards passed — commit strikes, then remediate whatever reached the limit ━━━ for _q in "${!QUEUE_PATH[@]}"; do host_path="${QUEUE_PATH[$_q]}" strikes="${QUEUE_STRIKES[$_q]}" item="${QUEUE_ITEM[$_q]}" set_scan_strikes "$host_path" "$strikes" [[ "$REMEDIATE" != true ]] && continue if (( strikes < CORRUPTION_SCAN_STRIKE_LIMIT )); then warn " $host_path — strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT, not yet remediating (needs repeat confirmation)" (( STRIKE_HELD++ )) continue fi reset_scan_strikes "$host_path" file_id=$(echo "$item" | jq -r '.file_id') parent_id=$(echo "$item" | jq -r '.parent_id') 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: $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)" (( REMEDIATE_FAILED++ )) continue fi sleep 2 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 error " ✗ $title — deleted but hasFile still '$verify_hasfile' — not searching, needs review" (( REMEDIATE_FAILED++ )) continue fi search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \ --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" (( REMEDIATED++ )) else warn " $title — deleted and verified, but search trigger returned HTTP $search_code" (( REMEDIATE_FAILED++ )) fi done 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_SUSPECT[$arr]=$SUSPECT_COUNT ARR_PROBE_ERRORS[$arr]=$PROBE_ERRORS 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 ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY CORRUPTION SCAN SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" 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]}" echo " $ICON_WARN Warnings (exit 0): ${ARR_SUSPECT[$arr]} (reported, never remediated)" echo " $ICON_WARN Probe errors: ${ARR_PROBE_ERRORS[$arr]} (not counted as corrupt)" 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_SUCCESS Remediated: $TOTAL_REMEDIATED" echo " $ICON_ERROR Remediation failed: $TOTAL_REMEDIATE_FAILED" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0