diff --git a/Arrs_Stack/radarr_cleanup.sh b/Arrs_Stack/radarr_cleanup.sh index f9659c2..aed1601 100644 --- a/Arrs_Stack/radarr_cleanup.sh +++ b/Arrs_Stack/radarr_cleanup.sh @@ -455,6 +455,41 @@ NOW=$(date +%s) TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt" > "$TO_DELETE_FILE" +# ── Orphan strikes ──────────────────────────────────────────────────────────────────────────── +# A file must classify for deletion on RADARR_ORPHAN_STRIKE_LIMIT consecutive runs before it is +# actually removed. Gate 6 already refuses a run whose tracked count collapsed; this covers the +# partial failure underneath that threshold — one root folder failing to enumerate makes its +# movies look orphaned while the overall percentage still looks fine, and a transient fault will +# not reproduce on the next run. +# +# The file is REBUILT from this run's classifications rather than edited in place, which is what +# prunes it: anything that stopped being an orphan simply is not written again, so a file that +# Radarr re-adopts loses its strikes without needing a reset pass to find it. +# +# Keyed by host path, which is why this could not have worked before 2026-08-26 — wd_state_set +# built a regex from the key, and a release tag like [Bluray-1080p] holds the reversed range 1-0, +# so every write truncated the store to one line. See common.sh. +RADARR_ORPHAN_STRIKE_LIMIT="${RADARR_ORPHAN_STRIKE_LIMIT:-2}" +STRIKES_FILE="${RADARR_ORPHAN_STRIKES_FILE:-$DB_DIR/radarr_orphan_strikes.tsv}" +mkdir -p "$(dirname "$STRIKES_FILE")" 2>/dev/null || true +touch "$STRIKES_FILE" 2>/dev/null || true +STRIKES_NEW="$TMP_DIR/strikes_new.tsv" +> "$STRIKES_NEW" +HELD_COUNT=0 +HELD_BYTES=0 + +# Records this run's strike for a file and says whether it has served enough of them. +# Returns 0 when the file may be deleted, 1 when it is still accruing. +orphan_strike_ok() { + local path="$1" prev strikes + prev=$(wd_state_get "$path" "$STRIKES_FILE"); prev="${prev//[^0-9]/}" + strikes=$(( ${prev:-0} + 1 )) + printf '%s:%s\n' "$path" "$strikes" >> "$STRIKES_NEW" + (( strikes >= RADARR_ORPHAN_STRIKE_LIMIT )) && return 0 + warn " strike $strikes/$RADARR_ORPHAN_STRIKE_LIMIT — not removing yet: $path" + return 1 +} + while read -r FILE_SIZE FILE_CTIME filepath; do [[ -z "$filepath" ]] && continue FILE_CTIME="${FILE_CTIME%%.*}" @@ -489,11 +524,13 @@ while read -r FILE_SIZE FILE_CTIME filepath; do warn "$ICON_TRASH ORPHAN: $filepath" (( ORPHAN_COUNT++ )) ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE )) + if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE" else log "JUNK: $filepath" (( JUNK_COUNT++ )) JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE )) + if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE" fi @@ -506,8 +543,18 @@ done < <( done | sort -u ) -TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES )) -TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT )) +# Eligible, not classified. A file still serving its strikes was counted as an orphan above — it +# is one — but it is not going to be deleted this run, so it must not appear in the denominator +# the budget reports against or the run claims to have skipped work it never queued. +TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES - HELD_BYTES )) +TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT - HELD_COUNT )) + +# Rebuilt, never edited: a path absent from this run is absent from the file, so a file Radarr +# re-adopts drops its strikes with no reset pass needed. Skipped on a dry run — a preview that +# advanced real strike counters would make the next real run delete a run early. +if [[ "$DRY_RUN" == false ]]; then + mv "$STRIKES_NEW" "$STRIKES_FILE" 2>/dev/null || warn "Could not update $STRIKES_FILE" +fi # ============================================================================================== # ━━━ Safety Layer 7 — Deletion Size Threshold ━━━ @@ -574,6 +621,8 @@ echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles, echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)" echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)" echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${RADARR_ORPHAN_AGE} days)" +[[ "${HELD_COUNT:-0}" -gt 0 ]] && \ + echo "$ICON_SKIP Held (strikes): $HELD_COUNT files ($(format_bytes "$HELD_BYTES")) — under ${RADARR_ORPHAN_STRIKE_LIMIT} consecutive runs" [[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && \ echo "$ICON_SKIP Deferred: $_BUDGET_DEFERRED_COUNT files ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) — over the ${RADARR_MAX_DELETE_GB}GB run budget" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 0379904..ca36ae2 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1390,6 +1390,12 @@ RADARR_MIN_TRACKED_PCT=80 # abort if tracked count drops below this % of last run # protects against API returning partial data on a bad day RADARR_TRACKED_COUNT_FILE="${DB_DIR}/radarr_tracked.count" + RADARR_ORPHAN_STRIKES_FILE="${DB_DIR}/radarr_orphan_strikes.tsv" # consecutive-classification counts, keyed by host path + RADARR_ORPHAN_STRIKE_LIMIT=2 # consecutive runs a file must classify for deletion before it is + # removed. Gate 6 already catches an API returning far too few + # tracked files; this catches the partial failure too small to trip + # that percentage — one root folder failing to enumerate makes its + # movies look orphaned, and a transient one will not repeat. RADARR_IMPORT_SCAN_TIMEOUT=600 # seconds to wait for pre-flight import scan RADARR_MOVE_POLL_TIMEOUT=3600 # seconds to wait for a single async MoveMovie command to # reach "completed" — mirrors SONARR_MOVE_POLL_TIMEOUT