Make a file earn its deletion over consecutive runs, so a partial classification failure too small to trip the tracked-count gate cannot remove anything

This commit is contained in:
Gmer4Lfe
2026-08-26 17:58:42 -04:00
parent 0431e720de
commit 96d8a5e3f0
2 changed files with 57 additions and 2 deletions
+51 -2
View File
@@ -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 )))"
+6
View File
@@ -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