#!/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. # # Cache-first, both layers (2026-07-17). The series list itself 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. The per-series episodefile walk below still # always fetches live (that's the actual disk-truth this script's delete decisions depend # on), but write-throughs its result to arr_item_cache_write() for any future script that # needs Sonarr's per-episode data — no second consumer exists yet, unlike Lidarr's # lidarr_missing_art.sh, but the data's there once one does. 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+mtime 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 — 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. # # Write-through — also caches the raw per-episode data via arr_item_cache_write() (2026-07-17) # for any future script that needs Sonarr's per-episode file data — none exist yet (unlike # Lidarr, where lidarr_missing_art.sh already reads this), but the walk is happening regardless # for our own cleanup decisions, so the cache write is free by comparison. Future consumers: # call arr_get_cached_items("sonarr") first, fall back to your own live walk on a miss. _fetch_tracked_files() { > "$TRACKED_FILE" local _series_index=0 local all_episodes_tmp all_episodes_tmp=$(mktemp) 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 echo "$SERIES_FILES" >> "$all_episodes_tmp" 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" arr_item_cache_write "sonarr" "$(jq -s 'add // []' "$all_episodes_tmp" 2>/dev/null)" rm -f "$all_episodes_tmp" 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 read -r FILE_SIZE FILE_MTIME filepath; do [[ -z "$filepath" ]] && continue FILE_MTIME="${FILE_MTIME%%.*}" 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 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 # -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 %T@ %p\n' 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