From 47565c9e951c618dcf91f4075e33a1f04b90b748 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 14:42:24 -0400 Subject: [PATCH] torrent-audit: only filter out complete torrents --- services/arr/torrent-audit.py | 21 ++++++++++++++++++++ tests/torrent-audit.nix | 36 ++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/services/arr/torrent-audit.py b/services/arr/torrent-audit.py index cc4baaf..8f15a2a 100644 --- a/services/arr/torrent-audit.py +++ b/services/arr/torrent-audit.py @@ -57,6 +57,19 @@ def get_qbit_torrents(qbit_client, category: str) -> dict[str, dict]: return {t["hash"].upper(): t for t in torrents} +def is_complete(torrent: dict) -> bool: + """True iff the torrent's payload is fully on disk. + + A torrent that was once imported can later end up at progress < 1 if the + files were deleted or qBittorrent was reset and the torrent was re-added. + Those entries must NOT be reported as abandoned-safe: their reported size + is the metadata size, not what is actually on disk, so the reclaim figure + would be a fiction and a 'safe to delete' verdict could kill a re-grab in + progress. + """ + return float(torrent.get("progress", 0)) >= 1.0 + + def gib(size_bytes: int) -> str: return f"{size_bytes / 1073741824:.1f}" @@ -133,6 +146,12 @@ def find_movie_abandoned(radarr, qbit_movies): torrent = qbit_movies.get(ahash) if torrent is None: continue + # Skip torrents whose payload is not fully on disk: their reported size + # is metadata, not actual on-disk bytes, so flagging them as + # abandoned-safe would lie about the reclaim and could disrupt a + # 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 @@ -211,6 +230,8 @@ def find_tv_abandoned(sonarr, qbit_tvshows): torrent = qbit_tvshows.get(ahash) if torrent is None: continue + if not is_complete(torrent): + continue status = "SAFE" notes = [] diff --git a/tests/torrent-audit.nix b/tests/torrent-audit.nix index 0c9e014..ffe4fbb 100644 --- a/tests/torrent-audit.nix +++ b/tests/torrent-audit.nix @@ -52,6 +52,7 @@ let SINGLE_CROSS = "A" * 38 + "0C" # movieId=7 single import AND older import for movieId=8 SINGLE8_NEW = "A" * 38 + "0D" # movieId=8, newer import → keeper (not in qBit) QUEUED_MOV = "A" * 38 + "0E" # in Radarr queue, not in history + INPROGRESS_MOV = "A" * 38 + "0F" # movieId=10, older import, currently re-downloading # TV UNMANAGED_TV = "B" * 38 + "01" @@ -62,13 +63,17 @@ let REPACK = "B" * 38 + "06" # episodeId=300, newer import → active REMOVED_TV = "B" * 38 + "07" # episodeId=400, older import (series removed) REMOVED_TV_NEW = "B" * 38 + "08" # episodeId=400, newer import (not in qBit) + INPROGRESS_TV = "B" * 38 + "09" # episodeId=500, older import, currently re-downloading + INPROGRESS_TV_NEW = "B" * 38 + "0A" # episodeId=500, newer import (not in qBit) + INPROGRESS_MOV_NEW = "A" * 38 + "10" # movieId=10, newer import (not in qBit) - def make_torrent(h, name, size, added_on, state="uploading"): + def make_torrent(h, name, size, added_on, state="uploading", progress=1.0): return { "hash": h.lower(), "name": name, "size": size, "state": state, + "progress": progress, "added_on": added_on, "content_path": f"/downloads/{name}", } @@ -84,6 +89,9 @@ let make_torrent(LARGER_OLD, "Larger.Movie.2024", 10_737_418_240, 1704067206), make_torrent(SINGLE_CROSS, "SingleCross.Movie.2024", 4_000_000_000, 1704067207), make_torrent(QUEUED_MOV, "Queued.Movie.2024", 2_000_000_000, 1704067208), + # In-progress re-download: hash matches an old import, but data is + # not yet on disk. Must NOT be flagged as abandoned (regression). + make_torrent(INPROGRESS_MOV, "InProgress.Movie.2024", 8_000_000_000, 1704067209, state="downloading", progress=0.05), ], "tvshows": [ make_torrent(UNMANAGED_TV, "Unmanaged.Show.S01E01", 1_000_000_000, 1704067200), @@ -92,6 +100,7 @@ let make_torrent(NEW_TV, "New.Show.S01E01", 1_200_000_000, 1704067203), make_torrent(SEASON_PACK, "Season.Pack.S02", 5_000_000_000, 1704067204), make_torrent(REMOVED_TV, "Removed.Show.S01E01", 900_000_000, 1704067205), + make_torrent(INPROGRESS_TV, "InProgress.Show.S01E01", 1_500_000_000, 1704067209, state="downloading", progress=0.05), ], } @@ -115,6 +124,9 @@ let {"movieId": 7, "downloadId": SINGLE_CROSS, "eventType": "downloadFolderImported", "date": "2024-03-01T00:00:00Z"}, {"movieId": 8, "downloadId": SINGLE_CROSS, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"movieId": 8, "downloadId": SINGLE8_NEW, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, + # In-progress re-download regression case for movies + {"movieId": 10, "downloadId": INPROGRESS_MOV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"movieId": 10, "downloadId": INPROGRESS_MOV_NEW,"eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, ] RADARR_MOVIES = [ @@ -126,6 +138,7 @@ let {"id": 6, "hasFile": True, "movieFile": {"size": 5_368_709_120, "quality": {"quality": {"name": "Bluray-720p"}}}}, {"id": 7, "hasFile": True, "movieFile": {"size": 4_000_000_000, "quality": {"quality": {"name": "Bluray-1080p"}}}}, {"id": 8, "hasFile": True, "movieFile": {"size": 5_000_000_000, "quality": {"quality": {"name": "Remux-1080p"}}}}, + {"id": 10, "hasFile": True, "movieFile": {"size": 8_000_000_000, "quality": {"quality": {"name": "Remux-2160p"}}}}, ] # ── Sonarr mock data ────────────────────────────────────────────────── @@ -148,6 +161,9 @@ let # Removed series scenario {"episodeId": 400, "seriesId": 99, "downloadId": REMOVED_TV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"episodeId": 400, "seriesId": 99, "downloadId": REMOVED_TV_NEW,"eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, + # In-progress re-download regression case for TV + {"episodeId": 500, "seriesId": 1, "downloadId": INPROGRESS_TV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"episodeId": 500, "seriesId": 1, "downloadId": INPROGRESS_TV_NEW,"eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, ] SONARR_HISTORY_ALL = SONARR_HISTORY_PAGE1 + SONARR_HISTORY_PAGE2 @@ -319,14 +335,14 @@ pkgs.testers.runNixOSTest { with subtest("Detects unmanaged movie torrent"): assert "Unmanaged.Movie.2024" in unmanaged_section, \ "Should detect unmanaged movie" - assert "1 unmanaged / 9 total" in unmanaged_section, \ - "Should show 1 unmanaged movie out of 9" + assert "1 unmanaged / 10 total" in unmanaged_section, \ + "Should show 1 unmanaged movie out of 10" with subtest("Detects unmanaged TV torrent"): assert "Unmanaged.Show.S01E01" in unmanaged_section, \ "Should detect unmanaged TV show" - assert "1 unmanaged / 6 total" in unmanaged_section, \ - "Should show 1 unmanaged TV show out of 6" + assert "1 unmanaged / 7 total" in unmanaged_section, \ + "Should show 1 unmanaged TV show out of 7" with subtest("Empty category shows zero counts"): assert "0 unmanaged / 0 total" in unmanaged_section, \ @@ -380,6 +396,16 @@ pkgs.testers.runNixOSTest { assert "SingleCross.Movie.2024" not in abandoned_section, \ "Hash that is sole import for movieId=7 must be in keeper set, not abandoned" + with subtest("In-progress re-download not abandoned (incomplete payload regression)"): + # A torrent whose hash matches an old downloadFolderImported entry but + # whose data is not currently on disk (progress < 1.0) must not be + # reported as abandoned: its size is metadata, not reclaimable bytes, + # and a SAFE verdict could disrupt a re-download in progress. + assert "InProgress.Movie.2024" not in abandoned_section, \ + "In-progress movie re-download must not appear as abandoned" + assert "InProgress.Show.S01E01" not in abandoned_section, \ + "In-progress TV re-download must not appear as abandoned" + with subtest("Removed movie triggers REVIEW status"): assert "Removed.Movie.2024" in abandoned_section, \ "Should detect abandoned torrent for removed movie"