torrent-audit: make more robust
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user