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.
536 lines
25 KiB
Bash
Executable File
536 lines
25 KiB
Bash
Executable File
#!/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 runs where files were deleted so ghost entries disappear immediately.
|
|
#
|
|
# Pre-flight and the tracked-count floor check are both rescan-aware: a library-wide
|
|
# rescan legitimately makes tracked counts read low mid-scan (confirmed 2026-07-16 —
|
|
# 22% of normal during an active RescanFolders), which used to trigger this script's own
|
|
# hard abort every time it overlapped with a real rescan. Now it checks for an active
|
|
# rescan-type command first — if one's running, it waits (calibrated to that command's
|
|
# own historical duration via arr_get_rescan_duration(), up to 3 strikes) and re-fetches
|
|
# rather than either stacking a duplicate scan or crying wolf on a normal, if slow, state.
|
|
# Only escalates to the scary abort-and-notify when the count is genuinely low AND nothing
|
|
# is actively rescanning.
|
|
#
|
|
# ==============================================================================================
|
|
# 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.
|
|
#
|
|
# ==============================================================================================
|
|
# DESIGN PRINCIPLES
|
|
# ==============================================================================================
|
|
#
|
|
# API as Ground Truth
|
|
# What Lidarr tracks is authoritative. Files not in the API response are
|
|
# orphans — Lidarr 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 LIDARR_ORPHAN_AGE are left alone regardless of tracked status.
|
|
# Lidarr's import pipeline writes files before registering them — acting
|
|
# immediately would delete files mid-import.
|
|
#
|
|
# Seven-Gate Safety Model
|
|
# Multiple independent sanity checks must all pass before any file is touched.
|
|
# No single check is trusted in isolation — a misconfigured path returning an
|
|
# empty API response must not result in a wiped library.
|
|
#
|
|
# ==============================================================================================
|
|
# 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
|
|
# ARR_DOCKER_TIMEOUT — container checks protected against daemon hangs (script-local, not common.sh's DOCKER_TIMEOUT)
|
|
# Duplicate detection — temp file of tracked paths, grep before delete
|
|
# platform_require_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
|
|
# ==============================================================================================
|
|
#
|
|
# 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-age-check — 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-age-check before parse_args
|
|
# to avoid unknown flag errors — both are handled separately below.
|
|
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
|
|
|
|
# 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
|
|
|
|
|
|
# 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"
|
|
TMP_DIR="/tmp/lidarr_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 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
|
|
info "Lidarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
|
|
exit 0
|
|
fi
|
|
|
|
ARR_DOCKER_TIMEOUT=15
|
|
LIDARR_CONTAINER="Lidarr" # container name on HOST1
|
|
|
|
# Build path map from MY_ID's Lidarr path map
|
|
build_arr_path_map "LIDARR"
|
|
|
|
# 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
|
|
|
|
log "$ICON_GEAR Config: url=${LIDARR_URL} root=${LIDARR_MUSIC_ROOT}"
|
|
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_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 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 age check: $SKIP_AGE_CHECK"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Safety Layer 1 — Container Health ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
|
|
|
|
check_container_health "$LIDARR_CONTAINER" "$ARR_DOCKER_TIMEOUT" "Lidarr Cleanup"
|
|
|
|
# ==============================================================================================
|
|
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
|
|
# check_container_health(), arr_api(), has_extension(), matches_pattern_list() — common.sh
|
|
|
|
# ==============================================================================================
|
|
# ━━━ 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
|
|
|
|
# Don't stack a fresh scan on top of one already running — confirmed 2026-07-16 that
|
|
# repeated runs each firing their own DownloadedAlbumsScan piled up in Lidarr's command
|
|
# queue behind each other rather than replacing/coalescing, contributing to a multi-hour
|
|
# backlog. If something's already scanning, just wait for that one instead.
|
|
_already_active=$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
|
|
|
|
if [[ -n "$_already_active" ]]; then
|
|
info "$_already_active already in progress — waiting for it instead of starting a new scan"
|
|
_wait=$(( $(arr_get_rescan_duration "lidarr" "$_already_active" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}") ))
|
|
_polled=0
|
|
while [[ "$_polled" -lt "$_wait" ]]; do
|
|
[[ -z "$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")" ]] && break
|
|
sleep 15
|
|
(( _polled += 15 ))
|
|
[[ $(( _polled % 60 )) -eq 0 ]] && log " Still waiting on $_already_active... (${_polled}s elapsed)"
|
|
done
|
|
elif [[ -n "$LIDARR_CONTAINER_ROOT" ]]; then
|
|
info "Triggering DownloadedAlbumsScan on: $LIDARR_CONTAINER_ROOT"
|
|
SCAN_PAYLOAD="{\"name\": \"DownloadedAlbumsScan\", \"path\": \"$LIDARR_CONTAINER_ROOT\"}"
|
|
trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$SCAN_PAYLOAD" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" "lidarr"
|
|
else
|
|
info "No path map match — triggering DownloadedAlbumsScan (all root folders)"
|
|
SCAN_PAYLOAD='{"name": "DownloadedAlbumsScan"}'
|
|
trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$SCAN_PAYLOAD" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" "lidarr"
|
|
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
|
|
|
|
info "Querying Lidarr 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 artist
|
|
# list itself is cached — the per-artist trackFile data below is never cached and always live,
|
|
# since that's the actual disk-truth this script's cleanup decisions depend on.
|
|
ARTIST_RESPONSE=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") || {
|
|
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
|
|
|
|
info "Found $ARTIST_COUNT artists — fetching track files..."
|
|
|
|
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
|
|
|
|
# Fetches every artist's track-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"
|
|
while IFS= read -r artist_id; do
|
|
[[ -z "$artist_id" ]] && continue
|
|
ARTIST_TRACKS=$(arr_api "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "trackFile?artistId=${artist_id}" "Lidarr" 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
|
|
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 "Lidarr cleanup aborted on $(hostname) — 0 tracked files returned" \
|
|
"Lidarr Cleanup" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
info "$ARTIST_COUNT artists | $TRACKED_COUNT tracked files"
|
|
|
|
# Safety Layer 6 — percentage drop vs last known count, with rescan-aware retry.
|
|
# A library-wide rescan legitimately makes tracked counts read low mid-scan — sometimes
|
|
# dramatically (confirmed 2026-07-16: 22% of normal during an active RescanFolders).
|
|
# That's not "something's wrong," it's Lidarr actively re-verifying every file. Wait it out
|
|
# (calibrated to that command's own historical duration) before treating a drop as a genuine
|
|
# problem worth the scary abort-and-notify. Only escalates to the hard abort in
|
|
# check_tracked_count_floor if the count is still low AND nothing is actively rescanning —
|
|
# that combination is the actually-suspicious case the floor check exists to catch.
|
|
_last_known=$(cat "$LIDARR_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 "${LIDARR_MIN_TRACKED_PCT:-50}" ]] && break
|
|
|
|
_active_cmd=$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
|
|
[[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry
|
|
|
|
_wait=$(( $(arr_get_rescan_duration "lidarr" "$_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 "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
|
|
if [[ -n "$_active_cmd" ]]; then
|
|
warn "Lidarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run"
|
|
exit 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
check_tracked_count_floor "$TRACKED_COUNT" "$LIDARR_TRACKED_COUNT_FILE" "$LIDARR_MIN_TRACKED_PCT" "Lidarr Cleanup"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Scan Music Root ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_CLEAN Scanning Music Root ━━━"
|
|
info "Root: $LIDARR_MUSIC_ROOT | Orphan age: ${LIDARR_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=$(( LIDARR_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 the whole tree 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, so there's no need to redo the classification
|
|
# itself once it's already been decided.
|
|
TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt"
|
|
> "$TO_DELETE_FILE"
|
|
|
|
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 matches_pattern_list "$filepath" "${LIDARR_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" "${LIDARR_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 < <(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 ━━━
|
|
# ==============================================================================================
|
|
check_delete_size_threshold "$TOTAL_DELETE_BYTES" "$LIDARR_MAX_DELETE_GB" "Lidarr Cleanup"
|
|
|
|
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
|
|
# All safety layers passed — delete orphans and junk. Reuses TO_DELETE_FILE from the
|
|
# classification pass above instead of re-walking and re-classifying the whole tree 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..."
|
|
find "$LIDARR_MUSIC_ROOT" -mindepth 1 -type d -empty -delete 2>/dev/null
|
|
info "Empty folders removed"
|
|
fi
|
|
|
|
END=$(date +%s)
|
|
|
|
# format_bytes() — provided by common.sh
|
|
|
|
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
|
|
echo "$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 |