#!/bin/bash # ============================================================================================== # ================================= Lidarr Cleanup ============================================= # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Delete orphaned music files not tracked by Lidarr. Queries the API for all # tracked 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. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Every file encountered on disk is classified into one of five categories: # # TRACKED — Lidarr API knows this exact path → leave it alone # PROTECTED — matches LIDARR_PROTECTED_PATTERNS → never delete # ORPHAN — music file, not tracked, older than LIDARR_ORPHAN_AGE → delete # JUNK — not a music extension, not protected → delete regardless of age # RECENT — not tracked, under LIDARR_ORPHAN_AGE → skip (may be mid-import) # # Lidarr generates cover art (*.jpg), metadata (*.nfo), and lyrics (*.lrc) but # does NOT include these in its tracked file API response. Without PROTECTED # classification these would be deleted — breaking Lidarr and Emby display. # # ============================================================================================== # 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 LIDARR_VERSION_MAJOR in master.conf # 4. Artist count > 0 # 5. Tracked file count > 0 # 6. Tracked count >= LIDARR_MIN_TRACKED_PCT % of last known count # 7. Deletion size < LIDARR_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 # DOCKER_TIMEOUT — container checks protected against daemon hangs # Duplicate detection — temp file of tracked paths, grep before delete # validate_unraid_cmd — notify script validated before use # Silent by default — orphans/junk warn(), clean library logs silently # # ============================================================================================== # STATE FILES # ============================================================================================== # # LIDARR_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 # ============================================================================================== # # master_host*.conf # # HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY / HOST1_LIDARR_MUSIC_ROOT # HOST1_LIDARR_PATH_MAP — container path → host path translation # All aliased by detect_hosts() — script uses unprefixed names # # master.conf # # LIDARR_ORPHAN_AGE — days before untracked file is eligible for deletion # LIDARR_MAX_DELETE_GB — require --i-know-what-im-doing above this # LIDARR_MIN_TRACKED_PCT — abort if tracked count drops below this % of last run # LIDARR_TRACKED_COUNT_FILE — persistent baseline file path # LIDARR_EXTENSIONS — music file extensions for orphan classification # LIDARR_PROTECTED_PATTERNS — file patterns that are never deleted # LIDARR_VERSION_MAJOR — expected Lidarr major version for API safety check # LIDARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600) # LIDARR_LOCK_WARN_AGE — override default lock warning age (large libraries) # ARR_CLEANUP_STATS — stats file path (read by coffee report) # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # lidarr_cleanup.sh — normal run # lidarr_cleanup.sh --dry-run — preview, no deletions # lidarr_cleanup.sh --log — verbose output # lidarr_cleanup.sh --status — show config and exit # lidarr_cleanup.sh --i-know-what-im-doing — bypass size threshold # lidarr_cleanup.sh --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE # # NUCLEAR MODE: both flags bypass age check AND size threshold. Use when Soularr # has filled the gaps and you want a clean one-pass wipe. 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 ─────────────────────────────────────────────────────────────── # Filter --i-know-what-im-doing and --skip-strike-list before parse_args # to avoid unknown flag errors — these are handled separately below. I_KNOW=false SKIP_STRIKES=false FILTERED_ARGS=() for arg in "$@"; do case "$arg" in --i-know-what-im-doing) I_KNOW=true ;; --skip-strike-list) SKIP_STRIKES=true ;; *) FILTERED_ARGS+=("$arg") ;; esac done parse_args "${FILTERED_ARGS[@]}" # ── Nuclear mode warning ────────────────────────────────────────────────────────────────────── if [[ "$I_KNOW" == true ]] && [[ "$SKIP_STRIKES" == true ]] && [[ "$DRY_RUN" != true ]]; then echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "⚠️ WARNING — NUCLEAR MODE ACTIVE" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Flags: --i-know-what-im-doing --skip-strike-list" echo " Strike system: BYPASSED — deletes on first pass" echo " Size threshold: BYPASSED — no GB limit" echo " Data recovery: NOT POSSIBLE after deletion" echo "" echo " Review --dry-run output before proceeding." echo " You have 10 seconds to cancel (Ctrl+C)..." echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" sleep 10 echo " Proceeding..." echo "" fi # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_GEAR Setup ━━━" if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi # Tool validation — both required, fail fast if ! command -v curl >/dev/null 2>&1; then error "curl not found — required for Lidarr API calls" exit 1 fi if ! command -v jq >/dev/null 2>&1; then error "jq not found — required for JSON parsing" notify "Lidarr cleanup failed on $(hostname) — jq not installed" "Lidarr Cleanup" "warning" exit 1 fi validate_unraid_cmd \ "/usr/local/emhttp/plugins/dynamix/scripts/notify" \ "" "" \ "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled" # Lock before detect_hosts — large library scans take time, wait mode appropriate [[ -n "${LIDARR_LOCK_WARN_AGE:-}" ]] && LOCK_WARN_AGE="$LIDARR_LOCK_WARN_AGE" acquire_lock "wait" # detect_hosts() sets MY_ID and aliases LIDARR_URL, LIDARR_API_KEY, LIDARR_MUSIC_ROOT detect_hosts # Skip if Lidarr is not configured on this host if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then log "Lidarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping" exit 0 fi DOCKER_TIMEOUT=15 LIDARR_CONTAINER="Lidarr" # container name on HOST1 # Build path map from HOST1_LIDARR_PATH_MAP for translate_path() declare -A ARR_PATH_MAP for key in "${!HOST1_LIDARR_PATH_MAP[@]}"; do ARR_PATH_MAP["$key"]="${HOST1_LIDARR_PATH_MAP[$key]}" done # Validate required vars — detect_hosts() should have set these require_var LIDARR_URL require_var LIDARR_API_KEY require_var LIDARR_MUSIC_ROOT if [[ ! -d "$LIDARR_MUSIC_ROOT" ]]; then error "Music root not found: $LIDARR_MUSIC_ROOT" notify "Lidarr cleanup failed on $(hostname) — music root not found: $LIDARR_MUSIC_ROOT" \ "Lidarr Cleanup" "warning" exit 1 fi echo " $MY_ID ($LOCAL_SERVER_NAME) — $LIDARR_URL" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted" [[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active" [[ "$SKIP_STRIKES" == true ]] && warn "OVERRIDE — --skip-strike-list 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 Lidarr URL: $LIDARR_URL" echo "$ICON_GEAR Music root: $LIDARR_MUSIC_ROOT" echo "$ICON_TIME Orphan age: ${LIDARR_ORPHAN_AGE} days" echo "$ICON_GEAR Max delete: ${LIDARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)" echo "$ICON_GEAR Min tracked %: ${LIDARR_MIN_TRACKED_PCT}%" echo "$ICON_GEAR Lidarr ver: v${LIDARR_VERSION_MAJOR} expected" echo "$ICON_GEAR Extensions: ${LIDARR_EXTENSIONS[*]}" echo "$ICON_GEAR Protected patterns: ${LIDARR_PROTECTED_PATTERNS[*]}" echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "$ICON_GEAR I know: $I_KNOW" echo "$ICON_GEAR Skip strikes: $SKIP_STRIKES" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ━━━ Safety Layer 1 — Container Health ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_SHIELD Safety Checks ━━━" CONTAINER_RUNNING=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \ '{{.State.Running}}' "$LIDARR_CONTAINER" 2>/dev/null) if [[ "$CONTAINER_RUNNING" != "true" ]]; then error "$LIDARR_CONTAINER is not running — aborting" notify "Lidarr cleanup aborted on $(hostname) — container not running" \ "Lidarr Cleanup" "warning" exit 1 fi CONTAINER_HEALTH=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \ '{{.State.Health.Status}}' "$LIDARR_CONTAINER" 2>/dev/null) case "$CONTAINER_HEALTH" in healthy) log "$LIDARR_CONTAINER is healthy" ;; "") log "$LIDARR_CONTAINER has no health check — proceeding" ;; starting) error "$LIDARR_CONTAINER is still starting — aborting" notify "Lidarr cleanup aborted on $(hostname) — container still starting" \ "Lidarr Cleanup" "warning" exit 1 ;; unhealthy) error "$LIDARR_CONTAINER is unhealthy — aborting" notify "Lidarr cleanup aborted on $(hostname) — container unhealthy" \ "Lidarr Cleanup" "warning" exit 1 ;; *) warn "$LIDARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution" ;; esac log "Safety layer 1 passed — container healthy" # ============================================================================================== # ── HELPER FUNCTIONS ────────────────────────────────────────────────────────────────────────── # ============================================================================================== # Lidarr API call with HTTP status check # Usage: lidarr_api "artist" | lidarr_api "trackFile?artistId=123" lidarr_api() { local endpoint="$1" local response http_code body response=$(curl -sf \ --max-time 30 \ -H "X-Api-Key: $LIDARR_API_KEY" \ -w "\n%{http_code}" \ "${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null) http_code=$(echo "$response" | tail -1) body=$(echo "$response" | head -n -1) if [[ "$http_code" != "200" ]]; then error "Lidarr API HTTP $http_code for: $endpoint" return 1 fi echo "$body" } # Check if a file extension is a tracked music format is_music_file() { local ext="${1##*.}" ext="${ext,,}" for valid_ext in "${LIDARR_EXTENSIONS[@]}"; do [[ "$ext" == "$valid_ext" ]] && return 0 done return 1 } # Check if a file matches any protected pattern is_protected_file() { local filename filename=$(basename "$1") for pattern in "${LIDARR_PROTECTED_PATTERNS[@]}"; do # shellcheck disable=SC2254 case "$filename" in $pattern) return 0 ;; esac done return 1 } # ============================================================================================== # ━━━ Pre-flight: Lidarr Import Scan ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_SYNC Pre-flight: Lidarr Import Scan ━━━" # Reverse-lookup container path from path map so Lidarr gets its own path, not the host path LIDARR_CONTAINER_ROOT="" for _cp in "${!ARR_PATH_MAP[@]}"; do if [[ "${ARR_PATH_MAP[$_cp]}" == "$LIDARR_MUSIC_ROOT" ]]; then LIDARR_CONTAINER_ROOT="$_cp" break fi done unset _cp if [[ -n "$LIDARR_CONTAINER_ROOT" ]]; then log "Triggering DownloadedAlbumsScan on: $LIDARR_CONTAINER_ROOT" SCAN_PAYLOAD="{\"name\": \"DownloadedAlbumsScan\", \"path\": \"$LIDARR_CONTAINER_ROOT\"}" else log "No path map match — triggering DownloadedAlbumsScan (all root folders)" SCAN_PAYLOAD='{"name": "DownloadedAlbumsScan"}' fi SCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \ -H "X-Api-Key: $LIDARR_API_KEY" \ -H "Content-Type: application/json" \ -d "$SCAN_PAYLOAD" \ "${LIDARR_URL}/api/v1/command" 2>/dev/null) SCAN_CMD_ID=$(echo "$SCAN_RESPONSE" | jq -r '.id // empty' 2>/dev/null) if [[ -z "$SCAN_CMD_ID" ]]; then warn "Could not trigger import scan — proceeding without pre-flight" else echo " Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion..." POLL_TIMEOUT=${LIDARR_IMPORT_SCAN_TIMEOUT:-600} POLLED=0 while [[ "$POLLED" -lt "$POLL_TIMEOUT" ]]; do SCAN_STATUS=$(curl -sf --max-time 10 \ -H "X-Api-Key: $LIDARR_API_KEY" \ "${LIDARR_URL}/api/v1/command/${SCAN_CMD_ID}" 2>/dev/null | \ jq -r '.status // empty' 2>/dev/null) case "$SCAN_STATUS" in completed) log "Import scan complete ✅"; break ;; failed) warn "Import scan reported failed — proceeding anyway"; break ;; esac sleep 10 (( POLLED += 10 )) [[ $(( POLLED % 60 )) -eq 0 ]] && log " Still scanning... (${POLLED}s elapsed)" done [[ "$POLLED" -ge "$POLL_TIMEOUT" ]] && \ warn "Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway" fi # ============================================================================================== # ━━━ Fetch Lidarr Tracked Files ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_SYNC Fetching Lidarr Tracked Files ━━━" # Safety Layer 2 — API reachability if ! check_api "$LIDARR_URL" "Lidarr" 10; then notify "Lidarr cleanup aborted on $(hostname) — API unreachable" "Lidarr Cleanup" "warning" exit 1 fi # Safety Layer 3 — API version check check_arr_version "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$LIDARR_VERSION_MAJOR" "Lidarr" || exit 1 echo " Querying Lidarr API..." # Fetch all artists ARTIST_RESPONSE=$(lidarr_api "artist") || { error "Failed to fetch artists from Lidarr" notify "Lidarr cleanup failed on $(hostname) — could not fetch artists" \ "Lidarr Cleanup" "warning" exit 1 } ARTIST_IDS=$(echo "$ARTIST_RESPONSE" | jq -r '.[].id' 2>/dev/null) ARTIST_COUNT=$(echo "$ARTIST_IDS" | grep -c "[0-9]" 2>/dev/null || echo 0) # Safety Layer 4 — artist count > 0 if [[ "$ARTIST_COUNT" -eq 0 ]]; then error "API returned 0 artists — aborting to prevent mass deletion" notify "Lidarr cleanup aborted on $(hostname) — 0 artists returned" \ "Lidarr Cleanup" "warning" exit 1 fi log "Found $ARTIST_COUNT artists — fetching track files..." TMP_DIR="/tmp/lidarr_cleanup_$$" mkdir -p "$TMP_DIR" trap "rm -rf $TMP_DIR" EXIT TRACKED_FILE="$TMP_DIR/tracked_paths.txt" > "$TRACKED_FILE" while IFS= read -r artist_id; do [[ -z "$artist_id" ]] && continue ARTIST_TRACKS=$(lidarr_api "trackFile?artistId=${artist_id}" 2>/dev/null) if [[ -n "$ARTIST_TRACKS" ]]; then while IFS= read -r api_path; do [[ -z "$api_path" ]] && continue translate_path "$api_path" >> "$TRACKED_FILE" done < <(echo "$ARTIST_TRACKS" | jq -r '.[].path' 2>/dev/null) fi done <<< "$ARTIST_IDS" 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 declare -A TRACKED_MAP while IFS= read -r _tracked_path; do [[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1 done < "$TRACKED_FILE" unset _tracked_path log "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths" TRACKED_COUNT=$(wc -l < "$TRACKED_FILE") # Safety Layer 5 — tracked count > 0 if [[ "$TRACKED_COUNT" -eq 0 ]]; then error "API returned 0 tracked files — aborting to prevent mass deletion" notify "Lidarr cleanup aborted on $(hostname) — 0 tracked files returned" \ "Lidarr Cleanup" "warning" exit 1 fi echo " $ARTIST_COUNT artists | $TRACKED_COUNT tracked files" # Safety Layer 6 — percentage drop vs last known count if [[ -f "$LIDARR_TRACKED_COUNT_FILE" ]]; then LAST_COUNT=$(cat "$LIDARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0) if [[ "$LAST_COUNT" -gt 0 ]]; then PCT=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $LAST_COUNT) * 100}") if [[ "$PCT" -lt "$LIDARR_MIN_TRACKED_PCT" ]]; then error "Tracked count dropped to ${PCT}% of last run ($TRACKED_COUNT vs $LAST_COUNT)" error "Suggests API issue — aborting to prevent mass deletion" error "If expected (large library removal) delete: $LIDARR_TRACKED_COUNT_FILE" notify "Lidarr cleanup aborted on $(hostname) — tracked count dropped to ${PCT}%" \ "Lidarr Cleanup" "warning" exit 1 fi log "Tracked count: ${PCT}% of last run ($TRACKED_COUNT vs $LAST_COUNT) ✅" fi else log "No previous count on record — first run, saving baseline" fi echo "$TRACKED_COUNT" > "$LIDARR_TRACKED_COUNT_FILE" # ============================================================================================== # ━━━ Scan Music Root ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_CLEAN Scanning Music Root ━━━" echo " Root: $LIDARR_MUSIC_ROOT | Orphan age: ${LIDARR_ORPHAN_AGE} days" echo "" START=$(date +%s) ORPHAN_COUNT=0 JUNK_COUNT=0 RECENT_COUNT=0 PROTECTED_COUNT=0 ORPHAN_BYTES=0 JUNK_BYTES=0 AGE_SECONDS=$(( LIDARR_ORPHAN_AGE * 86400 )) NOW=$(date +%s) MAX_DELETE_BYTES=$(awk "BEGIN {printf \"%d\", $LIDARR_MAX_DELETE_GB * 1073741824}") while IFS= read -r filepath; do [[ -z "$filepath" ]] && continue # Tracked — leave alone if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then log "TRACKED: $filepath" continue fi # Protected — never delete if is_protected_file "$filepath"; then log "$ICON_PROTECTED PROTECTED: $filepath" (( PROTECTED_COUNT++ )) continue fi FILE_SIZE=$(stat -c%s "$filepath" 2>/dev/null || echo 0) if is_music_file "$filepath"; then FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0) FILE_AGE=$(( NOW - FILE_MTIME )) if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_STRIKES" != true ]]; then log "RECENT (skipping): $filepath" (( RECENT_COUNT++ )) continue fi warn "$ICON_TRASH ORPHAN: $filepath" (( ORPHAN_COUNT++ )) ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE )) else log "JUNK: $filepath" (( JUNK_COUNT++ )) JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE )) fi done < <(find "$LIDARR_MUSIC_ROOT" -type f 2>/dev/null) TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES )) TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT )) # ============================================================================================== # ━━━ Safety Layer 7 — Deletion Size Threshold ━━━ # ============================================================================================== if [[ "$TOTAL_DELETE_BYTES" -gt "$MAX_DELETE_BYTES" ]]; then TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $TOTAL_DELETE_BYTES / 1073741824}") if [[ "$I_KNOW" != true ]]; then echo "" error "Deletion would exceed ${LIDARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted" error "Review ORPHAN lines above carefully before proceeding" error "Rerun with: --i-know-what-im-doing" error "To also bypass age check: add --skip-strike-list" notify "Lidarr cleanup halted on $(hostname) — ${TOTAL_HUMAN} requires --i-know-what-im-doing" \ "Lidarr Cleanup" "warning" exit 1 else warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing" fi fi # ── Execute Deletions ───────────────────────────────────────────────────────────────────────── # All safety layers passed — delete orphans and junk if [[ "$DRY_RUN" == false ]]; then while IFS= read -r filepath; do [[ -z "$filepath" ]] && continue [[ -n "${TRACKED_MAP[$filepath]:-}" ]] && continue is_protected_file "$filepath" && continue FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0) FILE_AGE=$(( NOW - FILE_MTIME )) if is_music_file "$filepath"; then [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && \ [[ "$SKIP_STRIKES" != true ]] && continue fi rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath" done < <(find "$LIDARR_MUSIC_ROOT" -type f 2>/dev/null) log "Cleaning up empty folders..." find "$LIDARR_MUSIC_ROOT" -mindepth 1 -type d -empty -delete 2>/dev/null log "Empty folders removed" fi END=$(date +%s) format_bytes() { local bytes=$1 if (( bytes > 1073741824 )); then awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}" elif (( bytes > 1048576 )); then awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}" else echo "${bytes}B" fi } ORPHAN_HUMAN=$(format_bytes "$ORPHAN_BYTES") JUNK_HUMAN=$(format_bytes "$JUNK_BYTES") # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY LIDARR CLEANUP SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_SYNC Tracked: $TRACKED_COUNT files ($ARTIST_COUNT artists)" echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (cover art, 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 ${LIDARR_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 log "$ICON_DONE Clean — nothing to remove" else warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" notify "Lidarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" "Lidarr 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')|lidarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}" \ >> "$ARR_CLEANUP_STATS" 2>/dev/null || true fi exit 0