#!/usr/bin/env python3 # ============================================================================================== # ================================= Arr Cleanup ================================================ # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Delete orphaned media files not tracked by an arr (Lidarr, Radarr, Sonarr, # or any future arr). 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. # # Called exclusively by arr_cleanup.sh, which sources shell config and exports # all configuration as environment variables before exec'ing this script. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Every file encountered on disk is classified into one of five categories: # # TRACKED — arr API knows this exact path → leave it alone # PROTECTED — matches {ARR}_PROTECTED_PATTERNS → never delete # ORPHAN — media file, not tracked, older than {ARR}_ORPHAN_AGE → delete # JUNK — not a tracked extension, not protected → delete regardless of age # RECENT — not tracked, under {ARR}_ORPHAN_AGE → skip (may be mid-import) # # Arr apps generate cover art (*.jpg), metadata (*.nfo), lyrics (*.lrc), and # subtitles (*.srt) but do NOT include these in their tracked file API response. # Without PROTECTED classification these would be deleted — breaking the arr # app 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 {ARR}_VERSION_MAJOR in master.conf # 4. Parent count > 0 (artists / movies / series) # 5. Tracked file count > 0 # 6. Tracked count >= {ARR}_MIN_TRACKED_PCT % of last known (if configured) # 7. Deletion size < {ARR}_MAX_DELETE_GB — or --i-know-what-im-doing required # # ============================================================================================== # ADDING A NEW ARR # ============================================================================================== # # 1. Add an entry to ARR_PROFILES below (6 values — API endpoint pattern only) # 2. Add HOST*__URL, API_KEY, MEDIA_ROOT, PATH_MAP to master_host*.conf # 3. Add _ORPHAN_AGE, MAX_DELETE_GB, EXTENSIONS, etc. to master.conf # 4. Add an export block to arr_cleanup.sh (copy existing block, change prefix) # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # master_host*.conf (host-specific, aliased by detect_hosts in arr_cleanup.sh) # # HOST*_{ARR}_URL — arr base URL # HOST*_{ARR}_API_KEY — arr API key # HOST*_{ARR}_MEDIA_ROOT — host-side library root (MUSIC_ROOT / MOVIES_ROOT / TV_ROOT) # HOST*_{ARR}_PATH_MAP — container path → host path translation (assoc array) # # master.conf (shared thresholds) # # {ARR}_ORPHAN_AGE — days before untracked file is eligible for deletion # {ARR}_MAX_DELETE_GB — require --i-know-what-im-doing above this # {ARR}_MIN_TRACKED_PCT — abort if tracked count drops below this % of last run # {ARR}_TRACKED_COUNT_FILE — persistent baseline file path (enables gate 6) # {ARR}_EXTENSIONS — media file extensions for orphan classification # {ARR}_PROTECTED_PATTERNS — glob patterns never deleted (cover art, metadata, etc.) # {ARR}_VERSION_MAJOR — expected arr major version for API safety check # {ARR}_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600) # {ARR}_LOCK_WARN_AGE — override default lock warning age (large libraries) # ARR_CLEANUP_STATS — stats file path (read by sunday_morning_coffee_report) # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # arr_cleanup.sh --arr lidarr — normal run # arr_cleanup.sh --arr lidarr --dry-run — preview, no deletions # arr_cleanup.sh --arr lidarr --log — verbose output # arr_cleanup.sh --arr lidarr --status — show config and exit # arr_cleanup.sh --arr lidarr --i-know-what-im-doing — bypass size threshold # arr_cleanup.sh --arr lidarr --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE # # NUCLEAR MODE: both flags bypass age check AND size threshold. Use when the arr # has filled gaps and you want a clean one-pass wipe. Flag name is long and # annoying by design. # # ============================================================================================== import argparse import atexit import datetime import fnmatch import json import os import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path # ── Arr profiles — only what cannot come from env vars ──────────────────────── # API endpoint patterns and labels. Everything else is config in master.conf. ARR_PROFILES = { "lidarr": { "api_version": "v1", "parent_endpoint": "artist", "parent_id_param": "artistId", "file_endpoint": "trackFile", "import_scan_cmd": "DownloadedAlbumsScan", "parent_label": "artists", }, "radarr": { "api_version": "v3", "parent_endpoint": "movie", "parent_id_param": "movieId", "file_endpoint": "moviefile", "import_scan_cmd": "DownloadedMoviesScan", "parent_label": "movies", }, "sonarr": { "api_version": "v3", "parent_endpoint": "series", "parent_id_param": "seriesId", "file_endpoint": "episodefile", "import_scan_cmd": "DownloadedEpisodesScan", "parent_label": "series", }, # Add new arrs here. 6 values — everything else goes in master.conf. # "readarr": { # "api_version": "v1", # "parent_endpoint": "author", # "parent_id_param": "authorId", # "file_endpoint": "bookfile", # "import_scan_cmd": "DownloadedBooksScan", # "parent_label": "authors", # }, } # ── Output helpers ───────────────────────────────────────────────────────────── VERBOSE = False def log(msg): if VERBOSE: print(f" {msg}") def warn(msg): print(f" ⚠️ {msg}") def error(msg): print(f" ❌ {msg}", file=sys.stderr) def success(msg): print(f" ✅ {msg}") def die(msg, notify_fn=None): error(msg) if notify_fn: notify_fn(msg) sys.exit(1) # ── Env var readers ──────────────────────────────────────────────────────────── def _env(key, default=""): return os.environ.get(key, default) def _env_int(key, default=0): try: return int(os.environ.get(key, str(default))) except ValueError: return default def _env_float(key, default=0.0): try: return float(os.environ.get(key, str(default))) except ValueError: return default def _env_list(key): val = os.environ.get(key, "") return val.split() if val else [] def _env_bool(key): return os.environ.get(key, "false").lower() == "true" # ── Lock ─────────────────────────────────────────────────────────────────────── LOCK_DIR = _env("LOCK_DIR", "/tmp/unraid_locks") LOCK_TIMEOUT = _env_int("LOCK_WAIT_TIMEOUT", 30) SCRIPT_NAME = "arr_cleanup" def acquire_lock(warn_age=3600): os.makedirs(LOCK_DIR, exist_ok=True) lockfile = Path(LOCK_DIR) / f"{SCRIPT_NAME}.lock" if lockfile.exists(): try: content = lockfile.read_text().strip() pid_str, locked_name = content.split(":", 1) pid = int(pid_str) try: os.kill(pid, 0) pid_alive = True except (ProcessLookupError, PermissionError): pid_alive = False if not pid_alive or locked_name != SCRIPT_NAME: warn(f"Stale lock (PID {pid} gone) — clearing") lockfile.unlink(missing_ok=True) else: age = time.time() - lockfile.stat().st_mtime if age > warn_age: warn(f"{SCRIPT_NAME} has been running for {int(age)}s — may be stuck (PID {pid})") print(f" Another instance of {SCRIPT_NAME} is running — waiting up to {LOCK_TIMEOUT}s...") waited = 0 while lockfile.exists() and waited < LOCK_TIMEOUT: time.sleep(1) waited += 1 if lockfile.exists(): die(f"{SCRIPT_NAME} still locked after {LOCK_TIMEOUT}s — exiting") except (ValueError, OSError): lockfile.unlink(missing_ok=True) lockfile.write_text(f"{os.getpid()}:{SCRIPT_NAME}") atexit.register(lambda: lockfile.unlink(missing_ok=True)) log(f"🔏 Lock acquired: {SCRIPT_NAME} (PID {os.getpid()})") # ── HTTP helper ──────────────────────────────────────────────────────────────── def _http_get(url, api_key, timeout=30): req = urllib.request.Request(url, headers={"X-Api-Key": api_key}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.read().decode() except urllib.error.HTTPError as e: return e.code, "" except Exception: return 0, "" def _http_post(url, api_key, payload=None, timeout=30): data = json.dumps(payload or {}).encode() req = urllib.request.Request( url, data=data, method="POST", headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.read().decode() except urllib.error.HTTPError as e: return e.code, "" except Exception: return 0, "" # ── Arr API ──────────────────────────────────────────────────────────────────── def arr_get(base_url, api_key, api_version, endpoint): url = f"{base_url}/api/{api_version}/{endpoint}" status, body = _http_get(url, api_key) if status != 200: error(f"API HTTP {status} for: {endpoint}") return None try: return json.loads(body) except json.JSONDecodeError: error(f"Failed to parse JSON for: {endpoint}") return None # ── Path translation ─────────────────────────────────────────────────────────── # Replicates common.sh translate_path() — longest-prefix match wins. def translate_path(api_path, path_map): best_match = "" best_len = 0 for container_path, host_path in path_map.items(): if api_path.startswith(container_path) and len(container_path) > best_len: best_match = container_path best_len = len(container_path) if best_match: return path_map[best_match] + api_path[best_len:] return api_path # ── Container health check ───────────────────────────────────────────────────── def check_container(name, timeout=15): def _inspect(fmt): try: r = subprocess.run( ["timeout", str(timeout), "docker", "inspect", "-f", fmt, name], capture_output=True, text=True, ) return r.stdout.strip() except Exception: return "" if _inspect("{{.State.Running}}") != "true": return False, f"{name} is not running" health = _inspect("{{.State.Health.Status}}") if health == "healthy": log(f"{name} is healthy") elif health == "": log(f"{name} has no health check — proceeding") elif health == "starting": return False, f"{name} is still starting" elif health == "unhealthy": return False, f"{name} is unhealthy" else: warn(f"{name} health: {health} — proceeding with caution") return True, "" # ── API reachability ─────────────────────────────────────────────────────────── def check_api(url, label, timeout=10): req = urllib.request.Request(url) try: urllib.request.urlopen(req, timeout=timeout) log(f"{label} API reachable: {url}") return True except Exception: error(f"{label} API not reachable: {url}") return False # ── API version check ────────────────────────────────────────────────────────── def check_arr_version(base_url, api_key, api_version, expected_major, label): url = f"{base_url}/api/{api_version}/system/status" status, body = _http_get(url, api_key, timeout=10) if status != 200 or not body: warn(f"{label} version check failed — proceeding without verification") return True try: data = json.loads(body) version = data.get("version", "") major = version.split(".")[0] if major == str(expected_major): success(f"{label} version: {version} (major {major} — tested ✅)") return True else: error(f"{label} version mismatch — running v{major}, tested against v{expected_major}") error(f"API structure may have changed — update {label.upper()}_VERSION_MAJOR in master.conf after verifying") return False except Exception: warn(f"{label} version check failed — could not parse response") return True # ── Import scan (pre-flight) ─────────────────────────────────────────────────── def run_import_scan(base_url, api_key, api_version, scan_cmd, media_root, path_map, timeout=600, label="Arr"): container_root = next( (cp for cp, hp in path_map.items() if hp == media_root), None, ) if container_root: log(f"Triggering {scan_cmd} on: {container_root}") payload = {"name": scan_cmd, "path": container_root} else: log(f"No path map match — triggering {scan_cmd} (all root folders)") payload = {"name": scan_cmd} url = f"{base_url}/api/{api_version}/command" status, body = _http_post(url, api_key, payload) if status not in (200, 201): warn(f"Could not trigger import scan (HTTP {status}) — proceeding without pre-flight") return try: cmd_id = json.loads(body).get("id") except Exception: cmd_id = None if not cmd_id: warn("Could not get scan command ID — proceeding without pre-flight") return print(f" Import scan queued (command ID: {cmd_id}) — waiting for completion...") polled = 0 poll_url = f"{base_url}/api/{api_version}/command/{cmd_id}" while polled < timeout: _, body = _http_get(poll_url, api_key, timeout=10) try: state = json.loads(body).get("status", "") except Exception: state = "" if state == "completed": log("Import scan complete ✅") return if state == "failed": warn("Import scan reported failed — proceeding anyway") return time.sleep(10) polled += 10 if polled % 60 == 0: log(f" Still scanning... ({polled}s elapsed)") warn(f"Import scan timed out after {timeout}s — proceeding anyway") # ── Fetch tracked paths ──────────────────────────────────────────────────────── def fetch_tracked_paths(base_url, api_key, api_version, profile, path_map, label): parent_endpoint = profile["parent_endpoint"] parent_id_param = profile["parent_id_param"] file_endpoint = profile["file_endpoint"] parent_label = profile["parent_label"] parents = arr_get(base_url, api_key, api_version, parent_endpoint) if parents is None: return None, 0, 0 parent_ids = [p["id"] for p in parents] parent_count = len(parent_ids) if parent_count == 0: return None, 0, 0 log(f"Found {parent_count} {parent_label} — fetching tracked files...") tracked = set() for i, pid in enumerate(parent_ids): if i > 0 and i % 100 == 0: log(f"Fetching files: {i}/{parent_count} {parent_label}...") files = arr_get(base_url, api_key, api_version, f"{file_endpoint}?{parent_id_param}={pid}") if not files: continue if isinstance(files, dict): files = [files] for f in files: api_path = f.get("path", "") if api_path: tracked.add(translate_path(api_path, path_map)) return tracked, parent_count, len(tracked) # ── File classification helpers ──────────────────────────────────────────────── def is_media_file(path, extensions): ext = Path(path).suffix.lstrip(".").lower() return ext in extensions def is_protected(path, patterns): name = Path(path).name return any(fnmatch.fnmatch(name, p) for p in patterns) # ── Notify Emby ─────────────────────────────────────────────────────────────── def notify_emby_scan(emby_url, emby_api_key, my_id): if not emby_url or not emby_api_key: log(f"Emby not configured on {my_id} — skipping library scan notification") return log("Notifying Emby to clean missing files...") status, body = _http_get(f"{emby_url}/ScheduledTasks", emby_api_key, timeout=15) if status != 200 or not body: warn(f"Could not reach Emby scheduled tasks API — skipping scan") return try: tasks = json.loads(body) except Exception: warn("Could not parse Emby tasks response") return task_id = None for task in tasks: if "Clean Missing" in task.get("Name", ""): task_id = task.get("Id") break if not task_id: for task in tasks: if "Scan Media Library" in task.get("Name", ""): task_id = task.get("Id") log("Clean Missing Files not found — using Scan Media Library") break if not task_id: warn("Could not find Emby Clean Missing Files or Scan Media Library task") warn("Ghost entries will persist until next Emby scan") return status, _ = _http_post(f"{emby_url}/ScheduledTasks/Running/{task_id}", emby_api_key) if status in (200, 204): warn("🎬 Emby Clean Missing Files triggered — ghost entries will be removed") else: warn(f"Emby task trigger returned HTTP {status} — ghost entries may persist") # ── Unraid + Discord notifications ──────────────────────────────────────────── def notify(msg, subject, notify_unraid, hostname, discord_webhook): log(f"🔔 Notification: {subject} — {msg}") if notify_unraid: notify_script = "/usr/local/emhttp/plugins/dynamix/scripts/notify" if os.path.isfile(notify_script) and os.access(notify_script, os.X_OK): subprocess.run([notify_script, "-s", subject, "-d", msg, "-i", "warning"], capture_output=True) if discord_webhook: payload = json.dumps({"content": f"🔔 **{subject}**\n{msg}"}).encode() req = urllib.request.Request( discord_webhook, data=payload, method="POST", headers={"Content-Type": "application/json"}, ) try: urllib.request.urlopen(req, timeout=10) except Exception: pass # ── Format helpers ───────────────────────────────────────────────────────────── def format_bytes(b): if b > 1_073_741_824: return f"{b / 1_073_741_824:.1f}GB" if b > 1_048_576: return f"{b / 1_048_576:.1f}MB" return f"{b}B" def format_duration(secs): if secs >= 3600: return f"{secs // 3600}h{(secs % 3600) // 60}m{secs % 60}s" if secs >= 60: return f"{secs // 60}m{secs % 60}s" return f"{secs}s" # ══════════════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════════════ def main(): global VERBOSE parser = argparse.ArgumentParser(prog="arr_cleanup.py") parser.add_argument("--arr", required=True, choices=list(ARR_PROFILES.keys())) parser.add_argument("--dry-run", action="store_true") parser.add_argument("--log", action="store_true") parser.add_argument("--status", action="store_true") parser.add_argument("--i-know-what-im-doing", action="store_true", dest="i_know") parser.add_argument("--skip-strike-list", action="store_true", dest="skip_strikes") args = parser.parse_args() VERBOSE = args.log ARR = args.arr PREFIX = ARR.upper() profile = ARR_PROFILES[ARR] arr_label = ARR.capitalize() # ── Read config from env ─────────────────────────────────────────────────── url = _env(f"{PREFIX}_URL") api_key = _env(f"{PREFIX}_API_KEY") media_root = _env(f"{PREFIX}_MEDIA_ROOT") extensions = set(_env_list(f"{PREFIX}_EXTENSIONS")) protected = _env_list(f"{PREFIX}_PROTECTED_PATTERNS") orphan_age = _env_int(f"{PREFIX}_ORPHAN_AGE", 3) max_del_gb = _env_float(f"{PREFIX}_MAX_DELETE_GB", 10.0) min_pct = _env_int(f"{PREFIX}_MIN_TRACKED_PCT", 0) count_file = _env(f"{PREFIX}_TRACKED_COUNT_FILE") ver_major = _env(f"{PREFIX}_VERSION_MAJOR", "0") scan_tmout = _env_int(f"{PREFIX}_IMPORT_SCAN_TIMEOUT", 600) lock_warn = _env_int(f"{PREFIX}_LOCK_WARN_AGE", 3600) container = _env(f"{PREFIX}_CONTAINER") or arr_label path_map_json = _env(f"{PREFIX}_PATH_MAP_JSON", "{}") try: path_map = json.loads(path_map_json) except json.JSONDecodeError: path_map = {} my_id = _env("MY_ID", "HOST1") server_name = _env("LOCAL_SERVER_NAME") notify_unraid = _env_bool("NOTIFY_UNRAID") emby_url = _env("EMBY_URL") emby_api_key = _env("EMBY_API_KEY") discord = _env("MY_DISCORD_WEBHOOK") stats_file = _env("ARR_CLEANUP_STATS") hostname = _env("ARR_HOSTNAME") api_version = profile["api_version"] def _notify(msg, subject=f"{arr_label} Cleanup"): notify(msg, subject, notify_unraid, hostname, discord) # ── Nuclear mode warning ─────────────────────────────────────────────────── if args.i_know and args.skip_strikes and not args.dry_run: print() print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print("⚠️ WARNING — NUCLEAR MODE ACTIVE") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print(" Flags: --i-know-what-im-doing --skip-strike-list") print(" Strike system: BYPASSED — deletes on first pass") print(" Size threshold: BYPASSED — no GB limit") print(" Data recovery: NOT POSSIBLE after deletion") print() print(" Review --dry-run output before proceeding.") print(" You have 10 seconds to cancel (Ctrl+C)...") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") time.sleep(10) print(" Proceeding...") print() # ── Setup ────────────────────────────────────────────────────────────────── print() print("━━━ ⚙️ Setup ━━━") if not url or not api_key: log(f"{arr_label} not configured on {my_id} ({server_name}) — skipping") sys.exit(0) if not media_root: die(f"{PREFIX}_MEDIA_ROOT not set — check master_host*.conf", _notify) if not Path(media_root).is_dir(): die(f"Media root not found: {media_root}", _notify) acquire_lock(warn_age=lock_warn) print(f" {my_id} ({server_name}) — {url}") if args.dry_run: warn("DRY RUN — no files will be deleted") if args.i_know: warn("OVERRIDE — --i-know-what-im-doing active") if args.skip_strikes: warn("OVERRIDE — --skip-strike-list active — age check bypassed") # ── Status ───────────────────────────────────────────────────────────────── if args.status: print() print(f"━━━━━ 📋 STATUS ━━━━━") print(f"⚙️ Identity: {my_id} ({server_name})") print(f"⚙️ {arr_label} URL: {url}") print(f"⚙️ Media root: {media_root}") print(f"⏱️ Orphan age: {orphan_age} days") print(f"⚙️ Max delete: {max_del_gb}GB (requires --i-know-what-im-doing)") if min_pct: print(f"⚙️ Min tracked %: {min_pct}%") print(f"⚙️ {arr_label} ver: v{ver_major} expected") print(f"⚙️ Extensions: {' '.join(sorted(extensions))}") print(f"⚙️ Protected patterns: {' '.join(protected)}") print(f"⚙️ Dry Run: {args.dry_run}") print(f"⚙️ I know: {args.i_know}") print(f"⚙️ Skip strikes: {args.skip_strikes}") print("━━━━━━━━━━━━━━━━━━━━━━━") sys.exit(0) # ── Safety gate 1 — container health ────────────────────────────────────── print() print("━━━ 🛡️ Safety Checks ━━━") ok, reason = check_container(container) if not ok: _notify(f"{arr_label} cleanup aborted on {hostname} — {reason}") die(f"{reason} — aborting") log("Safety gate 1 passed — container healthy") # ── Pre-flight import scan ───────────────────────────────────────────────── print() print(f"━━━ 🔄 Pre-flight: {arr_label} Import Scan ━━━") run_import_scan(url, api_key, api_version, profile["import_scan_cmd"], media_root, path_map, timeout=scan_tmout, label=arr_label) # ── Safety gate 2 — API reachability ────────────────────────────────────── print() print(f"━━━ 🔄 Fetching {arr_label} Tracked Files ━━━") if not check_api(url, arr_label): _notify(f"{arr_label} cleanup aborted on {hostname} — API unreachable") sys.exit(1) # ── Safety gate 3 — API version ─────────────────────────────────────────── if not check_arr_version(url, api_key, api_version, ver_major, arr_label): _notify(f"{arr_label} version mismatch on {hostname} — check master.conf") sys.exit(1) # ── Fetch tracked paths ──────────────────────────────────────────────────── print(f" Querying {arr_label} API...") tracked, parent_count, tracked_count = fetch_tracked_paths( url, api_key, api_version, profile, path_map, arr_label, ) # ── Safety gate 4 — parent count > 0 ────────────────────────────────────── if tracked is None or parent_count == 0: msg = f"API returned 0 {profile['parent_label']} — aborting to prevent mass deletion" _notify(f"{arr_label} cleanup aborted on {hostname} — 0 {profile['parent_label']} returned") die(msg) # ── Safety gate 5 — tracked count > 0 ──────────────────────────────────── if tracked_count == 0: _notify(f"{arr_label} cleanup aborted on {hostname} — 0 tracked files returned") die("API returned 0 tracked files — aborting to prevent mass deletion") print(f" {parent_count} {profile['parent_label']} | {tracked_count} tracked files") log(f"Built in-memory lookup set: {tracked_count} tracked paths") # ── Safety gate 6 — tracked % drop (only if configured) ────────────────── if count_file and min_pct > 0: count_path = Path(count_file) if count_path.exists(): try: last = int(count_path.read_text().strip()) if last > 0: pct = int((tracked_count / last) * 100) if pct < min_pct: error(f"Tracked count dropped to {pct}% of last run ({tracked_count} vs {last})") error(f"Suggests API issue — aborting to prevent mass deletion") error(f"If expected (large removal) delete: {count_file}") _notify(f"{arr_label} cleanup aborted on {hostname} — tracked count dropped to {pct}%") sys.exit(1) log(f"Tracked count: {pct}% of last run ({tracked_count} vs {last}) ✅") except (ValueError, OSError): log("Could not read previous count — skipping % check") else: log("No previous count on record — first run, saving baseline") try: count_path.write_text(str(tracked_count)) except OSError as e: warn(f"Could not write tracked count file: {e}") # ── Scan media root ──────────────────────────────────────────────────────── print() print(f"━━━ 🧹 Scanning Media Root ━━━") print(f" Root: {media_root} | Orphan age: {orphan_age} days") print() start = time.time() orphan_count = junk_count = recent_count = protected_count = 0 orphan_bytes = junk_bytes = 0 age_threshold = orphan_age * 86400 now = time.time() max_del_bytes = int(max_del_gb * 1_073_741_824) scan_roots = set(path_map.values()) | {media_root} all_files = [] for root in scan_roots: if Path(root).is_dir(): for fp in Path(root).rglob("*"): if fp.is_file(): all_files.append(str(fp)) all_files = sorted(set(all_files)) for filepath in all_files: if filepath in tracked: log(f"TRACKED: {filepath}") continue if is_protected(filepath, protected): log(f"🔰 PROTECTED: {filepath}") protected_count += 1 continue try: st = Path(filepath).stat() except OSError: continue file_size = st.st_size if is_media_file(filepath, extensions): file_age = now - st.st_mtime if file_age < age_threshold and not args.skip_strikes: log(f"RECENT (skipping): {filepath}") recent_count += 1 continue warn(f"🗑️ ORPHAN: {filepath}") orphan_count += 1 orphan_bytes += file_size else: log(f"JUNK: {filepath}") junk_count += 1 junk_bytes += file_size total_del_bytes = orphan_bytes + junk_bytes total_removed = orphan_count + junk_count # ── Safety gate 7 — deletion size threshold ─────────────────────────────── if total_del_bytes > max_del_bytes: total_human = format_bytes(total_del_bytes) if not args.i_know: print() error(f"Deletion would exceed {max_del_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(f"{arr_label} cleanup halted on {hostname} — {total_human} requires --i-know-what-im-doing") sys.exit(1) else: warn(f"OVERRIDE — deletion is {total_human} — proceeding with --i-know-what-im-doing") # ── Execute deletions ────────────────────────────────────────────────────── if not args.dry_run: for filepath in all_files: if filepath in tracked: continue if is_protected(filepath, protected): continue try: st = Path(filepath).stat() file_age = now - st.st_mtime except OSError: continue if is_media_file(filepath, extensions): if file_age < age_threshold and not args.skip_strikes: continue try: Path(filepath).unlink() except OSError as e: error(f"Failed to delete: {filepath} — {e}") log("Cleaning up empty folders...") for root in scan_roots: if Path(root).is_dir(): for d in sorted(Path(root).rglob("*"), key=lambda p: len(p.parts), reverse=True): if d.is_dir(): try: d.rmdir() except OSError: pass log("Empty folders removed") elapsed = int(time.time() - start) # ── Summary ──────────────────────────────────────────────────────────────── orphan_human = format_bytes(orphan_bytes) junk_human = format_bytes(junk_bytes) print() print(f"━━━━━ 📋 {arr_label.upper()} CLEANUP SUMMARY ━━━━━") print(f"🖥️ Identity: {my_id} ({server_name})") print(f"🔄 Tracked: {tracked_count} files ({parent_count} {profile['parent_label']})") print(f"🛡️ Protected: {protected_count} files (cover art, metadata)") print(f"🗑️ Orphans: {orphan_count} files ({orphan_human})") print(f"🗑️ Junk: {junk_count} files ({junk_human})") print(f"⏭️ Recent skipped: {recent_count} files (under {orphan_age} days)") print(f"⏱️ Duration: {format_duration(elapsed)}") print() if args.dry_run: warn("DRY RUN — no files deleted") elif total_removed == 0: success("Clean — nothing to remove") else: warn(f"🏁 Removed {total_removed} files (orphans: {orphan_human} junk: {junk_human})") _notify( f"{arr_label} cleanup on {hostname} — removed {total_removed} files " f"(orphans: {orphan_human} junk: {junk_human})" ) notify_emby_scan(emby_url, emby_api_key, my_id) print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") # ── Write stats for coffee report ───────────────────────────────────────── if not args.dry_run and stats_file: today = datetime.date.today().strftime("%Y-%m-%d") line = f"{today}|{ARR}|{orphan_count}|{orphan_bytes}|{junk_count}|{junk_bytes}|{recent_count}|{tracked_count}\n" try: with open(stats_file, "a") as f: f.write(line) except OSError: pass if __name__ == "__main__": main()