~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).
133 lines
4.4 KiB
Python
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()
|