diff --git a/Arrs_Stack/arr_corruption_scan.sh b/Arrs_Stack/arr_corruption_scan.sh index 373ea08..6463af4 100755 --- a/Arrs_Stack/arr_corruption_scan.sh +++ b/Arrs_Stack/arr_corruption_scan.sh @@ -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,159 +331,225 @@ 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 - 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 +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 } - 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" - done <<< "$SERIES_IDS" - EPISODE_FILES=$(jq -s 'add // []' "$all_tmp" 2>/dev/null) - rm -f "$all_tmp" - arr_item_cache_write "sonarr" "$EPISODE_FILES" -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" + # ────────────────────────────────────────────────────────────────────────────────────── + # 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 ━━━" -# ============================================================================================== -# ━━━ 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-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) - -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') - - 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="${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" - - episode_id=$(echo "$item" | jq -r '.episodeId // empty') - title=$(echo "$item" | jq -r '.sceneName // .relativePath // .path') - - 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) - - if [[ "$http_code" != "200" ]]; then - error " ✗ $title — delete failed (HTTP $http_code)" - (( REMEDIATE_FAILED++ )) - continue - fi - - sleep 2 - verify_hasfile=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "episode/${episode_id}" "Sonarr" 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: $SONARR_API_KEY" -H "Content-Type: application/json" \ - -d "{\"name\":\"EpisodeSearch\",\"episodeIds\":[${episode_id}]}" \ - "${SONARR_URL}/api/v3/command" 2>/dev/null) - - if [[ "$search_code" == "200" || "$search_code" == "201" ]]; then - echo " $ICON_SUCCESS $title — deleted, verified, search triggered" - (( REMEDIATED++ )) + 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 - warn " $title — deleted and verified, but search trigger returned HTTP $search_code" - (( REMEDIATE_FAILED++ )) + 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 -done < <(echo "$EPISODE_FILES" | 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" + 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="${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 ━━━ @@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"