#!/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()