torrent-audit: only filter out complete torrents
All checks were successful
Build and Deploy / mreow (push) Successful in 2m7s
Build and Deploy / yarn (push) Successful in 45s
Build and Deploy / muffin (push) Successful in 1m11s

This commit is contained in:
2026-04-29 14:42:24 -04:00
parent 365efe3482
commit 47565c9e95
2 changed files with 52 additions and 5 deletions

View File

@@ -57,6 +57,19 @@ def get_qbit_torrents(qbit_client, category: str) -> dict[str, dict]:
return {t["hash"].upper(): t for t in torrents} 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: def gib(size_bytes: int) -> str:
return f"{size_bytes / 1073741824:.1f}" return f"{size_bytes / 1073741824:.1f}"
@@ -133,6 +146,12 @@ def find_movie_abandoned(radarr, qbit_movies):
torrent = qbit_movies.get(ahash) torrent = qbit_movies.get(ahash)
if torrent is None: if torrent is None:
continue 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) mid = hash_to_movie.get(ahash)
movie = radarr_movies.get(mid) if mid else None 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) torrent = qbit_tvshows.get(ahash)
if torrent is None: if torrent is None:
continue continue
if not is_complete(torrent):
continue
status = "SAFE" status = "SAFE"
notes = [] notes = []

View File

@@ -52,6 +52,7 @@ let
SINGLE_CROSS = "A" * 38 + "0C" # movieId=7 single import AND older import for movieId=8 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) SINGLE8_NEW = "A" * 38 + "0D" # movieId=8, newer import keeper (not in qBit)
QUEUED_MOV = "A" * 38 + "0E" # in Radarr queue, not in history QUEUED_MOV = "A" * 38 + "0E" # in Radarr queue, not in history
INPROGRESS_MOV = "A" * 38 + "0F" # movieId=10, older import, currently re-downloading
# TV # TV
UNMANAGED_TV = "B" * 38 + "01" UNMANAGED_TV = "B" * 38 + "01"
@@ -62,13 +63,17 @@ let
REPACK = "B" * 38 + "06" # episodeId=300, newer import active REPACK = "B" * 38 + "06" # episodeId=300, newer import active
REMOVED_TV = "B" * 38 + "07" # episodeId=400, older import (series removed) REMOVED_TV = "B" * 38 + "07" # episodeId=400, older import (series removed)
REMOVED_TV_NEW = "B" * 38 + "08" # episodeId=400, newer import (not in qBit) 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 { return {
"hash": h.lower(), "hash": h.lower(),
"name": name, "name": name,
"size": size, "size": size,
"state": state, "state": state,
"progress": progress,
"added_on": added_on, "added_on": added_on,
"content_path": f"/downloads/{name}", "content_path": f"/downloads/{name}",
} }
@@ -84,6 +89,9 @@ let
make_torrent(LARGER_OLD, "Larger.Movie.2024", 10_737_418_240, 1704067206), 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(SINGLE_CROSS, "SingleCross.Movie.2024", 4_000_000_000, 1704067207),
make_torrent(QUEUED_MOV, "Queued.Movie.2024", 2_000_000_000, 1704067208), 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": [ "tvshows": [
make_torrent(UNMANAGED_TV, "Unmanaged.Show.S01E01", 1_000_000_000, 1704067200), 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(NEW_TV, "New.Show.S01E01", 1_200_000_000, 1704067203),
make_torrent(SEASON_PACK, "Season.Pack.S02", 5_000_000_000, 1704067204), 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(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": 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": SINGLE_CROSS, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"},
{"movieId": 8, "downloadId": SINGLE8_NEW, "eventType": "downloadFolderImported", "date": "2024-06-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 = [ RADARR_MOVIES = [
@@ -126,6 +138,7 @@ let
{"id": 6, "hasFile": True, "movieFile": {"size": 5_368_709_120, "quality": {"quality": {"name": "Bluray-720p"}}}}, {"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": 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": 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 # Sonarr mock data
@@ -148,6 +161,9 @@ let
# Removed series scenario # 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, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"},
{"episodeId": 400, "seriesId": 99, "downloadId": REMOVED_TV_NEW,"eventType": "downloadFolderImported", "date": "2024-06-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 SONARR_HISTORY_ALL = SONARR_HISTORY_PAGE1 + SONARR_HISTORY_PAGE2
@@ -319,14 +335,14 @@ pkgs.testers.runNixOSTest {
with subtest("Detects unmanaged movie torrent"): with subtest("Detects unmanaged movie torrent"):
assert "Unmanaged.Movie.2024" in unmanaged_section, \ assert "Unmanaged.Movie.2024" in unmanaged_section, \
"Should detect unmanaged movie" "Should detect unmanaged movie"
assert "1 unmanaged / 9 total" in unmanaged_section, \ assert "1 unmanaged / 10 total" in unmanaged_section, \
"Should show 1 unmanaged movie out of 9" "Should show 1 unmanaged movie out of 10"
with subtest("Detects unmanaged TV torrent"): with subtest("Detects unmanaged TV torrent"):
assert "Unmanaged.Show.S01E01" in unmanaged_section, \ assert "Unmanaged.Show.S01E01" in unmanaged_section, \
"Should detect unmanaged TV show" "Should detect unmanaged TV show"
assert "1 unmanaged / 6 total" in unmanaged_section, \ assert "1 unmanaged / 7 total" in unmanaged_section, \
"Should show 1 unmanaged TV show out of 6" "Should show 1 unmanaged TV show out of 7"
with subtest("Empty category shows zero counts"): with subtest("Empty category shows zero counts"):
assert "0 unmanaged / 0 total" in unmanaged_section, \ assert "0 unmanaged / 0 total" in unmanaged_section, \
@@ -380,6 +396,16 @@ pkgs.testers.runNixOSTest {
assert "SingleCross.Movie.2024" not in abandoned_section, \ assert "SingleCross.Movie.2024" not in abandoned_section, \
"Hash that is sole import for movieId=7 must be in keeper set, not abandoned" "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"): with subtest("Removed movie triggers REVIEW status"):
assert "Removed.Movie.2024" in abandoned_section, \ assert "Removed.Movie.2024" in abandoned_section, \
"Should detect abandoned torrent for removed movie" "Should detect abandoned torrent for removed movie"