#!/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() AUDIT_TAGS = {"audit:unmanaged", "audit:abandoned-safe", "audit:abandoned-review"} def tag_torrents(qbit_client, qbit_torrents, all_known, all_abandoned): log.info("Tagging torrents ...") abandoned_by_hash = {t["hash"].upper(): t for t in all_abandoned} all_hashes = [] for torrents in qbit_torrents.values(): all_hashes.extend(torrents.keys()) for h in all_hashes: current_tags = set() torrent_info = None for torrents in qbit_torrents.values(): if h in torrents: torrent_info = torrents[h] break if not torrent_info: continue existing_tags = {t.strip() for t in torrent_info.get("tags", "").split(",") if t.strip()} existing_audit_tags = existing_tags & AUDIT_TAGS if h in abandoned_by_hash: status = abandoned_by_hash[h]["status"] desired = "audit:abandoned-safe" if status == "SAFE" else "audit:abandoned-review" elif h not in all_known: desired = "audit:unmanaged" else: desired = None tags_to_remove = existing_audit_tags - ({desired} if desired else set()) tags_to_add = ({desired} if desired else set()) - existing_audit_tags low_hash = torrent_info["hash"] for tag in tags_to_remove: qbit_client.torrents_remove_tags(tags=tag, torrent_hashes=low_hash) for tag in tags_to_add: qbit_client.torrents_add_tags(tags=tag, torrent_hashes=low_hash) log.info("Tagging complete") 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") # -- Tagging -- if os.environ.get("TAG_TORRENTS", "").lower() in ("1", "true", "yes"): tag_torrents(qbit, qbit_torrents, all_known, all_abandoned) if __name__ == "__main__": main()