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:
Tuan-Dat Tran
2026-04-23 08:06:27 +02:00
parent e87dcd06f3
commit 8239988a70
5 changed files with 943 additions and 0 deletions

View File

@@ -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()

View File

@@ -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

View 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()

View File

@@ -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 S00S16 (-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.

View File

@@ -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()