Add arr_corruption_scan.sh — sequential ffprobe-based corruption scan for Sonarr

Healarr does the same job but crashes on a Go concurrency bug (unsynchronized
map access) whenever multiple corruption events land close together — confirmed
via its own crash log, not fixable from our side. Processing one file at a time
here sidesteps the whole bug class instead of trying to work around it.

Delete + explicit EpisodeSearch rather than relying on Sonarr's own background
missing-search cycle, since that skips unmonitored episodes and this shouldn't.
This commit is contained in:
Gmer4Lfe
2026-07-18 16:48:32 -04:00
parent d5d1403ebf
commit ab35fcd84d
3 changed files with 402 additions and 0 deletions
+390
View File
@@ -0,0 +1,390 @@
#!/bin/bash
# ==============================================================================================
# ============================ Arr Corruption Scan ==============================================
# ==============================================================================================
#
# 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.
#
# 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.
#
# ==============================================================================================
# 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.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 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 record via Sonarr'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").
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# SONARR_URL / SONARR_API_KEY / SONARR_PATH_MAP — existing, aliased by detect_hosts()
# 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
# container almost certainly mounts shares differently)
#
# master.conf
# CORRUPTION_SCAN_STATE_FILE — path to the clean-file skip-cache (default in DATA_DIR)
# SONARR_VERSION_MAJOR — reused from sonarr_cleanup.sh for the API version check
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# arr_corruption_scan.sh — report-only, scans everything not yet
# verified clean
# 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 --log — verbose (prints every clean file too)
# arr_corruption_scan.sh --status — show config and exit
#
# ==============================================================================================
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
for _arg in "$@"; do
case "$_arg" in
--remediate) REMEDIATE=true ;;
--limit=*) SCAN_LIMIT="${_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 Sonarr 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
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"
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"
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"
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 Remediate: $REMEDIATE"
echo "$ICON_GEAR Scan limit: ${SCAN_LIMIT:-unlimited}"
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 ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# 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
}
# ==============================================================================================
# ━━━ 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"
# ==============================================================================================
# ━━━ Fetch Sonarr's tracked episode files ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Fetching Sonarr Tracked Files ━━━"
# 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
}
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"
# ==============================================================================================
# ━━━ Scan ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning ━━━"
SCANNED=0
SKIPPED_CACHED=0
SKIPPED_UNMAPPED=0
CORRUPT_COUNT=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
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
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++ ))
echo " $ICON_ERROR CORRUPT: $host_path"
[[ "$ENABLE_LOGGING" == true ]] && echo " $reason"
if [[ "$REMEDIATE" != true ]]; then
continue
fi
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++ ))
else
warn " $title — deleted and verified, but search trigger returned HTTP $search_code"
(( REMEDIATE_FAILED++ ))
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"
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
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"
if [[ "$REMEDIATE" == true ]]; then
echo "$ICON_SUCCESS Remediated: $REMEDIATED"
echo "$ICON_ERROR Remediation failed: $REMEDIATE_FAILED"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
+11
View File
@@ -457,6 +457,17 @@
# ["/movies"]="/mnt/user/Movies"
)
# ━━━ Corruption Scan ━━━
# Container that has a working ffprobe binary AND mounts the same shares as the arrs'
# roots — check `docker inspect <container>` for its mounts before filling this in.
HOSTN_FFPROBE_CONTAINER=""
HOSTN_FFPROBE_BIN=""
declare -A HOSTN_FFPROBE_PATH_MAP=(
# ["/mnt/user/Tv_Shows"]="/ext-tv-shows"
# ["/mnt/user/Movies"]="/ext-movies"
)
# ━━━ Arr Recovery Toggles ━━━
HOSTN_LIDARR_RECOVERY=false
HOSTN_SONARR_RECOVERY=true
+1
View File
@@ -1184,6 +1184,7 @@
# reach "completed" — generous because a large series can sit
# queued behind other moves already in progress, not just its
# own copy time
CORRUPTION_SCAN_STATE_FILE="${DATA_DIR}/corruption_scan_state.tsv" # clean-file skip-cache
SONARR_EXTENSIONS=("mkv" "mp4" "avi" "m4v" "ts" "wmv" "mov")
SONARR_PROTECTED_PATTERNS=(
# Subtitles