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.
521 lines
24 KiB
Bash
Executable File
521 lines
24 KiB
Bash
Executable File
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ================================= Sonarr Cleanup =============================================
|
|
# ==============================================================================================
|
|
#
|
|
# PURPOSE
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Delete orphaned TV episode files not tracked by Sonarr. Queries the API for
|
|
# all tracked episode 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 Sonarr's own
|
|
# RescanSeries/DownloadedEpisodesScan is active (independently of this script's own
|
|
# lighter ProcessMonitoredDownloads pre-flight — e.g. during a large missing-episode
|
|
# search campaign), 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 — Sonarr API knows this exact path → leave it alone
|
|
# PROTECTED — matches SONARR_PROTECTED_PATTERNS → never delete
|
|
# ORPHAN — video file, not tracked, older than SONARR_ORPHAN_AGE → delete
|
|
# JUNK — not a video extension, not protected → delete regardless of age
|
|
# RECENT — not tracked, under SONARR_ORPHAN_AGE → skip (may be mid-import)
|
|
#
|
|
# Sonarr generates show 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 Sonarr 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 Sonarr tracks is authoritative. Files not in the API response are
|
|
# orphans — Sonarr 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 SONARR_ORPHAN_AGE are left alone regardless of tracked status.
|
|
# Sonarr'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 SONARR_VERSION_MAJOR in master.conf
|
|
# 4. Series count > 0
|
|
# 5. Tracked file count > 0
|
|
# 6. Tracked count >= SONARR_MIN_TRACKED_PCT % of last known count
|
|
# 7. Deletion size < SONARR_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
|
|
# ==============================================================================================
|
|
#
|
|
# SONARR_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*_SONARR_URL / HOST*_SONARR_API_KEY / HOST*_SONARR_TV_ROOT
|
|
# HOST*_SONARR_PATH_MAP — container path → host path translation
|
|
# All aliased by detect_hosts() — script uses unprefixed names
|
|
#
|
|
# master.conf
|
|
#
|
|
# SONARR_ORPHAN_AGE — days before untracked file eligible for deletion
|
|
# SONARR_MAX_DELETE_GB — require --i-know-what-im-doing above this
|
|
# SONARR_MIN_TRACKED_PCT — abort if tracked count drops below this % of last run
|
|
# SONARR_TRACKED_COUNT_FILE — persistent baseline file path
|
|
# SONARR_EXTENSIONS — video file extensions for orphan classification
|
|
# SONARR_PROTECTED_PATTERNS — file patterns never deleted
|
|
# SONARR_VERSION_MAJOR — expected Sonarr major version for API safety check
|
|
# SONARR_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
|
|
# ==============================================================================================
|
|
#
|
|
# sonarr_cleanup.sh — normal run
|
|
# sonarr_cleanup.sh --dry-run — preview, no deletions
|
|
# sonarr_cleanup.sh --log — verbose output
|
|
# sonarr_cleanup.sh --status — show config and exit
|
|
# sonarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
|
|
# sonarr_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 Sonarr API calls"
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
error "jq not found — required for JSON parsing"
|
|
notify "Sonarr cleanup failed on $(hostname) — jq not installed" "Sonarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
|
|
acquire_lock "wait"
|
|
TMP_DIR="/tmp/sonarr_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 SONARR_URL, SONARR_API_KEY, SONARR_TV_ROOT
|
|
detect_hosts
|
|
|
|
# Skip if Sonarr is not configured on this host
|
|
if [[ -z "${SONARR_URL:-}" ]] || [[ -z "${SONARR_API_KEY:-}" ]]; then
|
|
info "Sonarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
|
|
exit 0
|
|
fi
|
|
|
|
ARR_DOCKER_TIMEOUT=15
|
|
SONARR_CONTAINER="Sonarr"
|
|
|
|
# Build path map from MY_ID's Sonarr path map
|
|
build_arr_path_map "SONARR"
|
|
|
|
require_var SONARR_URL
|
|
require_var SONARR_API_KEY
|
|
require_var SONARR_TV_ROOT
|
|
|
|
if [[ ! -d "$SONARR_TV_ROOT" ]]; then
|
|
error "TV root not found: $SONARR_TV_ROOT"
|
|
notify "Sonarr cleanup failed on $(hostname) — TV root not found: $SONARR_TV_ROOT" \
|
|
"Sonarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
log "$ICON_GEAR Config: url=${SONARR_URL} root=${SONARR_TV_ROOT}"
|
|
echo " $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_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 Sonarr URL: $SONARR_URL"
|
|
echo "$ICON_GEAR TV root: $SONARR_TV_ROOT"
|
|
echo "$ICON_TIME Orphan age: ${SONARR_ORPHAN_AGE} days"
|
|
echo "$ICON_GEAR Max delete: ${SONARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)"
|
|
echo "$ICON_GEAR Min tracked %: ${SONARR_MIN_TRACKED_PCT}%"
|
|
echo "$ICON_GEAR Sonarr ver: v${SONARR_VERSION_MAJOR} expected"
|
|
echo "$ICON_GEAR Extensions: ${SONARR_EXTENSIONS[*]}"
|
|
echo "$ICON_GEAR Protected patterns: ${SONARR_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 "$SONARR_CONTAINER" "$ARR_DOCKER_TIMEOUT" "Sonarr Cleanup"
|
|
|
|
# ==============================================================================================
|
|
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
|
|
# check_container_health(), arr_api(), has_extension(), matches_pattern_list(), format_bytes() — common.sh
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Pre-flight: Sonarr Import Scan ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_SYNC Pre-flight: Sonarr Import Scan ━━━"
|
|
|
|
# Fetch root folders from Sonarr API and translate container paths to host paths
|
|
mapfile -t SCAN_ROOTS < <(
|
|
arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "rootfolder" "Sonarr" | \
|
|
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 Sonarr API — aborting"
|
|
notify "Sonarr cleanup aborted on $(hostname) — no root folders from API" \
|
|
"Sonarr 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 "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SCAN_PAYLOAD" "${SONARR_IMPORT_SCAN_TIMEOUT:-600}" "sonarr"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Fetch Sonarr Tracked Files ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_SYNC Fetching Sonarr Tracked Files ━━━"
|
|
|
|
# Safety Layer 2 — API reachability
|
|
if ! check_api "$SONARR_URL" "Sonarr" 10; then
|
|
notify "Sonarr cleanup aborted on $(hostname) — API unreachable" "Sonarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
# Safety Layer 3 — API version check
|
|
check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "Sonarr" || exit 1
|
|
|
|
info "Querying Sonarr 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 series
|
|
# list itself is cached — the per-series episodeFile data below is never cached and always
|
|
# live, since that's the actual disk-truth this script's cleanup decisions depend on.
|
|
SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || {
|
|
error "Failed to fetch series from Sonarr"
|
|
notify "Sonarr cleanup failed on $(hostname) — could not fetch series" \
|
|
"Sonarr Cleanup" "warning"
|
|
exit 1
|
|
}
|
|
|
|
SERIES_IDS=$(echo "$SERIES_RESPONSE" | jq -r '.[].id' 2>/dev/null)
|
|
SERIES_COUNT=$(echo "$SERIES_IDS" | grep -c "." 2>/dev/null || echo 0)
|
|
|
|
# Safety Layer 4 — series count > 0
|
|
if [[ "$SERIES_COUNT" -eq 0 ]]; then
|
|
error "API returned 0 series — aborting to prevent mass deletion"
|
|
notify "Sonarr cleanup aborted on $(hostname) — 0 series returned" \
|
|
"Sonarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
info "Found $SERIES_COUNT series — fetching episode files..."
|
|
|
|
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
|
|
> "$TRACKED_FILE"
|
|
|
|
# Fetches every series' episode-file paths 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.
|
|
_fetch_tracked_files() {
|
|
> "$TRACKED_FILE"
|
|
local _series_index=0
|
|
while IFS= read -r series_id; do
|
|
[[ -z "$series_id" ]] && continue
|
|
(( _series_index++ ))
|
|
[[ $(( _series_index % 50 )) -eq 0 ]] && \
|
|
log "Fetching files: $_series_index/$SERIES_COUNT series..."
|
|
SERIES_FILES=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "episodefile?seriesId=${series_id}" "Sonarr" 2>/dev/null)
|
|
if [[ -n "$SERIES_FILES" ]]; then
|
|
while IFS= read -r api_path; do
|
|
[[ -z "$api_path" ]] && continue
|
|
translate_path "$api_path" >> "$TRACKED_FILE"
|
|
done < <(echo "$SERIES_FILES" | jq -r '.[].path' 2>/dev/null)
|
|
fi
|
|
done <<< "$SERIES_IDS"
|
|
|
|
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
|
|
|
|
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
|
|
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 "Sonarr cleanup aborted on $(hostname) — 0 tracked files returned" \
|
|
"Sonarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
info "$SERIES_COUNT series | $TRACKED_COUNT tracked episode 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 Sonarr's own RescanSeries/DownloadedEpisodesScan can be
|
|
# triggered independently (e.g. during a large missing-episode search campaign) 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 "$SONARR_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 "${SONARR_MIN_TRACKED_PCT:-50}" ]] && break
|
|
|
|
_active_cmd=$(arr_active_rescan_command "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3")
|
|
[[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry
|
|
|
|
_wait=$(( $(arr_get_rescan_duration "sonarr" "$_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 "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3")
|
|
if [[ -n "$_active_cmd" ]]; then
|
|
warn "Sonarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run"
|
|
exit 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
check_tracked_count_floor "$TRACKED_COUNT" "$SONARR_TRACKED_COUNT_FILE" "$SONARR_MIN_TRACKED_PCT" "Sonarr Cleanup"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Scan TV Root ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_CLEAN Scanning TV Root ━━━"
|
|
info "Root: $SONARR_TV_ROOT | Orphan age: ${SONARR_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=$(( SONARR_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" "${SONARR_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" "${SONARR_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" "$SONARR_MAX_DELETE_GB" "Sonarr 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 SONARR CLEANUP SUMMARY ━━━━━"
|
|
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo "$ICON_SYNC Tracked: $TRACKED_COUNT files ($SERIES_COUNT series)"
|
|
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 ${SONARR_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 "Sonarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
|
|
"Sonarr 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')|sonarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}" \
|
|
>> "$ARR_CLEANUP_STATS" 2>/dev/null || true
|
|
fi
|
|
|
|
exit 0 |