697 lines
36 KiB
Bash
697 lines
36 KiB
Bash
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ================================= Radarr Cleanup =============================================
|
|
# ==============================================================================================
|
|
#
|
|
# PURPOSE
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Delete orphaned movie files not tracked by Radarr. Queries the API for all
|
|
# tracked movie file paths, walks the library on disk, and removes anything
|
|
# untracked that is old enough to be past the import window. Triggers an Emby
|
|
# library clean after each deletion run so ghost entries disappear immediately.
|
|
#
|
|
# The tracked-count floor check (Safety Layer 6) is rescan-aware: if Radarr's own
|
|
# RescanMovie/DownloadedMoviesScan is active (independently of this script's own
|
|
# lighter ProcessMonitoredDownloads pre-flight), a genuinely low mid-scan count gets
|
|
# waited out (calibrated to that command's historical duration via
|
|
# arr_get_rescan_duration(), up to 3 strikes) and re-fetched rather than triggering a
|
|
# false-alarm abort. Mirrors the same fix built for lidarr_cleanup.sh 2026-07-16 after
|
|
# a whole-library rescan there made trackFileCount read 22% of normal mid-scan.
|
|
#
|
|
# Cache-first movie list (2026-07-17), batched moviefile fetch (2026-07-19). The movie list
|
|
# comes from the shared tracked-data cache via arr_get_tracked_data() — fresh (kept warm
|
|
# every 30min by arr_cache_prefill.sh), live fetch as fallback. Radarr's movie list embeds
|
|
# movieFile.path directly on every hasFile=true entry, but that's only the *primary* file —
|
|
# Radarr 6+ supports a second tracked file per movie (alternate editions/extras) that never
|
|
# shows up there, so relying on it alone misclassified a movie's second edition as an orphan
|
|
# (confirmed live 2026-07-19: The Crash, They Will Kill You, The Drama, Lee Cronin's The
|
|
# Mummy, and Ready or Not: Here I Come all had a legitimately-tracked second file deleted-flagged
|
|
# this way). /moviefile?movieId=X returns every file for a movie, including secondaries, and
|
|
# accepts movieId as a repeated query param for a bulk fetch — but the whole library in one
|
|
# request 414s (Request-URI Too Long, confirmed live), so _fetch_tracked_files() batches
|
|
# movieId params BATCH_SIZE at a time instead: ~14 requests for a ~2800-movie library rather
|
|
# than the up-to-2896 individual per-movie calls the 2026-07-17 optimization eliminated, and
|
|
# rather than the one-shot list read that missed secondary files. The filesystem is walked
|
|
# once per run, not twice — classification records which paths are eligible for deletion as
|
|
# it goes, and the delete pass (once the size-threshold check below passes) just acts on that
|
|
# list instead of re-walking and re-classifying the whole tree. That single walk also gets
|
|
# size+ctime straight from find -printf instead of a separate stat fork per file — find
|
|
# already has to stat() every entry to know it's -type f, so this is free by comparison.
|
|
# Measured ~130x faster per file (0.033ms vs 4.3ms).
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL MODEL
|
|
# ==============================================================================================
|
|
#
|
|
# Every file encountered on disk is classified into one of five categories:
|
|
#
|
|
# TRACKED — Radarr API knows this exact path → leave it alone
|
|
# PROTECTED — matches RADARR_PROTECTED_PATTERNS → never delete
|
|
# ORPHAN — video file, not tracked, older than RADARR_ORPHAN_AGE → delete
|
|
# JUNK — not a video extension, not protected → delete regardless of age
|
|
# RECENT — not tracked, under RADARR_ORPHAN_AGE → skip (may be mid-import)
|
|
#
|
|
# Radarr generates movie artwork (*.jpg), metadata (*.nfo), and manages subtitles
|
|
# (*.srt, *.sub, *.ass) but does NOT include these in its tracked file API response.
|
|
# Without PROTECTED classification these would be deleted — breaking Radarr and
|
|
# Emby metadata display.
|
|
#
|
|
# After deletions: notify_emby_scan() triggers Emby "Clean Missing Files" task.
|
|
# Emby removes ghost entries immediately — no user-facing file-not-found errors.
|
|
#
|
|
# ==============================================================================================
|
|
# DESIGN PRINCIPLES
|
|
# ==============================================================================================
|
|
#
|
|
# API as Ground Truth
|
|
# What Radarr tracks is authoritative. Files not in the API response are
|
|
# orphans — Radarr has no record of them and they serve no purpose.
|
|
# The script never infers ownership from directory structure alone.
|
|
#
|
|
# Age Gate Before Deletion
|
|
# Files under RADARR_ORPHAN_AGE are left alone regardless of tracked status.
|
|
# Radarr's import pipeline writes files before registering them — acting
|
|
# immediately would delete files mid-import.
|
|
# Age is measured from ctime, not mtime — an import preserves the release's original
|
|
# mtime, so a file that landed today can read as years old and skip this gate. Depends
|
|
# on media_shares_permissions.sh touching only entries that are actually wrong.
|
|
#
|
|
# Emby Cleanup Is Part of the Job
|
|
# Deleting a file without telling Emby leaves ghost entries that show as
|
|
# broken items. Triggering the Emby clean is not optional — it completes
|
|
# the deletion from the user's perspective.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL SAFEGUARDS
|
|
# ==============================================================================================
|
|
#
|
|
# Seven gates — ALL must pass before any file is touched:
|
|
# 1. Container running and not starting/unhealthy
|
|
# 2. API reachable
|
|
# 3. API version matches RADARR_VERSION_MAJOR in master.conf
|
|
# 4. Movie count > 0
|
|
# 5. Tracked file count > 0
|
|
# 6. Tracked count >= RADARR_MIN_TRACKED_PCT % of last known count
|
|
# 7. Deletion size < RADARR_MAX_DELETE_GB — or --i-know-what-im-doing required
|
|
#
|
|
# acquire_lock "wait" — large scans take time, wait for previous run to finish
|
|
# jq + curl validation — exits if either tool missing
|
|
# ARR_DOCKER_TIMEOUT — container checks protected against daemon hangs (script-local, not common.sh's DOCKER_TIMEOUT)
|
|
# notify_emby_scan() — triggers Emby clean after deletion
|
|
# Silent by default — orphans/junk warn(), clean library logs silently
|
|
#
|
|
# ==============================================================================================
|
|
# STATE FILES
|
|
# ==============================================================================================
|
|
#
|
|
# RADARR_TRACKED_COUNT_FILE — persistent baseline for the tracked % safety check (gate 6)
|
|
# Updated after each successful run. Protects against misconfigured root path
|
|
# returning an empty API response and deleting the entire library.
|
|
#
|
|
# ==============================================================================================
|
|
# CONFIGURATION
|
|
# ==============================================================================================
|
|
#
|
|
# host*.conf
|
|
#
|
|
# HOST*_RADARR_URL / HOST*_RADARR_API_KEY / HOST*_RADARR_MOVIES_ROOT
|
|
# HOST*_RADARR_PATH_MAP — container path → host path translation
|
|
# All aliased by detect_hosts() — script uses unprefixed names
|
|
#
|
|
# master.conf
|
|
#
|
|
# RADARR_ORPHAN_AGE — days before untracked file eligible for deletion
|
|
# RADARR_MAX_DELETE_GB — require --i-know-what-im-doing above this
|
|
# RADARR_MIN_TRACKED_PCT — abort if tracked count drops below this % of last run
|
|
# RADARR_TRACKED_COUNT_FILE — persistent baseline file path
|
|
# RADARR_EXTENSIONS — video file extensions for orphan classification
|
|
# RADARR_PROTECTED_PATTERNS — file patterns never deleted
|
|
# RADARR_VERSION_MAJOR — expected Radarr major version for API safety check
|
|
# RADARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)
|
|
# ARR_CLEANUP_STATS — stats file path (read by coffee report)
|
|
#
|
|
# ==============================================================================================
|
|
# RUNTIME MODES
|
|
# ==============================================================================================
|
|
#
|
|
# radarr_cleanup.sh — normal run
|
|
# radarr_cleanup.sh --dry-run — preview, no deletions
|
|
# radarr_cleanup.sh --log — verbose output
|
|
# radarr_cleanup.sh --status — show config and exit
|
|
# radarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
|
|
# radarr_cleanup.sh --i-know-what-im-doing --skip-age-check — NUCLEAR MODE
|
|
#
|
|
# NUCLEAR MODE: both flags bypass age check AND size threshold. User accepts full
|
|
# responsibility — the flag name is long and annoying by design.
|
|
#
|
|
# ==============================================================================================
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
source "$SCRIPT_DIR/../load_config.sh"
|
|
|
|
# ── Special flag pre-processing ───────────────────────────────────────────────────────────────
|
|
parse_destructive_flags "$@"
|
|
|
|
parse_args "${FILTERED_ARGS[@]}"
|
|
|
|
# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────
|
|
nuclear_mode_warning
|
|
|
|
# ==============================================================================================
|
|
# ━━━ 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 Radarr API calls"
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
error "jq not found — required for JSON parsing"
|
|
notify "Radarr cleanup failed on $(hostname) — jq not installed" "Radarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
|
|
acquire_lock "wait"
|
|
TMP_DIR="/tmp/radarr_cleanup_$$"
|
|
mkdir -p "$TMP_DIR"
|
|
trap "_release_all_locks; rm -rf $TMP_DIR" EXIT
|
|
|
|
if ! command -v docker &>/dev/null; then
|
|
error "Docker command not found"
|
|
exit 1
|
|
fi
|
|
|
|
# detect_hosts() sets MY_ID and aliases RADARR_URL, RADARR_API_KEY, RADARR_MOVIES_ROOT
|
|
detect_hosts
|
|
|
|
# Skip if Radarr is not configured on this host
|
|
if [[ -z "${RADARR_URL:-}" ]] || [[ -z "${RADARR_API_KEY:-}" ]]; then
|
|
info "Radarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
|
|
exit 0
|
|
fi
|
|
|
|
ARR_DOCKER_TIMEOUT=15
|
|
RADARR_CONTAINER="Radarr"
|
|
|
|
# Build path map from MY_ID's Radarr path map
|
|
build_arr_path_map "RADARR"
|
|
|
|
require_var RADARR_URL
|
|
require_var RADARR_API_KEY
|
|
require_var RADARR_MOVIES_ROOT
|
|
|
|
if [[ ! -d "$RADARR_MOVIES_ROOT" ]]; then
|
|
error "Movies root not found: $RADARR_MOVIES_ROOT"
|
|
notify "Radarr cleanup failed on $(hostname) — movies root not found: $RADARR_MOVIES_ROOT" \
|
|
"Radarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
log "$ICON_GEAR Config: url=${RADARR_URL} root=${RADARR_MOVIES_ROOT}"
|
|
echo " $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL"
|
|
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
|
|
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
|
|
[[ "$SKIP_AGE_CHECK" == true ]] && warn "OVERRIDE — --skip-age-check active — age check bypassed"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Status ━━━
|
|
# ==============================================================================================
|
|
if [[ "$SHOW_STATUS" == true ]]; then
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
|
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo "$ICON_GEAR Radarr URL: $RADARR_URL"
|
|
echo "$ICON_GEAR Movies root: $RADARR_MOVIES_ROOT"
|
|
echo "$ICON_TIME Orphan age: ${RADARR_ORPHAN_AGE} days"
|
|
echo "$ICON_GEAR Max delete: ${RADARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)"
|
|
echo "$ICON_GEAR Min tracked %: ${RADARR_MIN_TRACKED_PCT}%"
|
|
echo "$ICON_GEAR Radarr ver: v${RADARR_VERSION_MAJOR} expected"
|
|
echo "$ICON_GEAR Extensions: ${RADARR_EXTENSIONS[*]}"
|
|
echo "$ICON_GEAR Protected patterns: ${RADARR_PROTECTED_PATTERNS[*]}"
|
|
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
|
echo "$ICON_GEAR I know: $I_KNOW"
|
|
echo "$ICON_GEAR Skip age check: $SKIP_AGE_CHECK"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Safety Layer 1 — Container Health ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
|
|
|
|
check_container_health "$RADARR_CONTAINER" "$ARR_DOCKER_TIMEOUT" "Radarr Cleanup"
|
|
|
|
# ==============================================================================================
|
|
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
|
|
# check_container_health(), arr_api(), has_extension(), matches_pattern_list(), format_bytes() — common.sh
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Pre-flight: Radarr Import Scan ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_SYNC Pre-flight: Radarr Import Scan ━━━"
|
|
|
|
# Fetch root folders from Radarr API and translate container paths to host paths
|
|
mapfile -t SCAN_ROOTS < <(
|
|
arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "rootfolder" "Radarr" | \
|
|
jq -r '.[].path' 2>/dev/null | \
|
|
while IFS= read -r cp; do translate_path "$cp"; done
|
|
)
|
|
|
|
if [[ "${#SCAN_ROOTS[@]}" -eq 0 ]]; then
|
|
error "No root folders returned from Radarr API — aborting"
|
|
notify "Radarr cleanup aborted on $(hostname) — no root folders from API" \
|
|
"Radarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
info "Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}"
|
|
|
|
info "Triggering ProcessMonitoredDownloads pre-flight"
|
|
SCAN_PAYLOAD='{"name": "ProcessMonitoredDownloads"}'
|
|
|
|
trigger_and_await_command "$RADARR_URL" "$RADARR_API_KEY" "v3" "$SCAN_PAYLOAD" "${RADARR_IMPORT_SCAN_TIMEOUT:-600}" "radarr"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Fetch Radarr Tracked Files ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_SYNC Fetching Radarr Tracked Files ━━━"
|
|
|
|
# Safety Layer 2 — API reachability
|
|
if ! check_api "$RADARR_URL" "Radarr" 10; then
|
|
notify "Radarr cleanup aborted on $(hostname) — API unreachable" "Radarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
# Safety Layer 3 — API version check
|
|
check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr" || exit 1
|
|
|
|
info "Querying Radarr API..."
|
|
|
|
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (now kept
|
|
# current every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to
|
|
# a live fetch when it's stale, and waits out an active rescan before either. Only the movie
|
|
# list itself is cached — the per-movie moviefile data below is never cached and always live,
|
|
# since that's the actual disk-truth this script's cleanup decisions depend on.
|
|
MOVIES_RESPONSE=$(arr_get_tracked_data "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") || {
|
|
error "Failed to fetch movies from Radarr"
|
|
notify "Radarr cleanup failed on $(hostname) — could not fetch movies" \
|
|
"Radarr Cleanup" "warning"
|
|
exit 1
|
|
}
|
|
|
|
MOVIE_IDS=$(echo "$MOVIES_RESPONSE" | jq -r '.[].id' 2>/dev/null)
|
|
MOVIE_COUNT=$(echo "$MOVIE_IDS" | grep -c "." 2>/dev/null || true)
|
|
|
|
# Safety Layer 4 — movie count > 0
|
|
if [[ "$MOVIE_COUNT" -eq 0 ]]; then
|
|
error "API returned 0 movies — aborting to prevent mass deletion"
|
|
notify "Radarr cleanup aborted on $(hostname) — 0 movies returned" \
|
|
"Radarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
info "Found $MOVIE_COUNT movies — fetching movie files..."
|
|
|
|
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
|
|
> "$TRACKED_FILE"
|
|
|
|
# Fetches every movie's file path(s) fresh into TRACKED_FILE/TRACKED_MAP/TRACKED_COUNT.
|
|
# Pulled into a function so the rescan-aware retry below can re-fetch after waiting without
|
|
# duplicating this whole loop inline.
|
|
#
|
|
# Batched, not per-movie and not a single one-shot list read (2026-07-19) — see the header
|
|
# comment above for why movie.movieFile.path alone misses secondary edition files. Still a
|
|
# fresh live fetch on every call (not cache-first) — this function's whole purpose during the
|
|
# rescan-aware retry below is to see Radarr's progress as the rescan updates hasFile/
|
|
# movieFile, so it needs genuinely current data each time, not a stale snapshot.
|
|
_fetch_tracked_files() {
|
|
> "$TRACKED_FILE"
|
|
local movies_now
|
|
movies_now=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr" 2>/dev/null)
|
|
|
|
local _ids=() _id _qs="" _batch_count=0
|
|
local BATCH_SIZE=200 # 250 confirmed working live 2026-07-19; kept under that for margin
|
|
mapfile -t _ids < <(echo "$movies_now" | jq -r '.[] | select(.hasFile==true) | .id' 2>/dev/null)
|
|
|
|
{
|
|
for _id in "${_ids[@]}"; do
|
|
_qs+="movieId=${_id}&"
|
|
(( _batch_count++ ))
|
|
if [[ "$_batch_count" -ge "$BATCH_SIZE" ]]; then
|
|
arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" 2>/dev/null | \
|
|
jq -r '.[].path' 2>/dev/null
|
|
_qs=""
|
|
_batch_count=0
|
|
fi
|
|
done
|
|
if [[ -n "$_qs" ]]; then
|
|
arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" 2>/dev/null | \
|
|
jq -r '.[].path' 2>/dev/null
|
|
fi
|
|
} | while IFS= read -r api_path; do
|
|
[[ -z "$api_path" ]] && continue
|
|
translate_path "$api_path" >> "$TRACKED_FILE"
|
|
done
|
|
|
|
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
|
|
|
|
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
|
|
# Eliminates the main performance bottleneck for large libraries
|
|
unset TRACKED_MAP
|
|
declare -gA TRACKED_MAP
|
|
while IFS= read -r _tracked_path; do
|
|
[[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1
|
|
done < "$TRACKED_FILE"
|
|
unset _tracked_path
|
|
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
|
|
}
|
|
|
|
_fetch_tracked_files
|
|
info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths"
|
|
|
|
# Safety Layer 5 — tracked count > 0
|
|
if [[ "$TRACKED_COUNT" -eq 0 ]]; then
|
|
error "API returned 0 tracked files — aborting to prevent mass deletion"
|
|
notify "Radarr cleanup aborted on $(hostname) — 0 tracked files returned" \
|
|
"Radarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
info "$MOVIE_COUNT movies | $TRACKED_COUNT tracked movie files"
|
|
|
|
# Safety Layer 6 — percentage drop vs last known count, with rescan-aware retry.
|
|
# ProcessMonitoredDownloads (this script's own pre-flight) is a different, lighter operation
|
|
# than a full library rescan — but Radarr's own RescanMovie/DownloadedMoviesScan can be
|
|
# triggered independently and would cause the exact same mid-scan count dip confirmed on
|
|
# Lidarr 2026-07-16. Wait it out (calibrated to that command's own historical duration)
|
|
# before treating a drop as genuine.
|
|
_last_known=$(cat "$RADARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0)
|
|
if [[ "$_last_known" -gt 0 ]]; then
|
|
_strike=1
|
|
while [[ "$_strike" -le 3 ]]; do
|
|
_pct=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $_last_known) * 100}")
|
|
[[ "$_pct" -ge "${RADARR_MIN_TRACKED_PCT:-50}" ]] && break
|
|
|
|
_active_cmd=$(arr_active_rescan_command "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3")
|
|
[[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry
|
|
|
|
_wait=$(( $(arr_get_rescan_duration "radarr" "$_active_cmd" 300) / 2 ))
|
|
[[ "$_wait" -lt 30 ]] && _wait=30
|
|
warn "Tracked count ${_pct}% of last run, but $_active_cmd active — waiting ${_wait}s (strike ${_strike}/3)"
|
|
sleep "$_wait"
|
|
_fetch_tracked_files
|
|
(( _strike++ ))
|
|
done
|
|
|
|
if [[ "$_strike" -gt 3 ]]; then
|
|
_active_cmd=$(arr_active_rescan_command "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3")
|
|
if [[ -n "$_active_cmd" ]]; then
|
|
warn "Radarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run"
|
|
exit 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
check_tracked_count_floor "$TRACKED_COUNT" "$RADARR_TRACKED_COUNT_FILE" "$RADARR_MIN_TRACKED_PCT" "Radarr Cleanup"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Scan Movies Root ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_CLEAN Scanning Movies Root ━━━"
|
|
info "Root: $RADARR_MOVIES_ROOT | Orphan age: ${RADARR_ORPHAN_AGE} days"
|
|
|
|
START=$(date +%s)
|
|
ORPHAN_COUNT=0
|
|
JUNK_COUNT=0
|
|
RECENT_COUNT=0
|
|
PROTECTED_COUNT=0
|
|
ORPHAN_BYTES=0
|
|
JUNK_BYTES=0
|
|
|
|
AGE_SECONDS=$(( RADARR_ORPHAN_AGE * 86400 ))
|
|
NOW=$(date +%s)
|
|
|
|
# Files classified ORPHAN/JUNK below get their path recorded here, so the deletion pass can
|
|
# just delete them directly instead of re-walking and re-classifying every SCAN_ROOTS entry a
|
|
# second time (2026-07-17) — the size-threshold check below needs to know the total before
|
|
# deleting anything, not before knowing what to delete.
|
|
# Carries size and ctime alongside the path now, because the budget pass below has to order by
|
|
# age and stop at a byte ceiling — neither of which a bare path list can answer.
|
|
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%%.*}"
|
|
|
|
if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then
|
|
log "TRACKED: $filepath"
|
|
continue
|
|
fi
|
|
|
|
if matches_pattern_list "$filepath" "${RADARR_PROTECTED_PATTERNS[@]}"; then
|
|
log "$ICON_PROTECTED PROTECTED: $filepath"
|
|
(( PROTECTED_COUNT++ ))
|
|
continue
|
|
fi
|
|
|
|
if has_extension "$filepath" "${RADARR_EXTENSIONS[@]}"; then
|
|
# ctime, not mtime — an import preserves the release's original mtime, so a file
|
|
# Radarr moved in today can read as years old and skip this gate entirely.
|
|
# Measured 2026-07-27: 400 of 400 files imported that week had mtimes over 7
|
|
# days, one of them 9613 days. ctime is stamped when the file lands on this
|
|
# filesystem and cannot be carried in from an archive. This only holds because
|
|
# media_shares_permissions.sh applies owner/mode conditionally — a blanket
|
|
# chown/chmod restamps every inode nightly and would peg every file at age 0.
|
|
FILE_AGE=$(( NOW - FILE_CTIME ))
|
|
|
|
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_AGE_CHECK" != true ]]; then
|
|
log "RECENT (skipping): $filepath"
|
|
(( RECENT_COUNT++ ))
|
|
continue
|
|
fi
|
|
|
|
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
|
|
|
|
# -printf gets size + mtime directly from find's own stat() during the walk, instead of a
|
|
# separate stat fork per file (2026-07-17) — measured ~130x faster per file (0.033ms vs
|
|
# 4.3ms), since find already has to stat() every entry anyway to know it's -type f.
|
|
done < <(
|
|
for host_path in "${SCAN_ROOTS[@]}"; do
|
|
[[ -d "$host_path" ]] && find "$host_path" -type f -printf '%s %C@ %p\n' 2>/dev/null
|
|
done | sort -u
|
|
)
|
|
|
|
# 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 ━━━
|
|
# ==============================================================================================
|
|
# The ceiling is a per-run budget, not a veto. It still means what it always meant — no single run
|
|
# removes more than RADARR_MAX_DELETE_GB — but a backlog larger than the ceiling now drains over
|
|
# consecutive nights instead of failing the orchestrator forever on a queue it cannot clear.
|
|
# ── AI note (AI_ASSIST_CLEANUP) ───────────────────────────────────────────────────────────────
|
|
# Describes the shape of what was classified. It decides nothing: the eligible set, the budget and
|
|
# the strikes are all settled above and none of them read this. Switch AI_ASSIST_CLEANUP off and
|
|
# the run removes exactly the same files — the log just loses a paragraph.
|
|
#
|
|
# ctime clustering is the signal worth surfacing. A normal upgrade cycle dribbles in over weeks; a
|
|
# lump sharing one narrow ctime window with mtimes spread across months is a bulk write-back, which
|
|
# is what a partnership merge against a partner holding older copies produces. That distinction
|
|
# took a person an evening on 2026-08-26 and is the whole reason this note exists.
|
|
if [[ "$ORPHAN_COUNT" -gt 0 ]] && [[ -s "$TO_DELETE_FILE" ]]; then
|
|
_ai_ev=$(awk -F'\t' '
|
|
{ n++; bytes += $1
|
|
c = int($2)
|
|
if (cmin == 0 || c < cmin) cmin = c
|
|
if (c > cmax) cmax = c
|
|
bucket[int(c / 21600)]++ }
|
|
END {
|
|
for (b in bucket) if (bucket[b] > top) { top = bucket[b] }
|
|
printf "files=%d bytes_gb=%.1f ctime_span_hours=%.1f largest_6h_ctime_bucket=%d\n",
|
|
n, bytes/1073741824, (cmax-cmin)/3600, top
|
|
}' "$TO_DELETE_FILE")
|
|
_ai_mt=$(cut -d"$(printf '\t')" -f3 "$TO_DELETE_FILE" | head -8 \
|
|
| while IFS= read -r p; do [[ -f "$p" ]] && \
|
|
printf '%s %s\n' "$(stat -c %y "$p" 2>/dev/null | cut -c1-7)" "$(basename "$p")"; done)
|
|
|
|
_ai_note=$(ai_assist_note AI_ASSIST_CLEANUP "You are looking at files an automated media-library cleanup has classified for deletion on an Unraid server. They are files on disk that the Radarr database no longer references.
|
|
|
|
EVIDENCE
|
|
$_ai_ev
|
|
sample (modification month, then path):
|
|
$_ai_mt
|
|
|
|
A normal quality-upgrade cycle produces orphans whose ctimes are spread out over weeks, because each upgrade happens on its own day. A bulk event - a sync or restore writing files back onto this host - produces orphans sharing one narrow ctime window while their modification times stay spread across months, because the copy preserves modification time but resets ctime.
|
|
|
|
In no more than three sentences, say which of those two this looks like and name the numbers above that support it. Do not recommend an action. Do not speculate beyond the evidence given.") || _ai_note=""
|
|
|
|
if [[ -n "$_ai_note" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_GEAR AI note on this classification ━━━"
|
|
printf '%s\n' "$_ai_note"
|
|
fi
|
|
unset _ai_ev _ai_mt
|
|
fi
|
|
|
|
BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt"
|
|
|
|
if [[ "$I_KNOW" == true ]]; then
|
|
warn "OVERRIDE — --i-know-what-im-doing active, per-run budget not applied"
|
|
cut -d"$(printf '\t')" -f3- "$TO_DELETE_FILE" > "$BUDGET_FILE"
|
|
_BUDGET_KEPT_COUNT=$TOTAL_REMOVED; _BUDGET_KEPT_BYTES=$TOTAL_DELETE_BYTES
|
|
_BUDGET_DEFERRED_COUNT=0; _BUDGET_DEFERRED_BYTES=0; _BUDGET_STUCK=""
|
|
else
|
|
apply_delete_budget "$TO_DELETE_FILE" "$BUDGET_FILE" "$RADARR_MAX_DELETE_GB"
|
|
|
|
if [[ -n "$_BUDGET_STUCK" ]]; then
|
|
# One file larger than the whole budget can never fit, so it would be re-found and
|
|
# re-deferred every night. Name it rather than loop on it silently.
|
|
error "Single file exceeds the ${RADARR_MAX_DELETE_GB}GB budget on its own — nothing removed this run"
|
|
error " $_BUDGET_STUCK"
|
|
error "Raise RADARR_MAX_DELETE_GB or clear this one with --i-know-what-im-doing"
|
|
notify "Radarr cleanup stalled on $(hostname) — one file exceeds the ${RADARR_MAX_DELETE_GB}GB budget" \
|
|
"Radarr Cleanup" "warning"
|
|
elif [[ "$_BUDGET_DEFERRED_COUNT" -gt 0 ]]; then
|
|
warn "Budget ${RADARR_MAX_DELETE_GB}GB — removing $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED ($(format_bytes "$_BUDGET_KEPT_BYTES")), deferring $_BUDGET_DEFERRED_COUNT ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) to the next run"
|
|
warn "Oldest first — the deferred files are the newest and are re-evaluated tomorrow"
|
|
notify "Radarr cleanup removed $(format_bytes "$_BUDGET_KEPT_BYTES") of $(format_bytes "$TOTAL_DELETE_BYTES") on $(hostname) — $_BUDGET_DEFERRED_COUNT file(s) deferred to the next run" \
|
|
"Radarr Cleanup" "normal"
|
|
fi
|
|
fi
|
|
|
|
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
|
|
# Reuses TO_DELETE_FILE from the classification pass above instead of re-walking and
|
|
# re-classifying every SCAN_ROOTS entry again.
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
while IFS= read -r filepath; do
|
|
[[ -z "$filepath" ]] && continue
|
|
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
|
|
done < "$BUDGET_FILE"
|
|
|
|
info "Cleaning up empty folders..."
|
|
for host_path in "${SCAN_ROOTS[@]}"; do
|
|
[[ -d "$host_path" ]] && \
|
|
find "$host_path" -mindepth 1 -type d -empty -delete 2>/dev/null
|
|
done
|
|
info "Empty folders removed"
|
|
fi
|
|
|
|
END=$(date +%s)
|
|
|
|
ORPHAN_HUMAN=$(format_bytes "$ORPHAN_BYTES")
|
|
JUNK_HUMAN=$(format_bytes "$JUNK_BYTES")
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Summary ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY RADARR CLEANUP SUMMARY ━━━━━"
|
|
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo "$ICON_SYNC Tracked: $TRACKED_COUNT files ($MOVIE_COUNT movies)"
|
|
echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles, metadata)"
|
|
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 )))"
|
|
echo ""
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — no files deleted"
|
|
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
|
|
echo "$ICON_DONE Clean — nothing to remove"
|
|
else
|
|
# What was actually removed, not what was classified. With a budget in force those differ, and
|
|
# reporting the classification as the outcome is the oldest bug shape in this codebase.
|
|
warn "$ICON_DONE Removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED classified files ($(format_bytes "$_BUDGET_KEPT_BYTES"))"
|
|
notify "Radarr cleanup on $(hostname) — removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED classified files ($(format_bytes "$_BUDGET_KEPT_BYTES"))$([[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && echo ", $_BUDGET_DEFERRED_COUNT deferred")" \
|
|
"Radarr Cleanup" "warning"
|
|
# Notify Emby to clean missing files — removes ghost entries immediately
|
|
notify_emby_scan
|
|
fi
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
# Write stats for sunday_morning_coffee_report.sh
|
|
if [[ "$DRY_RUN" == false ]] && [[ -n "${ARR_CLEANUP_STATS:-}" ]]; then
|
|
echo "$(date '+%Y-%m-%d')|radarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}" \
|
|
>> "$ARR_CLEANUP_STATS" 2>/dev/null || true
|
|
fi
|
|
|
|
exit 0
|