refactor: archive Python arr_cleanup and retire continuous_scripts_status

Add Old_Arch_Still_Works/ folder as staging area for scripts awaiting
native platform support or retirement. Python arr_cleanup.sh/.py parked
here until Unraid ships Python natively — fully built and config-driven,
ready to activate. continuous_scripts_status.sh retired from Tools.
This commit is contained in:
Gmer4Lfe
2026-05-17 00:58:27 -04:00
parent 9a5f6f88f2
commit 32a0d47155
3 changed files with 1015 additions and 15 deletions
+872
View File
@@ -0,0 +1,872 @@
#!/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*_<ARR>_URL, API_KEY, MEDIA_ROOT, PATH_MAP to master_host*.conf
# 3. Add <ARR>_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()
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# ==============================================================================================
# ================================= Arr Cleanup Launcher =======================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Thin bash launcher for arr_cleanup.py. Handles everything bash is uniquely
# suited for: sourcing shell config, detect_hosts(), exporting env vars.
# All logic lives in Python.
#
# USAGE
# ─────────────────────────────────────────────────────────────────────────────
# arr_cleanup.sh --arr lidarr [--dry-run] [--log] [--status]
# arr_cleanup.sh --arr radarr [--i-know-what-im-doing] [--skip-strike-list]
# arr_cleanup.sh --arr sonarr
#
# ADDING A NEW ARR
# ─────────────────────────────────────────────────────────────────────────────
# 1. Add HOST*_<ARR>_URL, API_KEY, MEDIA_ROOT, PATH_MAP to master_host*.conf
# 2. Add <ARR>_ORPHAN_AGE, MAX_DELETE_GB, EXTENSIONS, etc. to master.conf
# 3. Add a profile entry to ARR_PROFILES in arr_cleanup.py (6 values)
# 4. Add an export block for the new arr below (copy Sonarr block, change prefix)
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
if [[ "$EUID" -ne 0 ]]; then
echo "ERROR: Must be run as root" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 not found — required for arr_cleanup" >&2
exit 1
fi
detect_hosts
# ── Helper: serialize host-specific associative array to JSON ──────────────────
# Reads ${MY_ID}_${ARR_UPPER}_PATH_MAP and emits {"container_path":"host_path",...}
# Paths with double-quotes in names are not supported (not a real-world constraint).
_path_map_json() {
local arr_upper="$1"
local map_var="${MY_ID}_${arr_upper}_PATH_MAP"
local json="{"
local sep="" keys k v
if ! declare -p "$map_var" 2>/dev/null | grep -q "declare -A"; then
echo "{}"
return
fi
eval "keys=(\"\${!${map_var}[@]}\")"
for k in "${keys[@]}"; do
eval "v=\"\${${map_var}[\$k]}\""
json+="${sep}\"${k}\":\"${v}\""
sep=","
done
json+="}"
echo "$json"
}
# ── Host identity ──────────────────────────────────────────────────────────────
export MY_ID LOCAL_SERVER_NAME
export ARR_HOSTNAME
ARR_HOSTNAME=$(hostname)
# ── Notifications ──────────────────────────────────────────────────────────────
export NOTIFY_UNRAID
export EMBY_URL EMBY_API_KEY
export MY_DISCORD_WEBHOOK
# ── Shared ─────────────────────────────────────────────────────────────────────
export ARR_CLEANUP_STATS
export LOCK_DIR LOCK_WAIT_TIMEOUT
# ── Lidarr ─────────────────────────────────────────────────────────────────────
export LIDARR_URL LIDARR_API_KEY
export LIDARR_MEDIA_ROOT="${LIDARR_MUSIC_ROOT:-}"
export LIDARR_EXTENSIONS="${LIDARR_EXTENSIONS[*]:-}"
export LIDARR_PROTECTED_PATTERNS="${LIDARR_PROTECTED_PATTERNS[*]:-}"
export LIDARR_ORPHAN_AGE LIDARR_MAX_DELETE_GB
export LIDARR_MIN_TRACKED_PCT LIDARR_TRACKED_COUNT_FILE
export LIDARR_VERSION_MAJOR LIDARR_IMPORT_SCAN_TIMEOUT LIDARR_LOCK_WARN_AGE
export LIDARR_PATH_MAP_JSON
LIDARR_PATH_MAP_JSON=$(_path_map_json "LIDARR")
# ── Radarr ─────────────────────────────────────────────────────────────────────
export RADARR_URL RADARR_API_KEY
export RADARR_MEDIA_ROOT="${RADARR_MOVIES_ROOT:-}"
export RADARR_EXTENSIONS="${RADARR_EXTENSIONS[*]:-}"
export RADARR_PROTECTED_PATTERNS="${RADARR_PROTECTED_PATTERNS[*]:-}"
export RADARR_ORPHAN_AGE RADARR_MAX_DELETE_GB
export RADARR_MIN_TRACKED_PCT RADARR_TRACKED_COUNT_FILE
export RADARR_VERSION_MAJOR RADARR_IMPORT_SCAN_TIMEOUT RADARR_LOCK_WARN_AGE
export RADARR_PATH_MAP_JSON
RADARR_PATH_MAP_JSON=$(_path_map_json "RADARR")
# ── Sonarr ─────────────────────────────────────────────────────────────────────
export SONARR_URL SONARR_API_KEY
export SONARR_MEDIA_ROOT="${SONARR_TV_ROOT:-}"
export SONARR_EXTENSIONS="${SONARR_EXTENSIONS[*]:-}"
export SONARR_PROTECTED_PATTERNS="${SONARR_PROTECTED_PATTERNS[*]:-}"
export SONARR_ORPHAN_AGE SONARR_MAX_DELETE_GB
export SONARR_MIN_TRACKED_PCT SONARR_TRACKED_COUNT_FILE
export SONARR_VERSION_MAJOR SONARR_IMPORT_SCAN_TIMEOUT SONARR_LOCK_WARN_AGE
export SONARR_PATH_MAP_JSON
SONARR_PATH_MAP_JSON=$(_path_map_json "SONARR")
exec python3 "$SCRIPT_DIR/arr_cleanup.py" "$@"
@@ -2,30 +2,44 @@
# ==============================================================================================
# ========================= Continuous Scripts Status ==========================================
# ==============================================================================================
# Live status dashboard for all continuously running scripts in the ecosystem.
# Run manually anytime — no schedule, no cron.
#
# ── WHAT IT SHOWS ─────────────────────────────────────────────────────────────────────────────
# For each continuous script (system_watchdog, docker_watchdog, failover):
# Running state, PID, uptime, approximate cycle count
# Active strikes and skip list
# Recent restart history
# Live health snapshot
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Live status dashboard for all continuously running scripts in the ecosystem.
# Run manually at any time — no schedule, no cron.
#
# For each script shows: running state, PID, uptime, approximate cycle count,
# active strikes, skip list, recent restart history, and a live health snapshot.
#
# system_watchdog — rootfs, RAM, ZFS ARC, load, zombie count, CPU temp
# docker_watchdog — running/stopped/unhealthy containers, required containers,
# monitored memory containers, recent restart history
# memory-monitored containers, recent restart history
# failover — current state, tier status, remote Tailscale visibility
#
# If a script is mid-cycle, state files are read as-is — reflects last completed cycle.
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# detect_hosts() sets MY_ID and aliases all HOST*_WATCHDOG_* arrays.
# Required containers and tier delays use MY_ID/REMOTE_ID correctly.
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Host-Aware Output
# detect_hosts() sets MY_ID and aliases all HOST*_WATCHDOG_* arrays.
# Required containers and tier delays are shown for the correct host.
#
# Read-Only
# Reads state files and docker inspect output only — makes no changes to any
# running script, container, or state file.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# continuous_scripts_status.sh
# Show the full dashboard for all continuous scripts.
#
# continuous_scripts_status.sh --log
# Verbose output with additional detail per script section.
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# continuous_scripts_status.sh — show dashboard
# continuous_scripts_status.sh --log — verbose output
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"