lidarr_cleanup.sh/sonarr_cleanup.sh/radarr_cleanup.sh each walked their full media root twice per run: once to classify files and total the deletion size for the safety-threshold check, then again to actually delete. The size check needs to know the total before deleting anything, not before knowing what to delete -- the classification pass now records orphan/junk paths as it finds them, and the deletion pass just reads that list instead of re-walking and re-classifying the whole tree again. Only affects real (non-dry-run) executions, where the second walk used to happen. Also merges two separate stat calls per file into one.
522 lines
24 KiB
Bash
Executable File
522 lines
24 KiB
Bash
Executable File
#!/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.
|
|
#
|
|
# ==============================================================================================
|
|
# 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.
|
|
#
|
|
# 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
|
|
# platform_require_cmd — notify script validated before use
|
|
# 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 || echo 0)
|
|
|
|
# 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 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.
|
|
#
|
|
# Radarr's movie list already embeds movieFile.path directly on every entry with
|
|
# hasFile=true (confirmed live 2026-07-17, zero exceptions across the full library) —
|
|
# unlike Lidarr/Sonarr, where trackFile/episodefile genuinely require a separate per-item
|
|
# call. A single live movie-list fetch replaces what used to be one API call per movie
|
|
# (2896 of them) for the same data already sitting in that response. 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)
|
|
while IFS= read -r api_path; do
|
|
[[ -z "$api_path" ]] && continue
|
|
translate_path "$api_path" >> "$TRACKED_FILE"
|
|
done < <(echo "$movies_now" | jq -r '.[] | select(.hasFile==true) | .movieFile.path' 2>/dev/null)
|
|
|
|
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.
|
|
TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt"
|
|
> "$TO_DELETE_FILE"
|
|
|
|
while IFS= read -r filepath; do
|
|
[[ -z "$filepath" ]] && continue
|
|
|
|
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
|
|
|
|
# Single stat call for both fields instead of two separate subprocess invocations.
|
|
read -r FILE_SIZE FILE_MTIME < <(stat -c '%s %Y' "$filepath" 2>/dev/null || echo "0 0")
|
|
|
|
if has_extension "$filepath" "${RADARR_EXTENSIONS[@]}"; then
|
|
FILE_AGE=$(( NOW - FILE_MTIME ))
|
|
|
|
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 ))
|
|
echo "$filepath" >> "$TO_DELETE_FILE"
|
|
else
|
|
log "JUNK: $filepath"
|
|
(( JUNK_COUNT++ ))
|
|
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
|
|
echo "$filepath" >> "$TO_DELETE_FILE"
|
|
fi
|
|
|
|
done < <(
|
|
for host_path in "${SCAN_ROOTS[@]}"; do
|
|
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
|
|
done | sort -u
|
|
)
|
|
|
|
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
|
|
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━
|
|
# ==============================================================================================
|
|
check_delete_size_threshold "$TOTAL_DELETE_BYTES" "$RADARR_MAX_DELETE_GB" "Radarr Cleanup"
|
|
|
|
# ── 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 < "$TO_DELETE_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)"
|
|
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
|
|
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
|
|
notify "Radarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
|
|
"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 |