#!/bin/bash # ============================================================================================== # ======================= Arr Full Library Rescan ============================================= # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Forces a genuine full disk↔database reconciliation for Lidarr/Sonarr/Radarr. Organic # scans (triggered by new imports, RSS sync, etc.) only touch the files actually involved — # an artist/series/movie that already has files sitting untouched on disk never gets its # file-tracking stats refreshed on its own. Confirmed 2026-07-16: Lidarr reported only ~23% # of its true trackFileCount with no active scan running, for 1,004 of 1,357 artists — files # verified present and readable on disk the whole time. Every downstream script (cleanup, # duplicate-artist detection, discovery) trusts these arr stats as source of truth for what's # on the share, so silent drift like this is exactly what check_tracked_count_floor() exists # to catch reactively. This job exists to catch it proactively instead of waiting for someone # to notice a suspiciously low number. # # Runs sequentially across all three arrs, never parallel — each is a heavy full-disk walk, # and running them concurrently would just contend for the same disk I/O for no benefit. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Lidarr, then Sonarr, then Radarr — strictly sequential. Per arr: # # 1. Reachability # → check_api; unreachable skips this arr only # # 2. Already-scanning check # → a rescan already active (manual, or another script) means skip rather than # stack a second full-disk walk on top of it # # 3. Capture the before count # → tracked file count read from the arr's own stats # # 4. Trigger the rescan command # → RescanFolders (Lidarr) / RescanSeries (Sonarr) / RescanMovie (Radarr) # # 5. Poll to completion # → bounded by ARR_FULL_RESCAN_TIMEOUT # # 6. Report the delta # → before vs after tracked count, so drift that was corrected is visible # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Proactive, Not Reactive # check_tracked_count_floor() catches stat drift reactively, at the moment some other # script is about to act on bad numbers. This job exists so that drift is corrected on a # schedule instead of being discovered by whichever cleanup happens to trip over it first. # # Sequential by Design # Each rescan is a full-disk walk. Running three concurrently contends for the same # spindles and finishes no sooner, so the arrs are never parallelised — the slowness is # accepted deliberately rather than optimised into I/O thrash. # # Never Stack a Scan # An already-running rescan is left alone rather than duplicated. A second concurrent # walk of the same library doubles the I/O cost and returns nothing the first will not. # # Per-Arr Isolation # One arr being down, slow, or already scanning must never prevent the other two from # being reconciled. Partial coverage beats a skipped run. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Root Enforcement # Kept for consistency across Arrs_Stack/ — this script makes no direct filesystem writes. # # Lock Acquisition # acquire_lock "wait" — waits for a prior run rather than skipping or colliding. A full # rescan across three arrs runs long and is worth queuing behind, not silently dropping. # # Host Detection # detect_hosts() aliases each arr's URL and API key. # # jq Dependency Check # Fails fast if jq is missing. Both the before/after tracked counts and the command # payload are built with jq — without it the counts read empty and every delta would be # reported as if nothing changed. # # Reachability Check # check_api before touching an arr; unreachable skips that arr only. # # Active-Rescan Check # Skips triggering a new rescan if one is already active on that arr, so a duplicate # full-disk walk is never stacked. See Tools/arr_rescan_monitor.sh for catching that # arr's cache up once the pre-existing scan finishes, rather than waiting a week. # # Sequential Only # Two arrs' rescans never run in parallel. # # Per-Arr Isolation # One arr failing, timing out, or being skipped never blocks the others. # # Timeout Bound # ARR_FULL_RESCAN_TIMEOUT caps the wait per arr, so a rescan that never completes cannot # hold the weekly window open indefinitely. # # Dry Run Support # --dry-run reports which arrs would be rescanned and triggers nothing. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # master.conf # ARR_FULL_RESCAN_TIMEOUT — seconds to wait per arr (default 3600). A whole-library # RescanFolders/RescanSeries/RescanMovie is far heavier than the 600s pre-flight scan # timeout used elsewhere — that shorter timeout is sized for a single release, not a # full-library walk. # # host*.conf # HOST1_LIDARR_URL / _API_KEY, HOST1_SONARR_URL / _API_KEY, HOST1_RADARR_URL / _API_KEY # — aliased by detect_hosts() # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # arr_full_rescan.sh — normal run # arr_full_rescan.sh --dry-run — preview which arrs would be rescanned, trigger nothing # arr_full_rescan.sh --log — verbose, per-arr detail # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi # Both the tracked-count reads and the command payload are built with jq — without it the # counts read empty and every arr would report a zero delta as if nothing had drifted. if ! command -v jq >/dev/null 2>&1; then error "jq not found — required for JSON parsing" notify "Arr full rescan failed on $(hostname) — jq not installed" "Arr Full Rescan" "warning" exit 1 fi acquire_lock "wait" detect_hosts [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no rescans will be triggered" declare -A ARR_FULL_RESCAN_COMMAND=( [lidarr]="RescanFolders" [sonarr]="RescanSeries" [radarr]="RescanMovie" ) RESCANNED=0 SKIPPED=0 for arr in lidarr sonarr radarr; do url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY" url="${!url_var:-}"; key="${!key_var:-}" ver="v3"; [[ "$arr" == "lidarr" ]] && ver="v1" if [[ -z "$url" || -z "$key" ]]; then info "${arr^} not configured on $MY_ID — skipping" continue fi check_api "$url" "${arr^}" 10 || { warn "${arr^} unreachable — skipping full rescan this run" (( SKIPPED++ )) continue } active=$(arr_active_rescan_command "$arr" "$url" "$key" "$ver") if [[ -n "$active" ]]; then warn "${arr^} already mid-rescan ($active) — skipping, will catch it next scheduled run (run Tools/arr_rescan_monitor.sh ${arr} to refresh its cache as soon as this one finishes instead of waiting)" (( SKIPPED++ )) continue fi endpoint="${ARR_LIBRARY_ENDPOINT[$arr]}" expr="${ARR_TRACKED_COUNT_EXPR[$arr]}" before_json=$(curl -sf --max-time 60 -H "X-Api-Key: $key" "${url}/api/${ver}/${endpoint}" 2>/dev/null) before_count=$(echo "$before_json" | jq "$expr" 2>/dev/null) if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would trigger full ${ARR_FULL_RESCAN_COMMAND[$arr]} for ${arr} (currently: ${before_count:-unknown} tracked)" continue fi log "$ICON_GEAR Triggering full ${ARR_FULL_RESCAN_COMMAND[$arr]} for ${arr} (before: ${before_count:-unknown} tracked)" payload=$(jq -c -n --arg name "${ARR_FULL_RESCAN_COMMAND[$arr]}" '{name:$name}') trigger_and_await_command "$url" "$key" "$ver" "$payload" "${ARR_FULL_RESCAN_TIMEOUT:-3600}" "$arr" after_json=$(curl -sf --max-time 60 -H "X-Api-Key: $key" "${url}/api/${ver}/${endpoint}" 2>/dev/null) after_count=$(echo "$after_json" | jq "$expr" 2>/dev/null) if [[ -n "$after_json" && -n "$after_count" && "$after_count" != "null" ]]; then arr_cache_write "$arr" "$after_json" log "$ICON_DONE ${arr^} rescan complete — tracked: ${before_count:-?} → ${after_count}" (( RESCANNED++ )) # A completed full rescan is ground truth — if it's STILL far below the running # baseline, that's a real problem (missing disk, permissions, actual data loss), # not a stale-cache or mid-scan artifact. Worth a direct heads-up either way. count_file_var="${arr^^}_TRACKED_COUNT_FILE" min_pct_var="${arr^^}_MIN_TRACKED_PCT" count_file="${!count_file_var:-}" min_pct="${!min_pct_var:-80}" if [[ -n "$count_file" && -f "$count_file" ]]; then baseline=$(cat "$count_file" 2>/dev/null || echo 0) if [[ "$baseline" -gt 0 ]]; then pct=$(awk "BEGIN {printf \"%d\", ($after_count / $baseline) * 100}") if [[ "$pct" -lt "$min_pct" ]]; then notify "${arr^} full rescan complete but tracked count still ${pct}% of baseline ($after_count vs $baseline) on $(hostname) — real drop, not a scan artifact, needs a look" \ "Arr Full Rescan" "warning" else echo "$after_count" > "$count_file" fi else echo "$after_count" > "$count_file" fi fi else warn "${arr^} rescan finished but re-fetch failed — cache not updated" fi done echo "" echo "━━━━━ $ICON_SUMMARY ARR FULL RESCAN SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_GEAR Rescanned: $RESCANNED" echo "$ICON_SKIP Skipped: $SKIPPED" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0