diff --git a/configuration.nix b/configuration.nix index 26602a5..515fa79 100644 --- a/configuration.nix +++ b/configuration.nix @@ -40,6 +40,7 @@ ./services/arr/jellyseerr.nix ./services/arr/recyclarr.nix ./services/arr/arr-search.nix + ./services/arr/torrent-audit.nix ./services/arr/init.nix ./services/soulseek.nix diff --git a/services/arr/torrent-audit.nix b/services/arr/torrent-audit.nix new file mode 100644 index 0000000..9217074 --- /dev/null +++ b/services/arr/torrent-audit.nix @@ -0,0 +1,40 @@ +{ + pkgs, + config, + service_configs, + ... +}: +{ + systemd.services.torrent-audit = { + description = "Audit qBittorrent for unmanaged and abandoned upgrade torrents"; + after = [ + "network-online.target" + "sonarr.service" + "radarr.service" + "qbittorrent.service" + ]; + wants = [ "network-online.target" ]; + + serviceConfig = { + Type = "oneshot"; + ExecStart = "+${ + pkgs.python3.withPackages ( + ps: with ps; [ + pyarr + qbittorrent-api + ] + ) + }/bin/python ${./torrent-audit.py}"; + TimeoutSec = 300; + }; + + environment = { + QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.torrent.port}"; + RADARR_URL = "http://localhost:${builtins.toString service_configs.ports.private.radarr.port}"; + RADARR_CONFIG = "${service_configs.radarr.dataDir}/config.xml"; + SONARR_URL = "http://localhost:${builtins.toString service_configs.ports.private.sonarr.port}"; + SONARR_CONFIG = "${service_configs.sonarr.dataDir}/config.xml"; + CATEGORIES = "tvshows,movies,anime"; + }; + }; +} diff --git a/services/arr/torrent-audit.py b/services/arr/torrent-audit.py new file mode 100644 index 0000000..0d0830f --- /dev/null +++ b/services/arr/torrent-audit.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" +Audit qBittorrent torrents against Radarr/Sonarr. + +Reports two categories: + + UNMANAGED -- torrents in qBittorrent that no *arr service has ever touched. + These were added manually or by some other tool. + + ABANDONED -- torrents that *arr grabbed but later replaced with a better + version. The old torrent is still seeding while the library + points to the new one. + +Abandoned detection uses API cross-referencing (not filesystem hardlinks) and +verifies against the *arr's current file state: + + 1. HISTORY -- group imports by content unit (movieId / episodeId); the + most recent import is the keeper, older ones are candidates. + 2. CURRENT -- verify against the *arr's active file mapping. +""" + +import logging +import os +import sys +from collections import defaultdict +from xml.etree import ElementTree + +import qbittorrentapi +from pyarr import RadarrAPI, SonarrAPI + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + stream=sys.stderr, +) +log = logging.getLogger(__name__) + + +def get_api_key(config_path: str) -> str: + tree = ElementTree.parse(config_path) + return tree.find(".//ApiKey").text + + +def paginate(arr_client, endpoint: str, page_size: int = 1000): + method = getattr(arr_client, f"get_{endpoint}") + page = 1 + while True: + data = method(page=page, page_size=page_size) + yield from data["records"] + if page * page_size >= data["totalRecords"]: + break + page += 1 + + +def get_qbit_torrents(qbit_client, category: str) -> dict[str, dict]: + torrents = qbit_client.torrents_info(category=category) + return {t["hash"].upper(): t for t in torrents} + + +def gib(size_bytes: int) -> str: + return f"{size_bytes / 1073741824:.1f}" + + +# --------------------------------------------------------------------------- +# Collect all known hashes from *arr history + queue +# --------------------------------------------------------------------------- + + +def collect_all_known_hashes(arr_client, page_size: int = 1000) -> set[str]: + hashes = set() + for endpoint in ("queue", "history"): + for rec in paginate(arr_client, endpoint, page_size): + did = (rec.get("downloadId") or "").upper() + if did: + hashes.add(did) + return hashes + + +# --------------------------------------------------------------------------- +# Unmanaged: torrents with hashes not in any *arr history/queue +# --------------------------------------------------------------------------- + + +def find_unmanaged(qbit_torrents: dict, known_hashes: set) -> list[dict]: + results = [] + for uhash, torrent in qbit_torrents.items(): + if uhash not in known_hashes: + results.append(torrent) + return sorted(results, key=lambda t: t["added_on"]) + + +# --------------------------------------------------------------------------- +# Abandoned movies: group imports by movieId, older = abandoned +# --------------------------------------------------------------------------- + + +def find_movie_abandoned(radarr, qbit_movies): + log.info("Analysing Radarr import history ...") + imports_by_movie = defaultdict(list) + for rec in paginate(radarr, "history"): + if rec.get("eventType") != "downloadFolderImported": + continue + did = (rec.get("downloadId") or "").upper() + if not did: + continue + mid = rec.get("movieId") + if not mid: + continue + imports_by_movie[mid].append( + {"downloadId": did, "date": rec["date"]} + ) + + # 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] = {} + + for mid, events in imports_by_movie.items(): + ordered = sorted(events, key=lambda e: e["date"]) + keeper_hashes.add(ordered[-1]["downloadId"]) + for e in ordered[:-1]: + abandoned_hashes.add(e["downloadId"]) + hash_to_movie[e["downloadId"]] = mid + + # A hash that is a keeper for *any* movie must not be deleted. + abandoned_hashes -= keeper_hashes + + log.info("Fetching Radarr current movie state ...") + radarr_movies = {m["id"]: m for m in radarr.get_movie()} + + results = [] + for ahash in abandoned_hashes: + torrent = qbit_movies.get(ahash) + if torrent is None: + continue + + mid = hash_to_movie.get(ahash) + movie = radarr_movies.get(mid) if mid else None + mf = (movie or {}).get("movieFile") or {} + + current_quality = (mf.get("quality") or {}).get("quality", {}).get("name", "?") + current_size = mf.get("size", 0) + + status = "SAFE" + notes = [] + + 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: + notes.append( + f"abandoned is larger than current " + f"({gib(torrent['size'])} > {gib(current_size)} GiB)" + ) + status = "REVIEW" + + results.append( + { + "name": torrent["name"], + "size": torrent["size"], + "state": torrent["state"], + "hash": torrent["hash"], + "added_on": torrent["added_on"], + "status": status, + "notes": notes, + "current_quality": current_quality, + } + ) + + return sorted(results, key=lambda r: r["added_on"]) + + +# --------------------------------------------------------------------------- +# Abandoned TV: group imports by episodeId, a hash is abandoned only when +# it is NOT the latest import for ANY episode it covers. +# --------------------------------------------------------------------------- + + +def find_tv_abandoned(sonarr, qbit_tvshows): + log.info("Analysing Sonarr import history ...") + episode_imports = defaultdict(list) + all_download_ids: set[str] = set() + hash_to_series: dict[str, int] = {} + + for rec in paginate(sonarr, "history"): + if rec.get("eventType") != "downloadFolderImported": + continue + did = (rec.get("downloadId") or "").upper() + eid = rec.get("episodeId") + if not did or not eid: + continue + episode_imports[eid].append({"downloadId": did, "date": rec["date"]}) + all_download_ids.add(did) + sid = rec.get("seriesId") + if sid: + hash_to_series[did] = sid + + # A hash is "active" if it is the latest import for *any* episode. + active_hashes: set[str] = set() + for events in episode_imports.values(): + latest = max(events, key=lambda e: e["date"]) + active_hashes.add(latest["downloadId"]) + + abandoned_hashes = all_download_ids - active_hashes + + log.info("Fetching Sonarr current series state ...") + current_series = {s["id"] for s in sonarr.get_series()} + + results = [] + for ahash in abandoned_hashes: + torrent = qbit_tvshows.get(ahash) + if torrent is None: + continue + + status = "SAFE" + notes = [] + sid = hash_to_series.get(ahash) + if sid and sid not in current_series: + notes.append("series removed from Sonarr") + status = "REVIEW" + + results.append( + { + "name": torrent["name"], + "size": torrent["size"], + "state": torrent["state"], + "hash": torrent["hash"], + "added_on": torrent["added_on"], + "status": status, + "notes": notes, + } + ) + + return sorted(results, key=lambda r: r["added_on"]) + + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- + + +def print_section(torrents, show_status=False): + if not torrents: + print(" (none)\n") + return + + total_size = sum(t["size"] for t in torrents) + for t in torrents: + prefix = f"[{t['status']:6s}] " if show_status else " " + print(f" {prefix}{t['name']}") + extra = f"{gib(t['size'])} GiB | {t['state']}" + print(f" {' ' * len(prefix)}{extra}") + for note in t.get("notes", []): + print(f" {' ' * len(prefix)}** {note}") + print() + + if show_status: + safe = [t for t in torrents if t["status"] == "SAFE"] + review = [t for t in torrents if t["status"] == "REVIEW"] + print( + f" total={len(torrents)} ({gib(total_size)} GiB) | " + f"safe={len(safe)} | review={len(review)}" + ) + else: + print(f" total={len(torrents)} ({gib(total_size)} GiB)") + print() + + +def main(): + qbit_url = os.environ["QBITTORRENT_URL"] + radarr_url = os.environ["RADARR_URL"] + radarr_config = os.environ["RADARR_CONFIG"] + sonarr_url = os.environ["SONARR_URL"] + sonarr_config = os.environ["SONARR_CONFIG"] + categories = os.environ.get("CATEGORIES", "tvshows,movies,anime").split(",") + + radarr_key = get_api_key(radarr_config) + sonarr_key = get_api_key(sonarr_config) + + radarr = RadarrAPI(radarr_url, radarr_key) + sonarr = SonarrAPI(sonarr_url, sonarr_key) + qbit = qbittorrentapi.Client(host=qbit_url) + + log.info("Getting qBittorrent state ...") + qbit_torrents = {cat: get_qbit_torrents(qbit, cat) for cat in categories} + for cat, torrents in qbit_torrents.items(): + log.info(" %s: %d torrents", cat, len(torrents)) + + log.info("Collecting known hashes from Sonarr ...") + sonarr_hashes = collect_all_known_hashes(sonarr) + log.info(" %d unique hashes", len(sonarr_hashes)) + + log.info("Collecting known hashes from Radarr ...") + radarr_hashes = collect_all_known_hashes(radarr) + log.info(" %d unique hashes", len(radarr_hashes)) + + all_known = sonarr_hashes | radarr_hashes + + # -- Unmanaged -- + print("\n========== UNMANAGED TORRENTS ==========\n") + for cat in categories: + unmanaged = find_unmanaged(qbit_torrents[cat], all_known) + print(f"--- {cat} ({len(unmanaged)} unmanaged / {len(qbit_torrents[cat])} total) ---\n") + print_section(unmanaged) + + # -- Abandoned -- + print("========== ABANDONED UPGRADE LEFTOVERS ==========\n") + + movie_abandoned = find_movie_abandoned( + radarr, qbit_torrents.get("movies", {}) + ) + 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", {}) + ) + print(f"--- tvshows ({len(tv_abandoned)} abandoned) ---\n") + print_section(tv_abandoned, show_status=True) + + # -- Summary -- + all_abandoned = movie_abandoned + tv_abandoned + safe = [t for t in all_abandoned if t["status"] == "SAFE"] + + print("=" * 50) + print( + f"ABANDONED: {len(all_abandoned)} total ({len(safe)} safe to delete)" + ) + print(f"SAFE TO RECLAIM: {gib(sum(t['size'] for t in safe))} GiB") + + +if __name__ == "__main__": + main() diff --git a/tests/tests.nix b/tests/tests.nix index 8a7178d..27684a2 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -24,4 +24,7 @@ in # ntfy alerts test ntfyAlertsTest = handleTest ./ntfy-alerts.nix; + + # torrent audit test + torrentAuditTest = handleTest ./torrent-audit.nix; } diff --git a/tests/torrent-audit.nix b/tests/torrent-audit.nix new file mode 100644 index 0000000..0c9e014 --- /dev/null +++ b/tests/torrent-audit.nix @@ -0,0 +1,422 @@ +{ + config, + lib, + pkgs, + ... +}: +let + qbitPort = 18080; + radarrPort = 17878; + sonarrPort = 18989; + + radarrConfig = pkgs.writeText "radarr-config.xml" '' + test-radarr-key + ''; + + sonarrConfig = pkgs.writeText "sonarr-config.xml" '' + test-sonarr-key + ''; + + python = "${ + pkgs.python3.withPackages (ps: [ + ps.pyarr + ps.qbittorrent-api + ]) + }/bin/python3"; + auditScript = ../services/arr/torrent-audit.py; + + # Single mock API server script -- accepts SERVICE and PORT as CLI args. + # Routes responses based on SERVICE type (qbit / radarr / sonarr). + mockScript = pkgs.writeText "mock-api-server.py" '' + import json + import sys + from http.server import HTTPServer, BaseHTTPRequestHandler + from urllib.parse import urlparse, parse_qs + + SERVICE = sys.argv[1] + PORT = int(sys.argv[2]) + + # ── Hash constants (uppercase, 40 hex chars) ────────────────────────── + # Movies + UNMANAGED_MOV = "A" * 38 + "01" + MANAGED_MOV = "A" * 38 + "02" + OLD_MOV = "A" * 38 + "03" # movieId=2, older import → abandoned SAFE + NEW_MOV = "A" * 38 + "04" # movieId=2, newer import → keeper + KEEPER_CROSS = "A" * 38 + "05" # keeper for movieId=3, old for movieId=4 + KEEPER3_OLD = "A" * 38 + "0B" # movieId=3, older import (not in qBit) + KEEPER4_NEW = "A" * 38 + "06" # movieId=4, newer import → keeper + REMOVED_OLD = "A" * 38 + "07" # movieId=5, older import (movie removed) + REMOVED_NEW = "A" * 38 + "08" # movieId=5, newer import → keeper (not in qBit) + LARGER_OLD = "A" * 38 + "09" # movieId=6, older import (larger than current) + LARGER_NEW = "A" * 38 + "0A" # movieId=6, newer import → keeper + 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 + + # TV + UNMANAGED_TV = "B" * 38 + "01" + MANAGED_TV = "B" * 38 + "02" # episodeId=100, single import + OLD_TV = "B" * 38 + "03" # episodeId=200, older import → abandoned SAFE + NEW_TV = "B" * 38 + "04" # episodeId=200, newer import → active + SEASON_PACK = "B" * 38 + "05" # episodeIds 300,301,302 (still active for 301,302) + 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) + + def make_torrent(h, name, size, added_on, state="uploading"): + return { + "hash": h.lower(), + "name": name, + "size": size, + "state": state, + "added_on": added_on, + "content_path": f"/downloads/{name}", + } + + QBIT_DATA = { + "movies": [ + make_torrent(UNMANAGED_MOV, "Unmanaged.Movie.2024", 5_000_000_000, 1704067200), + make_torrent(MANAGED_MOV, "Managed.Movie.2024", 4_000_000_000, 1704067201), + make_torrent(OLD_MOV, "Old.Movie.Quality.2024", 3_000_000_000, 1704067202), + make_torrent(NEW_MOV, "New.Movie.Quality.2024", 6_000_000_000, 1704067203), + make_torrent(KEEPER_CROSS, "CrossRef.Movie.2024", 4_500_000_000, 1704067204), + make_torrent(REMOVED_OLD, "Removed.Movie.2024", 3_500_000_000, 1704067205), + 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), + ], + "tvshows": [ + make_torrent(UNMANAGED_TV, "Unmanaged.Show.S01E01", 1_000_000_000, 1704067200), + make_torrent(MANAGED_TV, "Managed.Show.S01E01", 800_000_000, 1704067201), + make_torrent(OLD_TV, "Old.Show.S01E01", 700_000_000, 1704067202), + 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), + ], + } + + # ── Radarr mock data ────────────────────────────────────────────────── + RADARR_HISTORY = [ + {"movieId": 1, "downloadId": MANAGED_MOV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"movieId": 2, "downloadId": OLD_MOV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"movieId": 2, "downloadId": NEW_MOV, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, + {"movieId": 3, "downloadId": KEEPER3_OLD, "eventType": "downloadFolderImported", "date": "2023-01-01T00:00:00Z"}, + {"movieId": 3, "downloadId": KEEPER_CROSS, "eventType": "downloadFolderImported", "date": "2024-03-01T00:00:00Z"}, + {"movieId": 4, "downloadId": KEEPER_CROSS, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"movieId": 4, "downloadId": KEEPER4_NEW, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, + {"movieId": 5, "downloadId": REMOVED_OLD, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"movieId": 5, "downloadId": REMOVED_NEW, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, + {"movieId": 6, "downloadId": LARGER_OLD, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"movieId": 6, "downloadId": LARGER_NEW, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, + # Non-import event (should be ignored by abandoned detection) + {"movieId": 2, "downloadId": NEW_MOV, "eventType": "grabbed", "date": "2024-05-31T00:00:00Z"}, + # Single-import keeper test (Fix 13): SINGLE_CROSS is only import for movieId=7 + # AND an older import for movieId=8 (SINGLE8_NEW is newer for movieId=8) + {"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"}, + ] + + RADARR_MOVIES = [ + {"id": 1, "hasFile": True, "movieFile": {"size": 4_000_000_000, "quality": {"quality": {"name": "Bluray-1080p"}}}}, + {"id": 2, "hasFile": True, "movieFile": {"size": 6_000_000_000, "quality": {"quality": {"name": "Remux-1080p"}}}}, + {"id": 3, "hasFile": True, "movieFile": {"size": 4_500_000_000, "quality": {"quality": {"name": "Bluray-1080p"}}}}, + {"id": 4, "hasFile": True, "movieFile": {"size": 5_000_000_000, "quality": {"quality": {"name": "Remux-1080p"}}}}, + # id=5 intentionally MISSING -- movie removed from Radarr + {"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"}}}}, + ] + + # ── Sonarr mock data ────────────────────────────────────────────────── + # Page 1 records (returned on page=1, with totalRecords=1001 to force pagination) + SONARR_HISTORY_PAGE1 = [ + {"episodeId": 100, "seriesId": 1, "downloadId": MANAGED_TV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"episodeId": 200, "seriesId": 1, "downloadId": OLD_TV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"episodeId": 200, "seriesId": 1, "downloadId": NEW_TV, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, + # Season pack covers 3 episodes + {"episodeId": 300, "seriesId": 2, "downloadId": SEASON_PACK, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"episodeId": 301, "seriesId": 2, "downloadId": SEASON_PACK, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + {"episodeId": 302, "seriesId": 2, "downloadId": SEASON_PACK, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, + # Non-import event (should be ignored) + {"episodeId": 200, "seriesId": 1, "downloadId": NEW_TV, "eventType": "grabbed", "date": "2024-05-31T00:00:00Z"}, + ] + # Page 2 records (critical data only available via pagination) + SONARR_HISTORY_PAGE2 = [ + # Episode 300 re-imported from a repack -- but 301,302 still reference SEASON_PACK + {"episodeId": 300, "seriesId": 2, "downloadId": REPACK, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, + # 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"}, + ] + SONARR_HISTORY_ALL = SONARR_HISTORY_PAGE1 + SONARR_HISTORY_PAGE2 + + # seriesId=99 intentionally MISSING -- series removed from Sonarr + SONARR_SERIES = [ + {"id": 1, "title": "Managed Show"}, + {"id": 2, "title": "Season Pack Show"}, + ] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path.startswith("/api/v2/auth/login"): + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Set-Cookie", "SID=test; path=/") + self.end_headers() + self.wfile.write(b"Ok.") + else: + self._handle_json() + + def do_GET(self): + self._handle_json() + + def _handle_json(self): + parsed = urlparse(self.path) + path = parsed.path + params = parse_qs(parsed.query) + + content_length = int(self.headers.get("Content-Length", 0)) + if content_length: + body = self.rfile.read(content_length).decode() + params.update(parse_qs(body)) + + response = self._route(path, params) + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(response).encode()) + + def _route(self, path, params): + if SERVICE == "qbit": + category = params.get("category", [""])[0] + return QBIT_DATA.get(category, []) + + elif SERVICE == "radarr": + if path == "/api/v3/history": + return {"records": RADARR_HISTORY, "totalRecords": len(RADARR_HISTORY)} + elif path == "/api/v3/queue": + return {"records": [{"downloadId": QUEUED_MOV}], "totalRecords": 1} + elif path == "/api/v3/movie": + return RADARR_MOVIES + return {} + + elif SERVICE == "sonarr": + if path == "/api/v3/history": + page = int(params.get("page", ["1"])[0]) + if page == 1: + return {"records": SONARR_HISTORY_PAGE1, "totalRecords": 1001} + else: + return {"records": SONARR_HISTORY_PAGE2, "totalRecords": 1001} + elif path == "/api/v3/queue": + return {"records": [], "totalRecords": 0} + elif path == "/api/v3/series": + return SONARR_SERIES + return {} + + return {} + + def log_message(self, fmt, *args): + pass + + HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() + ''; +in +pkgs.testers.runNixOSTest { + name = "torrent-audit"; + + nodes.machine = + { pkgs, ... }: + { + environment.systemPackages = [ pkgs.curl ]; + + systemd.services.mock-qbittorrent = { + description = "Mock qBittorrent API"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockScript} qbit ${toString qbitPort}"; + Type = "simple"; + }; + }; + + systemd.services.mock-radarr = { + description = "Mock Radarr API"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockScript} radarr ${toString radarrPort}"; + Type = "simple"; + }; + }; + + systemd.services.mock-sonarr = { + description = "Mock Sonarr API"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockScript} sonarr ${toString sonarrPort}"; + Type = "simple"; + }; + }; + }; + + testScript = '' + start_all() + machine.wait_for_unit("multi-user.target") + + # Wait for all mock services to be responsive + machine.wait_for_unit("mock-qbittorrent.service") + machine.wait_for_unit("mock-radarr.service") + machine.wait_for_unit("mock-sonarr.service") + machine.wait_until_succeeds( + "curl -sf http://localhost:${toString qbitPort}/api/v2/torrents/info?category=movies", + timeout=30, + ) + machine.wait_until_succeeds( + "curl -sf http://localhost:${toString radarrPort}/api/v3/movie", + timeout=30, + ) + machine.wait_until_succeeds( + "curl -sf http://localhost:${toString sonarrPort}/api/v3/queue", + timeout=30, + ) + + # Run the audit script and capture stdout + output = machine.succeed( + "QBITTORRENT_URL=http://localhost:${toString qbitPort} " + "RADARR_URL=http://localhost:${toString radarrPort} " + "RADARR_CONFIG=${radarrConfig} " + "SONARR_URL=http://localhost:${toString sonarrPort} " + "SONARR_CONFIG=${sonarrConfig} " + "CATEGORIES=movies,tvshows,anime " + "${python} ${auditScript}" + ) + + print("=== SCRIPT OUTPUT ===") + print(output) + print("=== END OUTPUT ===") + + # Fix 10: Assert section heading exists before splitting + assert "ABANDONED UPGRADE LEFTOVERS" in output, \ + "Output must contain ABANDONED UPGRADE LEFTOVERS heading" + + # Split output into sections for targeted assertions + unmanaged_section = output.split("ABANDONED UPGRADE LEFTOVERS")[0] + abandoned_section = output.split("ABANDONED UPGRADE LEFTOVERS")[1] + + # Helper: find a torrent name line and check nearby lines (within 3) for a note + def assert_note_near(section, torrent_name, note_text): + lines = section.splitlines() + found_idx = None + for i, line in enumerate(lines): + if torrent_name in line: + found_idx = i + break + assert found_idx is not None, f"{torrent_name} not found in section" + nearby = "\n".join(lines[max(0, found_idx):found_idx + 4]) + assert note_text in nearby, \ + f"Expected '{note_text}' near '{torrent_name}', got:\n{nearby}" + + 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" + + 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" + + with subtest("Empty category shows zero counts"): + assert "0 unmanaged / 0 total" in unmanaged_section, \ + "anime category should show 0 unmanaged / 0 total" + + with subtest("Managed torrents are NOT listed as unmanaged"): + assert "Managed.Movie.2024" not in unmanaged_section, \ + "Managed movie should not appear in unmanaged section" + assert "Managed.Show.S01E01" not in unmanaged_section, \ + "Managed TV show should not appear in unmanaged section" + + with subtest("Queue-known hash is NOT listed as unmanaged"): + assert "Queued.Movie.2024" not in unmanaged_section, \ + "Torrent in Radarr queue should not appear as unmanaged" + + with subtest("Detects abandoned movie upgrade as SAFE"): + assert "Old.Movie.Quality.2024" in abandoned_section, \ + "Should detect abandoned movie" + for line in abandoned_section.splitlines(): + if "Old.Movie.Quality.2024" in line: + assert "SAFE" in line, f"Old movie should be SAFE, got: {line}" + break + + with subtest("Detects abandoned TV episode as SAFE"): + assert "Old.Show.S01E01" in abandoned_section, \ + "Should detect abandoned TV episode" + for line in abandoned_section.splitlines(): + if "Old.Show.S01E01" in line: + assert "SAFE" in line, f"Old TV should be SAFE, got: {line}" + break + + with subtest("Keeper-also-abandoned hash is NOT listed as abandoned"): + assert "CrossRef.Movie.2024" not in abandoned_section, \ + "Hash that is keeper for another movie must not appear as abandoned" + + with subtest("Season pack NOT abandoned when still active for other episodes"): + assert "Season.Pack.S02" not in abandoned_section, \ + "Season pack still active for episodes 301/302 must not be abandoned" + + with subtest("Negative assertions for keepers"): + assert "New.Movie.Quality.2024" not in abandoned_section, \ + "Keeper for movieId=2 must not appear as abandoned" + assert "New.Show.S01E01" not in abandoned_section, \ + "Keeper for episodeId=200 must not appear as abandoned" + assert "Managed.Movie.2024" not in abandoned_section, \ + "Single-import movie must not appear as abandoned" + assert "Managed.Show.S01E01" not in abandoned_section, \ + "Single-import TV show must not appear as abandoned" + + with subtest("Single-import keeper not abandoned (Bug 1 regression)"): + 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("Removed movie triggers REVIEW status"): + assert "Removed.Movie.2024" in abandoned_section, \ + "Should detect abandoned torrent for removed movie" + assert_note_near(abandoned_section, "Removed.Movie.2024", "movie removed") + for line in abandoned_section.splitlines(): + if "Removed.Movie.2024" in line: + assert "REVIEW" in line, f"Removed movie should be REVIEW, got: {line}" + break + + with subtest("Abandoned larger than current triggers REVIEW"): + assert "Larger.Movie.2024" in abandoned_section, \ + "Should detect larger abandoned torrent" + assert_note_near(abandoned_section, "Larger.Movie.2024", "abandoned is larger") + for line in abandoned_section.splitlines(): + if "Larger.Movie.2024" in line: + assert "REVIEW" in line, f"Larger abandoned should be REVIEW, got: {line}" + break + + with subtest("Removed series triggers REVIEW status for TV"): + assert "Removed.Show.S01E01" in abandoned_section, \ + "Should detect abandoned torrent for removed series" + assert_note_near(abandoned_section, "Removed.Show.S01E01", "series removed") + for line in abandoned_section.splitlines(): + if "Removed.Show.S01E01" in line: + assert "REVIEW" in line, f"Removed series should be REVIEW, got: {line}" + break + + with subtest("Correct abandoned counts per category"): + assert "movies (3 abandoned)" in abandoned_section, \ + "Should show 3 abandoned movies" + assert "tvshows (2 abandoned)" in abandoned_section, \ + "Should show 2 abandoned TV shows" + + with subtest("Correct summary totals"): + assert "ABANDONED: 5 total (2 safe to delete)" in output, \ + "Summary should show 5 total abandoned, 2 safe to delete" + assert "SAFE TO RECLAIM: 3.4 GiB" in output, \ + "Should report 3.4 GiB reclaimable (2.8 GiB movie + 0.7 GiB TV)" + ''; +}