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.
585 lines
28 KiB
Bash
Executable File
585 lines
28 KiB
Bash
Executable File
#!/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").
|
|
#
|
|
# ==============================================================================================
|
|
# 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
|
|
|
|
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}"
|
|
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
|
|
}
|
|
|
|
# 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 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:<reason>". 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
|
|
container_path=$(ffprobe_translate_path "$host_path") || { echo "unmapped"; return; }
|
|
output=$(docker exec "$FFPROBE_CONTAINER" "$FFPROBE_BIN" -v error "$container_path" 2>&1)
|
|
if [[ -z "$output" ]]; then
|
|
echo "clean"
|
|
else
|
|
echo "corrupt:${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_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
|
|
|
|
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
|
|
|
|
if [[ "$result" == "clean" ]]; then
|
|
reset_scan_strikes "$host_path"
|
|
echo -e "${host_path}\t${stamp}" >> "$FRESH_CLEAN_TMP"
|
|
[[ "$ENABLE_LOGGING" == true ]] && echo " $ICON_SUCCESS $host_path"
|
|
continue
|
|
fi
|
|
|
|
# corrupt:<reason>
|
|
reason="${result#corrupt:}"
|
|
(( CORRUPT_COUNT++ ))
|
|
strikes=$(increment_scan_strikes "$host_path")
|
|
echo " $ICON_ERROR CORRUPT: $host_path (strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT)"
|
|
[[ "$ENABLE_LOGGING" == true ]] && echo " $reason"
|
|
|
|
if [[ "$REMEDIATE" != true ]]; then
|
|
continue
|
|
fi
|
|
|
|
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"
|
|
|
|
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 < <(echo "$ITEMS" | jq -c '.[]')
|
|
|
|
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 ━━━
|
|
# ==============================================================================================
|
|
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]}"
|
|
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
|