torrent-audit: make more robust

This commit is contained in:
2026-05-04 02:27:52 -04:00
parent ce42ccdcc0
commit 09175cd0dc
2 changed files with 128 additions and 21 deletions

View File

@@ -74,6 +74,26 @@ def gib(size_bytes: int) -> str:
return f"{size_bytes / 1073741824:.1f}"
def _is_keeper_viable(
keeper_hash: str,
all_qbit: dict[str, dict],
file_path: str | None,
) -> bool:
"""True iff the keeper (newer import) is actually viable.
A keeper is viable when its data is reachable: either the keeper
torrent is complete in qBittorrent, or (when no torrent exists) the
file is present on the filesystem.
"""
keeper_torrent = all_qbit.get(keeper_hash)
if keeper_torrent is not None:
return is_complete(keeper_torrent)
# Keeper hash not in qBittorrent -- trust Radarr's file mapping
# only if the file actually exists on disk.
if file_path:
return os.path.exists(file_path)
return False
# ---------------------------------------------------------------------------
# Collect all known hashes from *arr history + queue
# ---------------------------------------------------------------------------
@@ -107,7 +127,7 @@ def find_unmanaged(qbit_torrents: dict, known_hashes: set) -> list[dict]:
# ---------------------------------------------------------------------------
def find_movie_abandoned(radarr, qbit_movies):
def find_movie_abandoned(radarr, qbit_movies, all_qbit=None):
log.info("Analysing Radarr import history ...")
imports_by_movie = defaultdict(list)
for rec in paginate(radarr, "history"):
@@ -126,11 +146,14 @@ def find_movie_abandoned(radarr, qbit_movies):
# Identify keeper (latest) and abandoned (older) hashes per movie.
abandoned_hashes: set[str] = set()
keeper_hashes: set[str] = set()
hash_to_movie: dict[str, int] = {}
hash_to_movie: dict[str, int] = {} # abandoned hash -> movieId
movie_to_keeper: dict[int, str] = {} # movieId -> keeper hash
for mid, events in imports_by_movie.items():
ordered = sorted(events, key=lambda e: e["date"])
keeper_hashes.add(ordered[-1]["downloadId"])
keeper = ordered[-1]["downloadId"]
keeper_hashes.add(keeper)
movie_to_keeper[mid] = keeper
for e in ordered[:-1]:
abandoned_hashes.add(e["downloadId"])
hash_to_movie[e["downloadId"]] = mid
@@ -152,7 +175,6 @@ def find_movie_abandoned(radarr, qbit_movies):
# re-download in progress.
if not is_complete(torrent):
continue
mid = hash_to_movie.get(ahash)
movie = radarr_movies.get(mid) if mid else None
mf = (movie or {}).get("movieFile") or {}
@@ -163,16 +185,27 @@ def find_movie_abandoned(radarr, qbit_movies):
status = "SAFE"
notes = []
# Verify the keeper (newer import) is actually viable before
# declaring the abandoned torrent safe to delete.
keeper_hash = movie_to_keeper.get(mid) if mid else None
if keeper_hash and all_qbit is not None:
file_path = mf.get("path")
if not _is_keeper_viable(keeper_hash, all_qbit, file_path):
notes.append(
"keeper torrent incomplete or missing, "
"file not on disk"
)
status = "REVIEW"
if not movie or not movie.get("hasFile"):
notes.append("movie removed or has no file in Radarr")
status = "REVIEW"
elif torrent["size"] > current_size * 1.05:
if current_size > 0 and torrent["size"] > current_size * 1.05:
notes.append(
f"abandoned is larger than current "
f"({gib(torrent['size'])} > {gib(current_size)} GiB)"
)
status = "REVIEW"
results.append(
{
"name": torrent["name"],
@@ -195,7 +228,7 @@ def find_movie_abandoned(radarr, qbit_movies):
# ---------------------------------------------------------------------------
def find_tv_abandoned(sonarr, qbit_tvshows):
def find_tv_abandoned(sonarr, qbit_tvshows, all_qbit=None):
log.info("Analysing Sonarr import history ...")
episode_imports = defaultdict(list)
all_download_ids: set[str] = set()
@@ -222,6 +255,15 @@ def find_tv_abandoned(sonarr, qbit_tvshows):
abandoned_hashes = all_download_ids - active_hashes
# For keeper-viability checks: find the active hash for each
# episode an abandoned hash once covered.
hash_to_active: dict[str, list[str]] = defaultdict(list)
for eid, events in episode_imports.items():
active = max(events, key=lambda e: e["date"])["downloadId"]
for e in events:
if e["downloadId"] in abandoned_hashes:
hash_to_active[e["downloadId"]].append(active)
log.info("Fetching Sonarr current series state ...")
current_series = {s["id"] for s in sonarr.get_series()}
@@ -235,6 +277,17 @@ def find_tv_abandoned(sonarr, qbit_tvshows):
status = "SAFE"
notes = []
# Verify every keeper that replaced this hash is viable.
if all_qbit is not None:
for keeper_h in hash_to_active.get(ahash, []):
if not _is_keeper_viable(keeper_h, all_qbit, None):
notes.append(
"replacement torrent incomplete or missing"
)
status = "REVIEW"
break
sid = hash_to_series.get(ahash)
if sid and sid not in current_series:
notes.append("series removed from Sonarr")
@@ -369,17 +422,23 @@ def main():
print(f"--- {cat} ({len(unmanaged)} unmanaged / {len(qbit_torrents[cat])} total) ---\n")
print_section(unmanaged)
# Flatten all qBittorrent torrents into one lookup for keeper
# viability checks across categories.
all_qbit = {}
for torrents in qbit_torrents.values():
all_qbit.update(torrents)
# -- Abandoned --
print("========== ABANDONED UPGRADE LEFTOVERS ==========\n")
movie_abandoned = find_movie_abandoned(
radarr, qbit_torrents.get("movies", {})
radarr, qbit_torrents.get("movies", {}), all_qbit
)
print(f"--- movies ({len(movie_abandoned)} abandoned) ---\n")
print_section(movie_abandoned, show_status=True)
tv_abandoned = find_tv_abandoned(
sonarr, qbit_torrents.get("tvshows", {})
sonarr, qbit_torrents.get("tvshows", {}), all_qbit
)
print(f"--- tvshows ({len(tv_abandoned)} abandoned) ---\n")
print_section(tv_abandoned, show_status=True)