From 8239988a70f1c508918e88afbd038675ee3785fb Mon Sep 17 00:00:00 2001 From: Tuan-Dat Tran Date: Thu, 23 Apr 2026 08:06:27 +0200 Subject: [PATCH] docs(runbook): add arr-stack downloads cleanup investigation and scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~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). --- docs/runbooks/arr-cleanup/cleanup-orphans.py | 259 +++++++++++++++++++ docs/runbooks/arr-cleanup/cleanup.log | 160 ++++++++++++ docs/runbooks/arr-cleanup/cleanup.py | 132 ++++++++++ docs/runbooks/arr-cleanup/findings.md | 146 +++++++++++ docs/runbooks/arr-cleanup/verify.py | 246 ++++++++++++++++++ 5 files changed, 943 insertions(+) create mode 100644 docs/runbooks/arr-cleanup/cleanup-orphans.py create mode 100644 docs/runbooks/arr-cleanup/cleanup.log create mode 100644 docs/runbooks/arr-cleanup/cleanup.py create mode 100644 docs/runbooks/arr-cleanup/findings.md create mode 100644 docs/runbooks/arr-cleanup/verify.py diff --git a/docs/runbooks/arr-cleanup/cleanup-orphans.py b/docs/runbooks/arr-cleanup/cleanup-orphans.py new file mode 100644 index 0000000..f56ff20 --- /dev/null +++ b/docs/runbooks/arr-cleanup/cleanup-orphans.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Delete download entries from /media/downloads/sonarr that are NOT in Sonarr, +logging every action (size, path, timestamp, outcome) to cleanup.log. + +Runs in two passes: + 1. Tries hard to match each orphan against Sonarr (title + romaji + partial). + Anything that matches is skipped — only true non-matches are deleted. + 2. For each confirmed non-match, checks whether a directory with that show + name exists in /media/series (belt-and-suspenders). If it does, skips. + 3. Deletes remaining entries and logs every outcome. + +Usage: + python3 cleanup-orphans.py --dry-run # show what would be deleted + python3 cleanup-orphans.py --yes # delete without confirmation +""" + +import urllib.request +import json +import subprocess +import re +import os +import sys +import argparse +from datetime import datetime, timezone + +SONARR_URL = "http://localhost:8989/api/v3" +SSH_HOST = "aya01" +DL_ROOT = "/media/downloads/sonarr" +SERIES_ROOT = "/media/series" + +script_dir = os.path.dirname(os.path.abspath(__file__)) +LOG_FILE = os.path.join(script_dir, "cleanup.log") + +with open(os.path.join(script_dir, '..', 'sonarr.api.env')) as f: + SONARR_KEY = f.read().strip() + + +def api_get(url): + with urllib.request.urlopen(url, timeout=30) as r: + return json.load(r) + + +def norm(s): + return re.sub(r'[^a-z0-9]', '', s.lower()) + + +def ssh_run(cmd): + r = subprocess.run(['ssh', SSH_HOST, cmd], capture_output=True, text=True) + return r.stdout.strip() + + +def ssh_exists(path): + return ssh_run(f'[ -e {json.dumps(path)} ] && echo yes || echo no') == 'yes' + + +def ssh_size(path): + """Return size in bytes, or 0 if path doesn't exist.""" + out = ssh_run(f'du -sb {json.dumps(path)} 2>/dev/null | cut -f1') + try: + return int(out) + except ValueError: + return 0 + + +def ssh_delete(path): + r = subprocess.run(['ssh', SSH_HOST, f'rm -rf {json.dumps(path)}'], + capture_output=True, text=True) + return r.returncode == 0, r.stderr.strip() + + +def log(line): + ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + entry = f"[{ts}] {line}" + print(entry) + with open(LOG_FILE, 'a') as f: + f.write(entry + '\n') + + +def extract_title(name): + """Strip season/episode/quality tags to recover a bare show title.""" + name = re.sub(r'\.(mkv|mp4|ts|avi)$', '', name, flags=re.IGNORECASE) + name = re.sub(r'^\[.*?\]\s*', '', name) # [Group] prefix + name = re.sub(r'\s*\[.*?\]\s*', ' ', name) # inline [tags] + name = re.sub(r'[\.\s_\-]?[Ss]\d{1,2}[Ee]\d{1,2}.*$', '', name) + name = re.sub(r'[\.\s_\-]?[Ss]\d{1,2}[\.\s_\-].*$', '', name) + name = re.sub(r'[\.\s_\-]?[Ss]\d{2}$', '', name) + name = re.sub(r'[\.\s_\-]?(19|20)\d{2}.*$', '', name) + name = re.sub(r'[\.\s_\-]?\d{3,4}p.*$', '', name) # 1080p etc + name = re.sub(r'[\.\-_]+', ' ', name).strip() + return name + + +def build_sonarr_index(series): + idx = {} + for s in series: + for title_variant in [s['title'], s.get('titleSlug', ''), s.get('sortTitle', '')]: + if title_variant: + idx[norm(title_variant)] = s + # Also index alternate titles if present + for alt in s.get('alternateTitles', []): + t = alt.get('title', '') + if t: + idx[norm(t)] = s + return idx + + +def find_in_sonarr(dl_name, idx): + title = extract_title(dl_name) + tn = norm(title) + if tn in idx: + return idx[tn], title + # Partial: dl title starts with series title (or vice versa), min 6 chars + for k, rec in idx.items(): + if k and len(k) >= 6 and len(tn) >= 6: + if tn.startswith(k) or k.startswith(tn): + return rec, title + return None, title + + +def confirm(prompt): + answer = input(f"{prompt} [y/N] ").strip().lower() + return answer == 'y' + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--dry-run', action='store_true') + parser.add_argument('--yes', '-y', action='store_true') + args = parser.parse_args() + + if args.dry_run: + print("DRY-RUN — nothing will be deleted\n") + + log("=" * 60) + log(f"cleanup-orphans.py started (dry_run={args.dry_run})") + + print("Fetching Sonarr series (including alternate titles)...") + series = api_get(f"{SONARR_URL}/series?apikey={SONARR_KEY}") + print(f" {len(series)} series") + idx = build_sonarr_index(series) + + # Collect series dirs on disk for secondary check + # Strip years, imdb tags, and punctuation so "Bleach (2004) {imdb-...}" matches "Bleach" + print("Fetching /media/series directory listing...") + series_on_disk_raw = ssh_run(f'ls {json.dumps(SERIES_ROOT)}/').splitlines() + def norm_dir(d): + d = re.sub(r'\{.*?\}', '', d) # remove {imdb-...} + d = re.sub(r'\(?\d{4}\)?', '', d) # remove years + d = re.sub(r'[^a-z0-9]', '', d.lower()) + return d + series_on_disk_norm = {norm_dir(d) for d in series_on_disk_raw if d.strip()} + + print("Fetching download listing...") + dl_entries = ssh_run(f'ls {json.dumps(DL_ROOT)}/').splitlines() + dl_entries = [e.strip() for e in dl_entries if e.strip()] + print(f" {len(dl_entries)} entries in {DL_ROOT}") + + # --- First pass: match against Sonarr --- + not_in_sonarr = [] + in_sonarr = [] + + for dl in dl_entries: + rec, extracted_title = find_in_sonarr(dl, idx) + if rec: + in_sonarr.append((dl, rec['title'])) + else: + not_in_sonarr.append((dl, extracted_title)) + + print(f"\n Matched to Sonarr: {len(in_sonarr)}") + print(f" NOT in Sonarr: {len(not_in_sonarr)}") + + # --- Second pass: check if series dir exists on disk anyway --- + skip_has_series_dir = [] + to_delete = [] + + for dl, title in not_in_sonarr: + title_n = norm(title) + # Check if any series dir on disk has a similar name + has_dir = any( + d and len(d) >= 6 and (title_n.startswith(d) or d.startswith(title_n)) + for d in series_on_disk_norm + ) + # Also check the full download path exists + dl_path = f"{DL_ROOT}/{dl}" + if has_dir: + skip_has_series_dir.append((dl, title, dl_path)) + else: + to_delete.append((dl, title, dl_path)) + + if skip_has_series_dir: + print(f"\n SKIPPED (series dir found on disk, needs manual review): {len(skip_has_series_dir)}") + for dl, title, _ in skip_has_series_dir: + print(f" {title:40s} ← {dl[:60]}") + + print(f"\n{'='*60}") + print(f"TO DELETE ({len(to_delete)} entries — not in Sonarr, no series dir on disk)") + print(f"{'='*60}") + + # Get sizes in parallel + print("\nMeasuring sizes...") + size_cmd = ' && '.join( + f'du -sb {json.dumps(f"{DL_ROOT}/{dl}")} 2>/dev/null | cut -f1' + for dl, _, _ in to_delete + ) + if to_delete: + size_out = ssh_run(f'bash -c {json.dumps(size_cmd)}').splitlines() + else: + size_out = [] + + sizes = {} + for i, (dl, title, path) in enumerate(to_delete): + try: + sizes[dl] = int(size_out[i]) if i < len(size_out) else 0 + except (ValueError, IndexError): + sizes[dl] = 0 + + total_bytes = sum(sizes.values()) + for dl, title, path in sorted(to_delete, key=lambda x: x[1]): + sz = sizes.get(dl, 0) + print(f" {sz/1e9:6.1f}G {title:40s} ← {dl[:60]}") + + print(f"\n Total: {total_bytes/1e9:.1f}G across {len(to_delete)} entries") + + if not to_delete: + log("Nothing to delete.") + return + + if not args.dry_run and not args.yes: + if not confirm(f"\nDelete {len(to_delete)} entries?"): + log("Aborted by user.") + return + + # --- Delete with logging --- + deleted_count = 0 + deleted_bytes = 0 + failed_count = 0 + + for dl, title, path in sorted(to_delete, key=lambda x: x[1]): + sz = sizes.get(dl, 0) + if args.dry_run: + log(f"DRY-RUN | {sz/1e9:.2f}G | {title} | {path}") + deleted_count += 1 + deleted_bytes += sz + else: + ok, err = ssh_delete(path) + if ok: + log(f"DELETED | {sz/1e9:.2f}G | {title} | {path}") + deleted_count += 1 + deleted_bytes += sz + else: + log(f"FAILED | {sz/1e9:.2f}G | {title} | {path} | {err}") + failed_count += 1 + + log(f"DONE | deleted={deleted_count} | freed={deleted_bytes/1e9:.1f}G | failed={failed_count}") + + +if __name__ == '__main__': + main() diff --git a/docs/runbooks/arr-cleanup/cleanup.log b/docs/runbooks/arr-cleanup/cleanup.log new file mode 100644 index 0000000..e0d62af --- /dev/null +++ b/docs/runbooks/arr-cleanup/cleanup.log @@ -0,0 +1,160 @@ +[2026-04-22T21:18:32Z] ============================================================ +[2026-04-22T21:18:32Z] cleanup-orphans.py started (dry_run=True) +[2026-04-22T21:18:55Z] DRY-RUN | 14.62G | BLEACH Thousand Year Blood War | /media/downloads/sonarr/BLEACH.Thousand-Year.Blood.War.S01.JAPANESE.1080p.DSNP.WEBRip.AAC2.0.x264-NTb[rartv] +[2026-04-22T21:18:55Z] DRY-RUN | 1971.45G | Bleach USBD Remux TL | /media/downloads/sonarr/Bleach USBD Remux TL +[2026-04-22T21:18:55Z] DRY-RUN | 0.52G | Gachiakuta 09 | /media/downloads/sonarr/[KiyoshiiSubs] Gachiakuta - 09 [1080p][H.265 - 10Bit].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.44G | Gachiakuta 19 ( | /media/downloads/sonarr/[SubsPlease] Gachiakuta - 19 (1080p) [019A6A50].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 24.39G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S01.1080p.MAX.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:18:55Z] DRY-RUN | 36.73G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S02.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:18:55Z] DRY-RUN | 37.52G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S03.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:18:55Z] DRY-RUN | 36.83G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S04.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:18:55Z] DRY-RUN | 37.77G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S05.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:18:55Z] DRY-RUN | 36.07G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S06.1080p.HMAX.WEB-DL.DD.5.1.H.264-GNOME +[2026-04-22T21:18:55Z] DRY-RUN | 29.48G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S07.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:18:55Z] DRY-RUN | 28.71G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S08.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:18:55Z] DRY-RUN | 4.58G | Grimgar Of Fantasy And Ash ( | /media/downloads/sonarr/Grimgar Of Fantasy And Ash (2016) S01 1080p BluRay 10bit EAC3 2 0 x265-iVy +[2026-04-22T21:18:55Z] DRY-RUN | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 01 (1080p) [4CA94F81] +[2026-04-22T21:18:55Z] DRY-RUN | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 05 (1080p) [A0556FA8].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 06 (1080p) [982D7547].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 07 (1080p) [247CFB44].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.52G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 10 (1080p) [ABE1B90A] +[2026-04-22T21:18:55Z] DRY-RUN | 1.52G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 13 (1080p) [230618C3].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 0.52G | Hikikomari Kyuuketsuki no Monmon 07 ( | /media/downloads/sonarr/[SubsPlease] Hikikomari Kyuuketsuki no Monmon - 07 (1080p) [B07BA1C7] +[2026-04-22T21:18:55Z] DRY-RUN | 10.49G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:18:55Z] DRY-RUN | 8.97G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.x264-NTG +[2026-04-22T21:18:55Z] DRY-RUN | 4.23G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S02.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:18:55Z] DRY-RUN | 4.97G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S02.1080p.NF.WEB-DL.DDP5.1.Atmos.x264-Telly +[2026-04-22T21:18:55Z] DRY-RUN | 5.99G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S03.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:18:55Z] DRY-RUN | 5.26G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S03.1080p.NF.WEBRip.DDP5.1.Atmos.x264-SMURF +[2026-04-22T21:18:55Z] DRY-RUN | 4.44G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S04.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:18:55Z] DRY-RUN | 0.88G | SANDA | /media/downloads/sonarr/SANDA.S01E02.1080p.WEB.H264-SENSEI +[2026-04-22T21:18:55Z] DRY-RUN | 0.70G | Senpai Is An Otokonoko | /media/downloads/sonarr/Senpai.Is.An.Otokonoko.S01E05.720p.WEB.H264-SKYANiME +[2026-04-22T21:18:55Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E01.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E02.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E03.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E04.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E07.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E08.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E10.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E12.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 31.17G | Sex Education | /media/downloads/sonarr/Sex.Education.S01.1080p.NF.WEB.DDP5.1.x264-DEFLATE +[2026-04-22T21:18:55Z] DRY-RUN | 41.53G | Sex Education | /media/downloads/sonarr/Sex.Education.S02.1080p.NF.WEB.DDP5.1.x264-NTb +[2026-04-22T21:18:55Z] DRY-RUN | 15.56G | Sex Education | /media/downloads/sonarr/Sex.Education.S03.1080p.NF.WEB-DL.DDP5.1.H.264-FLUX +[2026-04-22T21:18:55Z] DRY-RUN | 21.49G | Sex Education | /media/downloads/sonarr/Sex.Education.S04.1080p.NF.WEB-DL.DDP5.1.H.264-Archie +[2026-04-22T21:18:55Z] DRY-RUN | 1.49G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E02.THE.HERO.OF.MY.DREAMS.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.49G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E03.THE.MAN.WHO.STANDS.AT.THE.TOP.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.48G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E04.CLASH.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:18:55Z] DRY-RUN | 0.34G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E07.A.Fight.He.Cant.Lose.1080p.B-Global.WEB-DL.JPN.AAC2.0.H.264.MSubs-ToonsHub.mkv +[2026-04-22T21:18:55Z] DRY-RUN | 0.26G | Wind Breaker | /media/downloads/sonarr/Wind Breaker - S01E12 - 1080p WEB HEVC -NanDesuKa (B-Global).mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.46G | Wind Breaker 01 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 01 (1080p) [5D5071F6].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.46G | Wind Breaker 05 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 05 (1080p) [B6649F46].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 1.46G | Wind Breaker 06 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 06 (1080p) [1C13E5BC].mkv +[2026-04-22T21:18:55Z] DRY-RUN | 0.74G | Wistoria Wand And Sword | /media/downloads/sonarr/Wistoria.Wand.And.Sword.S01E01.720p.WEB.H264-SKYANiME +[2026-04-22T21:18:55Z] DRY-RUN | 1.45G | Wistoria Wand and Sword | /media/downloads/sonarr/Wistoria.Wand.and.Sword.S01E02.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 1.44G | Wistoria Wand and Sword | /media/downloads/sonarr/Wistoria.Wand.and.Sword.S01E03.1080p.WEB.H264-KAWAII +[2026-04-22T21:18:55Z] DRY-RUN | 0.00G | www UIndex org Severance | /media/downloads/sonarr/www.UIndex.org - Severance S02E10 Cold Harbor 1080p ATVP WEB-DL DDP5 1 Atmos H 264-Kitsune +[2026-04-22T21:18:55Z] DONE | deleted=53 | freed=2449.6G | failed=0 +[2026-04-22T21:23:05Z] ============================================================ +[2026-04-22T21:23:05Z] cleanup-orphans.py started (dry_run=True) +[2026-04-22T21:23:28Z] DRY-RUN | 24.39G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S01.1080p.MAX.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:23:28Z] DRY-RUN | 36.73G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S02.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:23:28Z] DRY-RUN | 37.52G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S03.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:23:28Z] DRY-RUN | 36.83G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S04.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:23:28Z] DRY-RUN | 37.77G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S05.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:23:28Z] DRY-RUN | 36.07G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S06.1080p.HMAX.WEB-DL.DD.5.1.H.264-GNOME +[2026-04-22T21:23:28Z] DRY-RUN | 29.48G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S07.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:23:28Z] DRY-RUN | 28.71G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S08.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:23:28Z] DRY-RUN | 4.58G | Grimgar Of Fantasy And Ash ( | /media/downloads/sonarr/Grimgar Of Fantasy And Ash (2016) S01 1080p BluRay 10bit EAC3 2 0 x265-iVy +[2026-04-22T21:23:28Z] DRY-RUN | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 01 (1080p) [4CA94F81] +[2026-04-22T21:23:28Z] DRY-RUN | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 05 (1080p) [A0556FA8].mkv +[2026-04-22T21:23:28Z] DRY-RUN | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 06 (1080p) [982D7547].mkv +[2026-04-22T21:23:28Z] DRY-RUN | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 07 (1080p) [247CFB44].mkv +[2026-04-22T21:23:28Z] DRY-RUN | 1.52G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 10 (1080p) [ABE1B90A] +[2026-04-22T21:23:28Z] DRY-RUN | 1.52G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 13 (1080p) [230618C3].mkv +[2026-04-22T21:23:28Z] DRY-RUN | 0.52G | Hikikomari Kyuuketsuki no Monmon 07 ( | /media/downloads/sonarr/[SubsPlease] Hikikomari Kyuuketsuki no Monmon - 07 (1080p) [B07BA1C7] +[2026-04-22T21:23:28Z] DRY-RUN | 10.49G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:23:28Z] DRY-RUN | 8.97G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.x264-NTG +[2026-04-22T21:23:28Z] DRY-RUN | 4.23G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S02.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:23:28Z] DRY-RUN | 4.97G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S02.1080p.NF.WEB-DL.DDP5.1.Atmos.x264-Telly +[2026-04-22T21:23:28Z] DRY-RUN | 5.99G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S03.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:23:28Z] DRY-RUN | 5.26G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S03.1080p.NF.WEBRip.DDP5.1.Atmos.x264-SMURF +[2026-04-22T21:23:28Z] DRY-RUN | 4.44G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S04.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:23:28Z] DRY-RUN | 0.88G | SANDA | /media/downloads/sonarr/SANDA.S01E02.1080p.WEB.H264-SENSEI +[2026-04-22T21:23:28Z] DRY-RUN | 0.70G | Senpai Is An Otokonoko | /media/downloads/sonarr/Senpai.Is.An.Otokonoko.S01E05.720p.WEB.H264-SKYANiME +[2026-04-22T21:23:28Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E01.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E02.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E03.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E04.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E07.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E08.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E10.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E12.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 31.17G | Sex Education | /media/downloads/sonarr/Sex.Education.S01.1080p.NF.WEB.DDP5.1.x264-DEFLATE +[2026-04-22T21:23:28Z] DRY-RUN | 41.53G | Sex Education | /media/downloads/sonarr/Sex.Education.S02.1080p.NF.WEB.DDP5.1.x264-NTb +[2026-04-22T21:23:28Z] DRY-RUN | 15.56G | Sex Education | /media/downloads/sonarr/Sex.Education.S03.1080p.NF.WEB-DL.DDP5.1.H.264-FLUX +[2026-04-22T21:23:28Z] DRY-RUN | 21.49G | Sex Education | /media/downloads/sonarr/Sex.Education.S04.1080p.NF.WEB-DL.DDP5.1.H.264-Archie +[2026-04-22T21:23:28Z] DRY-RUN | 1.49G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E02.THE.HERO.OF.MY.DREAMS.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:23:28Z] DRY-RUN | 1.49G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E03.THE.MAN.WHO.STANDS.AT.THE.TOP.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:23:28Z] DRY-RUN | 1.48G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E04.CLASH.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:23:28Z] DRY-RUN | 0.34G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E07.A.Fight.He.Cant.Lose.1080p.B-Global.WEB-DL.JPN.AAC2.0.H.264.MSubs-ToonsHub.mkv +[2026-04-22T21:23:28Z] DRY-RUN | 0.26G | Wind Breaker | /media/downloads/sonarr/Wind Breaker - S01E12 - 1080p WEB HEVC -NanDesuKa (B-Global).mkv +[2026-04-22T21:23:28Z] DRY-RUN | 1.46G | Wind Breaker 01 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 01 (1080p) [5D5071F6].mkv +[2026-04-22T21:23:28Z] DRY-RUN | 1.46G | Wind Breaker 05 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 05 (1080p) [B6649F46].mkv +[2026-04-22T21:23:28Z] DRY-RUN | 1.46G | Wind Breaker 06 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 06 (1080p) [1C13E5BC].mkv +[2026-04-22T21:23:28Z] DRY-RUN | 0.74G | Wistoria Wand And Sword | /media/downloads/sonarr/Wistoria.Wand.And.Sword.S01E01.720p.WEB.H264-SKYANiME +[2026-04-22T21:23:28Z] DRY-RUN | 1.45G | Wistoria Wand and Sword | /media/downloads/sonarr/Wistoria.Wand.and.Sword.S01E02.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 1.44G | Wistoria Wand and Sword | /media/downloads/sonarr/Wistoria.Wand.and.Sword.S01E03.1080p.WEB.H264-KAWAII +[2026-04-22T21:23:28Z] DRY-RUN | 0.00G | www UIndex org Severance | /media/downloads/sonarr/www.UIndex.org - Severance S02E10 Cold Harbor 1080p ATVP WEB-DL DDP5 1 Atmos H 264-Kitsune +[2026-04-22T21:23:28Z] DONE | deleted=49 | freed=461.6G | failed=0 +[2026-04-22T21:32:57Z] ============================================================ +[2026-04-22T21:32:57Z] cleanup-orphans.py started (dry_run=False) +[2026-04-22T21:33:31Z] DELETED | 24.39G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S01.1080p.MAX.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:34:04Z] DELETED | 36.73G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S02.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:34:39Z] DELETED | 37.52G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S03.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:35:05Z] DELETED | 36.83G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S04.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:35:33Z] DELETED | 37.77G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S05.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:35:51Z] DELETED | 36.07G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S06.1080p.HMAX.WEB-DL.DD.5.1.H.264-GNOME +[2026-04-22T21:36:01Z] DELETED | 29.48G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S07.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:36:09Z] DELETED | 28.71G | Game of Thrones | /media/downloads/sonarr/Game.of.Thrones.S08.NORDiC.1080p.HMAX.WEB-DL.DDP5.1.Atmos.H.264-DKV +[2026-04-22T21:36:10Z] DELETED | 4.58G | Grimgar Of Fantasy And Ash ( | /media/downloads/sonarr/Grimgar Of Fantasy And Ash (2016) S01 1080p BluRay 10bit EAC3 2 0 x265-iVy +[2026-04-22T21:36:11Z] DELETED | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 01 (1080p) [4CA94F81] +[2026-04-22T21:36:11Z] DELETED | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 05 (1080p) [A0556FA8].mkv +[2026-04-22T21:36:11Z] DELETED | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 06 (1080p) [982D7547].mkv +[2026-04-22T21:36:12Z] DELETED | 1.53G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 07 (1080p) [247CFB44].mkv +[2026-04-22T21:36:13Z] DELETED | 1.52G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 10 (1080p) [ABE1B90A] +[2026-04-22T21:36:13Z] DELETED | 1.52G | Hibike! Euphonium | /media/downloads/sonarr/[SubsPlease] Hibike! Euphonium S3 - 13 (1080p) [230618C3].mkv +[2026-04-22T21:36:13Z] DELETED | 0.52G | Hikikomari Kyuuketsuki no Monmon 07 ( | /media/downloads/sonarr/[SubsPlease] Hikikomari Kyuuketsuki no Monmon - 07 (1080p) [B07BA1C7] +[2026-04-22T21:36:15Z] DELETED | 10.49G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:36:16Z] DELETED | 8.97G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.x264-NTG +[2026-04-22T21:36:16Z] DELETED | 4.23G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S02.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:36:17Z] DELETED | 4.97G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S02.1080p.NF.WEB-DL.DDP5.1.Atmos.x264-Telly +[2026-04-22T21:36:17Z] DELETED | 5.99G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S03.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:36:18Z] DELETED | 5.26G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S03.1080p.NF.WEBRip.DDP5.1.Atmos.x264-SMURF +[2026-04-22T21:36:22Z] DELETED | 4.44G | Love Death and Robots | /media/downloads/sonarr/Love.Death.and.Robots.S04.1080p.NF.WEB-DL.DDP5.1.Atmos.H.264-FLUX +[2026-04-22T21:36:22Z] DELETED | 0.88G | SANDA | /media/downloads/sonarr/SANDA.S01E02.1080p.WEB.H264-SENSEI +[2026-04-22T21:36:22Z] DELETED | 0.70G | Senpai Is An Otokonoko | /media/downloads/sonarr/Senpai.Is.An.Otokonoko.S01E05.720p.WEB.H264-SKYANiME +[2026-04-22T21:36:22Z] DELETED | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E01.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:23Z] DELETED | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E02.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:23Z] DELETED | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E03.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:23Z] DELETED | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E04.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:23Z] DELETED | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E07.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:24Z] DELETED | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E08.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:24Z] DELETED | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E10.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:24Z] DELETED | 1.39G | Senpai is an Otokonoko | /media/downloads/sonarr/Senpai.is.an.Otokonoko.S01E12.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:25Z] DELETED | 31.17G | Sex Education | /media/downloads/sonarr/Sex.Education.S01.1080p.NF.WEB.DDP5.1.x264-DEFLATE +[2026-04-22T21:36:26Z] DELETED | 41.53G | Sex Education | /media/downloads/sonarr/Sex.Education.S02.1080p.NF.WEB.DDP5.1.x264-NTb +[2026-04-22T21:36:26Z] DELETED | 15.56G | Sex Education | /media/downloads/sonarr/Sex.Education.S03.1080p.NF.WEB-DL.DDP5.1.H.264-FLUX +[2026-04-22T21:36:27Z] DELETED | 21.49G | Sex Education | /media/downloads/sonarr/Sex.Education.S04.1080p.NF.WEB-DL.DDP5.1.H.264-Archie +[2026-04-22T21:36:27Z] DELETED | 1.49G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E02.THE.HERO.OF.MY.DREAMS.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:36:28Z] DELETED | 1.49G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E03.THE.MAN.WHO.STANDS.AT.THE.TOP.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:36:28Z] DELETED | 1.48G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E04.CLASH.1080p.CR.WEB-DL.AAC2.0.H.264.DUAL-VARYG.mkv +[2026-04-22T21:36:28Z] DELETED | 0.34G | WIND BREAKER | /media/downloads/sonarr/WIND.BREAKER.S01E07.A.Fight.He.Cant.Lose.1080p.B-Global.WEB-DL.JPN.AAC2.0.H.264.MSubs-ToonsHub.mkv +[2026-04-22T21:36:29Z] DELETED | 0.26G | Wind Breaker | /media/downloads/sonarr/Wind Breaker - S01E12 - 1080p WEB HEVC -NanDesuKa (B-Global).mkv +[2026-04-22T21:36:29Z] DELETED | 1.46G | Wind Breaker 01 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 01 (1080p) [5D5071F6].mkv +[2026-04-22T21:36:29Z] DELETED | 1.46G | Wind Breaker 05 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 05 (1080p) [B6649F46].mkv +[2026-04-22T21:36:30Z] DELETED | 1.46G | Wind Breaker 06 ( | /media/downloads/sonarr/[SubsPlease] Wind Breaker - 06 (1080p) [1C13E5BC].mkv +[2026-04-22T21:36:30Z] DELETED | 0.74G | Wistoria Wand And Sword | /media/downloads/sonarr/Wistoria.Wand.And.Sword.S01E01.720p.WEB.H264-SKYANiME +[2026-04-22T21:36:30Z] DELETED | 1.45G | Wistoria Wand and Sword | /media/downloads/sonarr/Wistoria.Wand.and.Sword.S01E02.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:31Z] DELETED | 1.44G | Wistoria Wand and Sword | /media/downloads/sonarr/Wistoria.Wand.and.Sword.S01E03.1080p.WEB.H264-KAWAII +[2026-04-22T21:36:31Z] DELETED | 0.00G | www UIndex org Severance | /media/downloads/sonarr/www.UIndex.org - Severance S02E10 Cold Harbor 1080p ATVP WEB-DL DDP5 1 Atmos H 264-Kitsune +[2026-04-22T21:36:31Z] DONE | deleted=49 | freed=461.6G | failed=0 diff --git a/docs/runbooks/arr-cleanup/cleanup.py b/docs/runbooks/arr-cleanup/cleanup.py new file mode 100644 index 0000000..ba0694b --- /dev/null +++ b/docs/runbooks/arr-cleanup/cleanup.py @@ -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() diff --git a/docs/runbooks/arr-cleanup/findings.md b/docs/runbooks/arr-cleanup/findings.md new file mode 100644 index 0000000..0e8f835 --- /dev/null +++ b/docs/runbooks/arr-cleanup/findings.md @@ -0,0 +1,146 @@ +# arr-stack Downloads Cleanup — Investigation Findings + +## Storage Layout (aya01) + +| Device | FS | Size | Used | Mount | +|--------|----|------|------|-------| +| `/dev/sdc3` | btrfs | 1.9T | 177G (10%) | `/` (system) | +| `/dev/sda1` | btrfs `proxmox` | 2.8T | 1.3T (48%) | `/opt` | +| `/dev/sdd1` | ext4 | 17T | 15T (92%) | `/mnt/hdd0` | +| `/dev/sde1` | ext4 | 17T | 15T (92%) | `/mnt/hdd2` | +| `/dev/sdf1` | ext4 | 17T | 15T (92%) | `/mnt/hdd1` | +| `mergerfs` | fuse | 49T | 43T (92%) | `/media` | + +`/media` is a mergerfs union of hdd0 + hdd1 + hdd2. All three HDDs were at ~92% capacity before cleanup. + +**After cleanup (2026-04-23):** + +| Device | Used | Avail | Use% | +|--------|------|-------|------| +| `/dev/sdd1` (hdd0) | 9.4T | 6.2T | 61% | +| `/dev/sdf1` (hdd1) | 9.3T | 6.3T | 60% | +| `/dev/sde1` (hdd2) | 7.8T | 7.8T | 51% | +| `mergerfs /media` | 27T | 21T | 57% | + +**~16T freed total** (92% → 57% on the mergerfs pool). + +## /media Breakdown (before cleanup) + +| Directory | Size | +|-----------|------| +| `downloads` | **22T** | +| `series` | 16T | +| `movies` | 5T | + +## Root Cause: No Hardlinks → All Imports Are Copies + +Zero hardlinked files exist anywhere across all three HDDs. Confirmed by inspecting the Kubernetes manifests in `argocd-homelab/services/arr-stack/` and by inode comparison of 1365 download/media file pairs (0 shared inodes found). + +**All three services mount the mergerfs `/media/` path via NFS:** + +``` +sonarr: NFS 192.168.20.12:/media/downloads → /downloads + NFS 192.168.20.12:/media/series → /tv +radarr: NFS 192.168.20.12:/media/downloads → /downloads + NFS 192.168.20.12:/media/movies → /movies +qbit: NFS 192.168.20.12:/media/downloads → /downloads +``` + +mergerfs does not support hardlinks across underlying filesystems. When qBit downloads to `/media/downloads/sonarr/` (lands on e.g. hdd1) and Sonarr imports to `/media/series/` (lands on e.g. hdd0), the hardlink attempt crosses a physical disk boundary → falls back to copy. Every import doubles the data. + +## Cleanup Performed (2026-04-23) + +Three passes using the scripts in this directory: + +### Pass 1 — Orphans (not in Sonarr at all) +Script: `cleanup-orphans.py` + +Deleted 49 entries totalling **461.6G** — downloads with no matching Sonarr series and no series directory on disk. Includes Game of Thrones (all 8 seasons), Sex Education (all 4 seasons), Love Death & Robots (multiple duplicate copies), and various anime episode files. + +111 entries were SKIPPED (series dir found on disk, needs manual review) — includes Bleach, House, Lucifer, You, Detective Conan episodes, What If, etc. See cleanup.log for full list. + +### Pass 2 — Confirmed-imported Sonarr downloads +Script: `cleanup.py --arr sonarr` + +Deleted **1106 entries**, 0 failed. These were downloads where Sonarr confirmed `episodeFileCount > 0` AND the series directory was verified to exist on disk at the time of `verify.py` run. + +### Pass 3 — Confirmed-imported Radarr downloads +Script: `cleanup.py --arr radarr` + +Deleted **259 entries**, 0 failed. These were downloads where Radarr confirmed `hasFile=True` AND the file/directory path was verified to exist on disk. + +### Totals +| Pass | Entries | Space | +|------|---------|-------| +| Orphans (cleanup-orphans.py) | 49 | ~461G | +| Sonarr imports (cleanup.py) | 1106 | ~12T (estimated) | +| Radarr imports (cleanup.py) | 259 | ~4T (estimated) | +| **Total** | **1414** | **~16T freed** | + +All deletions logged to `cleanup.log` with UTC timestamp, size, title, path, outcome. + +## Verification Results (via API + disk path check) + +API keys stored in `../sonarr.api.env` and `../radarr.api.env`. +Access via `kubectl -n arr-stack port-forward svc/sonarr 8989:8989` and `svc/radarr 7878:7878`. + +Container path mappings: +- Sonarr `/tv/` → `/media/series/` +- Radarr `/movies/` → `/media/movies/` + +| | Safe to delete | Orphans (not in arr) | Keep | +|---|---|---|---| +| **Radarr** (289 items, ~5.2T) | **265** | 25 | 0 | +| **Sonarr** (1439 items, ~17T) | **1106** | 333 | 0 | + +"Safe to delete" = API confirms `hasFile=True` (Radarr) or `episodeFileCount > 0` (Sonarr), AND the reported file/directory path was verified to exist on disk via SSH. + +### Radarr Orphans (25) — not matched in Radarr, not deleted +- Constantine (2005) +- Cowboy Bebop: Knockin' on Heaven's Door (2001) +- Les Misérables (2012) +- Pokémon Detective Pikachu (2019) +- Code Geass: Fukkatsu no Lelouch (2019) +- Eiga Go-Toubun no Hanayome (2022) +- Gisaengchung / Parasite (Korean title — matching failure) +- Dune: Part One (2021) — matching failure, is in Radarr +- Harry Potter (older/duplicate copies — matching failure) +- Porco Rosso / Kurenai no buta — matching failure +- Castle in the Sky / Laputa — matching failure +- Steins;Gate: The Movie — matching failure +- Project Silence / Talchul — matching failure +- Digimon: Frontier & Savers films +- One Piece films (several) +- Paripi Koumei movie +- Fantastic Four (2025) extra copies (3) +- JJK DCP trailer file + +### 6 Radarr "path mismatch" entries (all confirmed safe, deleted) +Flagged due to path comparison artifacts, manually verified on disk: +- Star Wars Episode IV/V/VI/IX — each is a separate Radarr entry; all directories exist +- WALL·E — `·` middle-dot character caused comparison failure; file exists + +## Pending Decisions + +### Bleach USBD Remux TL (1.8T) +`/media/downloads/sonarr/Bleach USBD Remux TL` — full lossless Bluray remux S00–S16 (-ZR- group). +Currently in SKIPPED (series dir `/media/series/Bleach (2004) {imdb-tt0434665}/` exists, 310G imported). +Most seasons were imported from x265 Bluray packs (-iVy group) rather than from this remux. +S11 has no imported content at all. S13, S14 partially imported. +Decision: keep (for quality imports once disk freed) or delete (free 1.8T, accept x265 quality). +See memory file for full per-season breakdown. + +### SKIPPED downloads (111 Sonarr entries) +Downloads where the series directory exists on disk but the series is not currently in Sonarr. +Likely removed series (House, Lucifer, You, Black Clover, etc.) or ongoing shows with stale episodes. +These need manual review — series may have been intentionally removed from Sonarr. + +## Fix (not applied — future reference) +Mount per-HDD NFS paths instead of the mergerfs path, so downloads and media share the same physical filesystem and hardlinks work: +```yaml +# sonarr/radarr/qtun deployments — change NFS path from: +path: /media/downloads → path: /mnt/hdd0/downloads +path: /media/series → path: /mnt/hdd0/series +path: /media/movies → path: /mnt/hdd0/movies +``` +Jellyfin/Plex continue reading from `/media/` (mergerfs). New imports hardlink within hdd0. diff --git a/docs/runbooks/arr-cleanup/verify.py b/docs/runbooks/arr-cleanup/verify.py new file mode 100644 index 0000000..fbd7d8b --- /dev/null +++ b/docs/runbooks/arr-cleanup/verify.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +Cross-reference /media/downloads/sonarr and /media/downloads/radarr against +the Sonarr/Radarr APIs, then verify reported file paths actually exist on disk. + +Requirements: + - kubectl port-forwards active: + kubectl -n arr-stack port-forward svc/sonarr 8989:8989 + kubectl -n arr-stack port-forward svc/radarr 7878:7878 + - SSH access to aya01 + - API keys in ../sonarr.api.env and ../radarr.api.env + +Output: + /tmp/arr_verified.json — full structured results for use by cleanup.py +""" + +import urllib.request +import json +import subprocess +import re +import sys +import os + +SONARR_URL = "http://localhost:8989/api/v3" +RADARR_URL = "http://localhost:7878/api/v3" +SSH_HOST = "aya01" + +script_dir = os.path.dirname(os.path.abspath(__file__)) + +def load_key(filename): + path = os.path.join(script_dir, '..', filename) + return open(path).read().strip() + +SONARR_KEY = load_key('sonarr.api.env') +RADARR_KEY = load_key('radarr.api.env') + + +def api_get(url): + with urllib.request.urlopen(url, timeout=30) as r: + return json.load(r) + + +def norm(s): + return re.sub(r'[^a-z0-9]', '', s.lower()) + + +def extract_title(name, is_movie): + """Strip release tags from a download name to recover a bare title.""" + name = re.sub(r'\.(mkv|mp4|avi|m4v)$', '', name, flags=re.IGNORECASE) + name = re.sub(r'\[.*?\]', '', name) + if is_movie: + name = re.sub(r'[\.\s_\-]?(19|20)\d{2}.*$', '', name) + else: + name = re.sub(r'[\.\s_\-]?[Ss]\d{1,2}([Ee]\d{1,2})?.*$', '', name) + return re.sub(r'[\.\-_]+', ' ', name).strip() + + +def build_index(records, key_fn): + idx = {} + for rec in records: + for k in key_fn(rec): + if k: + idx[k] = rec + return idx + + +def find_match(dl_name, idx, is_movie): + title = extract_title(dl_name, is_movie) + tn = norm(title) + if tn in idx: + return idx[tn] + for k, rec in idx.items(): + if k and len(k) > 5 and (tn.startswith(k) or k.startswith(tn)): + return rec + return None + + +def ssh_check_paths(paths): + """Return (existing, missing) sets for the given list of paths.""" + if not paths: + return set(), set() + cmds = '\n'.join( + f'[ -e {json.dumps(p)} ] && echo "EXISTS:{p}" || echo "MISSING:{p}"' + for p in paths + ) + r = subprocess.run(['ssh', SSH_HOST, 'bash', '-s'], + input=cmds, capture_output=True, text=True) + existing, missing = set(), set() + for line in r.stdout.splitlines(): + if line.startswith('EXISTS:'): + existing.add(line[7:]) + elif line.startswith('MISSING:'): + missing.add(line[8:]) + return existing, missing + + +def main(): + print("Fetching Radarr movies...") + radarr_movies = api_get(f"{RADARR_URL}/movie?apikey={RADARR_KEY}") + print(f" {len(radarr_movies)} movies") + + print("Fetching Sonarr series...") + sonarr_series = api_get(f"{SONARR_URL}/series?apikey={SONARR_KEY}") + print(f" {len(sonarr_series)} series") + + # Radarr index + def radarr_keys(m): + return [norm(m['title']), norm(f"{m['title']}{m.get('year','')}")] + + radarr_idx = build_index(radarr_movies, radarr_keys) + + # Enrich radarr records with disk path + for m in radarr_movies: + mf = m.get('movieFile') + m['_file_path'] = ( + mf['path'].replace('/movies/', '/media/movies/', 1) if mf and mf.get('path') else None + ) + m['_dir_path'] = m.get('path', '').replace('/movies/', '/media/movies/', 1) + + # Sonarr index + def sonarr_keys(s): + return [norm(s['title'])] + + sonarr_idx = build_index(sonarr_series, sonarr_keys) + + for s in sonarr_series: + s['_dir_path'] = s.get('path', '').replace('/tv/', '/media/series/', 1) + + # Download listings + print(f"\nFetching download listings from {SSH_HOST}...") + r = subprocess.run( + ['ssh', SSH_HOST, 'ls /media/downloads/sonarr/ && echo "===RADARR===" && ls /media/downloads/radarr/'], + capture_output=True, text=True + ) + parts = r.stdout.split('===RADARR===\n') + sonarr_dls = [l.strip() for l in parts[0].splitlines() if l.strip()] + radarr_dls = [l.strip() for l in parts[1].splitlines() if l.strip()] + print(f" Sonarr downloads: {len(sonarr_dls)}") + print(f" Radarr downloads: {len(radarr_dls)}") + + # Match and collect paths + radarr_matched, radarr_orphans = [], [] + for dl in radarr_dls: + rec = find_match(dl, radarr_idx, is_movie=True) + if rec is None: + radarr_orphans.append(dl) + else: + check_path = rec['_file_path'] or rec['_dir_path'] + radarr_matched.append({ + 'dl': dl, + 'title': rec['title'], + 'year': rec.get('year'), + 'hasFile': rec.get('hasFile', False), + 'monitored': rec.get('monitored'), + 'check_path': check_path, + }) + + sonarr_matched, sonarr_orphans = [], [] + for dl in sonarr_dls: + rec = find_match(dl, sonarr_idx, is_movie=False) + if rec is None: + sonarr_orphans.append(dl) + else: + stats = rec.get('statistics', {}) + sonarr_matched.append({ + 'dl': dl, + 'title': rec['title'], + 'episodeFileCount': stats.get('episodeFileCount', 0), + 'totalEpisodeCount': stats.get('totalEpisodeCount', 0), + 'percentOfEpisodes': stats.get('percentOfEpisodes', 0), + 'monitored': rec.get('monitored'), + 'status': rec.get('status'), + 'check_path': rec['_dir_path'], + }) + + # Batch disk verification + all_paths = list(set( + [m['check_path'] for m in radarr_matched if m['check_path']] + + [m['check_path'] for m in sonarr_matched if m['check_path']] + )) + print(f"\nVerifying {len(all_paths)} paths on disk...") + existing, missing = ssh_check_paths(all_paths) + print(f" {len(existing)} exist, {len(missing)} missing") + + # Classify + def classify_radarr(m): + if not m['hasFile'] or not m['check_path']: + return 'not_imported' + if m['check_path'] in existing: + return 'safe' + return 'path_missing' + + def classify_sonarr(m): + if m['episodeFileCount'] == 0 or not m['check_path']: + return 'not_imported' + if m['check_path'] in existing: + return 'safe' + return 'path_missing' + + for m in radarr_matched: + m['status'] = classify_radarr(m) + for m in sonarr_matched: + m['status'] = classify_sonarr(m) + + result = { + 'radarr_matched': radarr_matched, + 'radarr_orphans': radarr_orphans, + 'sonarr_matched': sonarr_matched, + 'sonarr_orphans': sonarr_orphans, + 'existing_paths': list(existing), + 'missing_paths': list(missing), + } + + out_path = '/tmp/arr_verified.json' + with open(out_path, 'w') as f: + json.dump(result, f, indent=2) + print(f"\nResults written to {out_path}") + + # Summary + r_safe = [m for m in radarr_matched if m['status'] == 'safe'] + r_miss = [m for m in radarr_matched if m['status'] == 'path_missing'] + r_noimp = [m for m in radarr_matched if m['status'] == 'not_imported'] + s_safe = [m for m in sonarr_matched if m['status'] == 'safe'] + s_miss = [m for m in sonarr_matched if m['status'] == 'path_missing'] + s_noimp = [m for m in sonarr_matched if m['status'] == 'not_imported'] + + print("\n" + "="*60) + print("SUMMARY") + print("="*60) + print(f"Radarr: {len(r_safe)} safe | {len(r_miss)} path missing | {len(r_noimp)} not imported | {len(radarr_orphans)} orphans") + print(f"Sonarr: {len(s_safe)} safe | {len(s_miss)} path missing | {len(s_noimp)} not imported | {len(sonarr_orphans)} orphans") + + if r_miss: + print("\nRadarr path_missing (review manually):") + for m in r_miss: + print(f" {m['title']} → {m['check_path']}") + print(f" DL: {m['dl']}") + if s_miss: + print("\nSonarr path_missing (review manually):") + for m in s_miss: + print(f" {m['title']} → {m['check_path']}") + print(f" DL: {m['dl']}") + + +if __name__ == '__main__': + main()