diff --git a/Arrs_Stack/arr_full_rescan.sh b/Arrs_Stack/arr_full_rescan.sh new file mode 100755 index 0000000..47d8447 --- /dev/null +++ b/Arrs_Stack/arr_full_rescan.sh @@ -0,0 +1,150 @@ +#!/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. +# +# ============================================================================================== +# 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 +# +# ============================================================================================== + +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 + +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" + (( 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 diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 6f761e9..f064e26 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -425,6 +425,7 @@ "Docker_Essentials/docker_weekly_restart.sh" # weekly container restarts after sync "Docker_Essentials/docker_update_remaining.sh" # pull updates for all other containers "unRAID_Essentials/clear_logs.sh" # purge aged logs — Sunday only, low priority + "Arrs_Stack/arr_full_rescan.sh" # full disk↔DB reconciliation for Lidarr/Sonarr/Radarr — keeps tracked stats honest, runs before discovery so it works off fresh data "Arrs_Stack/playback_aware_lidarr_discovery.sh" # behavior-driven music discovery using weekly Emby playback history "Arrs_Stack/playback_aware_radarr_discovery.sh" # behavior-driven movie discovery using TMDB recommendations "Arrs_Stack/playback_aware_sonarr_discovery.sh" # behavior-driven TV discovery using TMDB recommendations @@ -1240,6 +1241,11 @@ # candidate is this language; an existing file in a # different language is always considered upgradeable +# ━━━ Arr Full Library Rescan ━━━ +# arr_full_rescan.sh — weekly disk↔DB reconciliation for Lidarr/Sonarr/Radarr. See script +# header for why this exists (2026-07-16 stale-stats incident). + ARR_FULL_RESCAN_TIMEOUT=3600 # seconds per arr — a full-library walk, not a single release + # ============================================================================================== # ── TRANSCODES ──────────────────────────────────────────────────────────────────────────────── # ==============================================================================================