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).
This commit is contained in:
132
docs/runbooks/arr-cleanup/cleanup.py
Normal file
132
docs/runbooks/arr-cleanup/cleanup.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user