Files
ansible/docs/runbooks/arr-cleanup/cleanup.py
Tuan-Dat Tran 8239988a70 docs(runbook): add arr-stack downloads cleanup investigation and scripts
~16T freed on aya01 (92% → 57% mergerfs pool). Documents root cause
(no hardlinks across mergerfs due to cross-device mounts), cleanup
passes via Sonarr/Radarr API verification, and pending decisions
(Bleach remux, 111 skipped Sonarr entries).
2026-04-23 08:06:27 +02:00

133 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""
Delete confirmed-safe download entries from /media/downloads/sonarr and /media/downloads/radarr.
Reads /tmp/arr_verified.json produced by verify.py.
Only deletes entries where status == 'safe' (API-confirmed imported + disk path verified).
Orphans and path_missing entries are never touched.
Usage:
python3 cleanup.py --dry-run # print what would be deleted
python3 cleanup.py --arr sonarr # delete only sonarr downloads
python3 cleanup.py --arr radarr # delete only radarr downloads
python3 cleanup.py # delete both (prompts for confirmation)
# Target a single series/movie by title substring:
python3 cleanup.py --arr sonarr --title "American Dragon"
"""
import json
import subprocess
import argparse
import sys
SSH_HOST = "aya01"
SONARR_DL_ROOT = "/media/downloads/sonarr"
RADARR_DL_ROOT = "/media/downloads/radarr"
VERIFIED_JSON = "/tmp/arr_verified.json"
def ssh_delete(path, dry_run):
"""Delete path on remote host. Returns True on success."""
if dry_run:
print(f" [DRY-RUN] would delete: {path}")
return True
result = subprocess.run(
['ssh', SSH_HOST, f'rm -rf {json.dumps(path)}'],
capture_output=True, text=True
)
if result.returncode != 0:
print(f" ERROR deleting {path}: {result.stderr.strip()}")
return False
return True
def ssh_exists(path):
r = subprocess.run(['ssh', SSH_HOST, f'[ -e {json.dumps(path)} ] && echo yes || echo no'],
capture_output=True, text=True)
return r.stdout.strip() == 'yes'
def confirm(prompt):
answer = input(f"{prompt} [y/N] ").strip().lower()
return answer == 'y'
def process(entries, dl_root, label, dry_run, title_filter, yes=False):
safe = [m for m in entries if m['status'] == 'safe']
if title_filter:
safe = [m for m in safe if title_filter.lower() in m['title'].lower()]
if not safe:
print(f"No safe entries to delete for {label}.")
return 0, 0
print(f"\n{'='*60}")
print(f"{label}{len(safe)} entries to delete")
print(f"{'='*60}")
for m in safe:
pct = m.get('percentOfEpisodes', '')
pct_str = f" [{pct:.0f}%]" if isinstance(pct, float) else ''
files = m.get('episodeFileCount', '')
total = m.get('totalEpisodeCount', '')
count_str = f" ({files}/{total} eps)" if files != '' else f" (hasFile=True)"
print(f" {m['title']}{pct_str}{count_str}")
print(f"{m['dl']}")
print(f"{m['check_path']}")
if not dry_run and not yes:
if not confirm(f"\nDelete {len(safe)} {label} download entries?"):
print("Skipped.")
return 0, 0
deleted, failed = 0, 0
for m in safe:
dl_path = f"{dl_root}/{m['dl']}"
# Double-check the series/movie still exists on disk before deleting the download
if not dry_run and not ssh_exists(m['check_path']):
print(f" SKIP {m['title']}: media path no longer on disk ({m['check_path']})")
failed += 1
continue
ok = ssh_delete(dl_path, dry_run)
if ok:
deleted += 1
else:
failed += 1
print(f"\n{label}: {deleted} deleted, {failed} failed/skipped")
return deleted, failed
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--dry-run', action='store_true', help='Print actions without deleting')
parser.add_argument('--yes', '-y', action='store_true', help='Skip confirmation prompt')
parser.add_argument('--arr', choices=['sonarr', 'radarr', 'both'], default='both')
parser.add_argument('--title', default='', help='Only process entries matching this title substring')
args = parser.parse_args()
with open(VERIFIED_JSON) as f:
data = json.load(f)
if args.dry_run:
print("DRY-RUN mode — nothing will be deleted\n")
total_deleted, total_failed = 0, 0
if args.arr in ('radarr', 'both'):
d, f = process(data['radarr_matched'], RADARR_DL_ROOT, 'Radarr', args.dry_run, args.title, args.yes)
total_deleted += d
total_failed += f
if args.arr in ('sonarr', 'both'):
d, f = process(data['sonarr_matched'], SONARR_DL_ROOT, 'Sonarr', args.dry_run, args.title, args.yes)
total_deleted += d
total_failed += f
print(f"\nTotal: {total_deleted} deleted, {total_failed} failed/skipped")
if __name__ == '__main__':
main()