From 265da07d7f55c49e44388db7fabff4a409189ead Mon Sep 17 00:00:00 2001 From: Atirna Date: Fri, 11 Sep 2026 10:16:47 +0530 Subject: [PATCH] feat(download): add Blackhole torrent handoff (#1312) ## Why Blackhole users need Shelfmark to hand a torrent file to their existing downloader instead of importing the downloaded book itself. ## Change - add Blackhole as a torrent client with a configurable watched directory - prefer a fetched `.torrent` file for Blackhole while preserving magnet preference for other clients - complete the queue task after the handoff without invoking book post-processing ## Verification - `uv run pytest -q tests/prowlarr/test_blackhole_client.py tests/prowlarr/test_handler.py tests/newznab/test_handler.py tests/download/test_orchestrator_lifecycle.py` - `uv run basedpyright shelfmark/download/clients/blackhole.py shelfmark/download/clients/__init__.py shelfmark/download/clients/base_handler.py shelfmark/download/clients/settings.py shelfmark/download/orchestrator.py shelfmark/release_sources/__init__.py shelfmark/release_sources/prowlarr/utils.py shelfmark/release_sources/prowlarr/handler.py shelfmark/release_sources/newznab/handler.py tests/prowlarr/test_blackhole_client.py tests/prowlarr/test_handler.py tests/newznab/test_handler.py tests/download/test_orchestrator_lifecycle.py` Fixes #1229 --- shelfmark/download/clients/__init__.py | 12 ++ shelfmark/download/clients/base_handler.py | 31 +++-- shelfmark/download/clients/blackhole.py | 113 ++++++++++++++++++ shelfmark/download/clients/settings.py | 8 ++ shelfmark/download/orchestrator.py | 16 +++ shelfmark/release_sources/__init__.py | 13 +- shelfmark/release_sources/newznab/handler.py | 34 ++++-- shelfmark/release_sources/prowlarr/handler.py | 15 ++- shelfmark/release_sources/prowlarr/utils.py | 8 +- tests/download/test_orchestrator_lifecycle.py | 30 +++++ tests/newznab/test_handler.py | 11 ++ tests/prowlarr/test_blackhole_client.py | 73 +++++++++++ tests/prowlarr/test_handler.py | 16 ++- 13 files changed, 347 insertions(+), 33 deletions(-) create mode 100644 shelfmark/download/clients/blackhole.py create mode 100644 tests/prowlarr/test_blackhole_client.py diff --git a/shelfmark/download/clients/__init__.py b/shelfmark/download/clients/__init__.py index a8942b7a..7dfd58f0 100644 --- a/shelfmark/download/clients/__init__.py +++ b/shelfmark/download/clients/__init__.py @@ -190,6 +190,8 @@ class DownloadClient(ABC): # Class attributes that subclasses must define protocol: str name: str + handoff_only = False + prefers_torrent_file = False def _log_error(self, method: str, e: Exception, level: str = "error") -> str: """Log a client error with consistent formatting. @@ -375,6 +377,7 @@ _CLIENTS: dict[str, list[type[DownloadClient]]] = {} ClientType = TypeVar("ClientType", bound=DownloadClient) _BUILTIN_CLIENT_MODULES = ( "shelfmark.download.clients.alldebrid", + "shelfmark.download.clients.blackhole", "shelfmark.download.clients.deluge", "shelfmark.download.clients.nzbget", "shelfmark.download.clients.qbittorrent", @@ -467,6 +470,15 @@ def list_configured_clients() -> list[str]: return result +def client_prefers_torrent_file(protocol: str) -> bool: + """Whether the active client needs a fetched .torrent file instead of a magnet.""" + _ensure_builtin_clients_registered() + return any( + client_cls.prefers_torrent_file and client_cls.is_configured() + for client_cls in _CLIENTS.get(protocol, []) + ) + + def get_all_clients() -> dict[str, list[type[DownloadClient]]]: """Get all registered client classes. diff --git a/shelfmark/download/clients/base_handler.py b/shelfmark/download/clients/base_handler.py index 835490df..e12dc41c 100644 --- a/shelfmark/download/clients/base_handler.py +++ b/shelfmark/download/clients/base_handler.py @@ -24,7 +24,7 @@ from shelfmark.download.clients import ( ) from shelfmark.download.fs import run_blocking_io from shelfmark.download.permissions_debug import log_path_permission_context -from shelfmark.release_sources import DownloadHandler +from shelfmark.release_sources import DownloadHandler, HandoffResult if TYPE_CHECKING: from collections.abc import Callable @@ -762,8 +762,8 @@ class ExternalClientHandler(DownloadHandler, ABC): cancel_flag: Event, progress_callback: Callable[[float], None], status_callback: Callable[[str, str | None], None], - ) -> str | None: - """Execute download via configured torrent/usenet client. Returns file path or None.""" + ) -> str | HandoffResult | None: + """Execute download via configured torrent/usenet client.""" try: if cancel_flag.is_set(): status_callback("cancelled", "Cancelled") @@ -897,7 +897,7 @@ class ExternalClientHandler(DownloadHandler, ABC): cancel_flag: Event, progress_callback: Callable[[float], None], status_callback: Callable[[str, str | None], None], - ) -> str | None: + ) -> str | HandoffResult | None: """Poll the download client for progress and handle completion.""" poll_interval = self._poll_interval() # Track consecutive "not found" errors - torrents may take time to appear in client @@ -905,7 +905,7 @@ class ExternalClientHandler(DownloadHandler, ABC): max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period try: - result: str | None = None + result: str | HandoffResult | None = None logger.debug("Starting poll for %s (content_type=%s)", download_id, task.content_type) while not cancel_flag.is_set(): status = client.get_status(download_id) @@ -1020,12 +1020,18 @@ class ExternalClientHandler(DownloadHandler, ABC): ) return None - result = self._handle_completed_file( - source_path=source_path_obj, - protocol=protocol, - task=task, - status_callback=status_callback, - ) + if getattr(client, "handoff_only", False) is True: + result = HandoffResult( + path=str(source_path_obj), + message=f"Torrent file saved to {source_path_obj}", + ) + else: + result = self._handle_completed_file( + source_path=source_path_obj, + protocol=protocol, + task=task, + status_callback=status_callback, + ) except Exception as e: logger.exception("Error during download polling") @@ -1036,7 +1042,8 @@ class ExternalClientHandler(DownloadHandler, ABC): # Clean up on success if result: self._on_download_complete(task) - self._cleanup_refs[task.task_id] = (client, download_id, protocol) + if not isinstance(result, HandoffResult): + self._cleanup_refs[task.task_id] = (client, download_id, protocol) return result diff --git a/shelfmark/download/clients/blackhole.py b/shelfmark/download/clients/blackhole.py new file mode 100644 index 00000000..041db424 --- /dev/null +++ b/shelfmark/download/clients/blackhole.py @@ -0,0 +1,113 @@ +"""Blackhole download client that saves torrent files for an external watcher.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from shelfmark.core.config import config +from shelfmark.core.naming import sanitize_filename +from shelfmark.download.clients import ( + DownloadClient, + DownloadState, + DownloadStatus, + register_client, +) +from shelfmark.download.clients._coercion import config_text +from shelfmark.download.clients.torrent_utils import extract_torrent_info + + +@register_client("torrent") +class BlackholeClient(DownloadClient): + """Write fetched torrent files to a directory watched by another downloader.""" + + protocol = "torrent" + name = "blackhole" + handoff_only = True + prefers_torrent_file = True + + def __init__(self) -> None: + directory = config_text(config.get("BLACKHOLE_DIRECTORY", "")) + if not directory: + msg = "BLACKHOLE_DIRECTORY is required" + raise ValueError(msg) + self._directory = Path(directory) + + @staticmethod + def is_configured() -> bool: + return config_text(config.get("PROWLARR_TORRENT_CLIENT", "")) == "blackhole" and bool( + config_text(config.get("BLACKHOLE_DIRECTORY", "")) + ) + + def test_connection(self) -> tuple[bool, str]: + try: + self._directory.mkdir(parents=True, exist_ok=True) + except OSError as error: + return False, f"Could not create Blackhole directory: {error}" + return True, f"Blackhole directory is ready: {self._directory}" + + def add_download( + self, + url: str, + name: str, + category: str | None = None, + expected_hash: str | None = None, + **kwargs: object, + ) -> str: + torrent_info = extract_torrent_info(url, expected_hash=expected_hash) + if not torrent_info.torrent_data: + msg = "Blackhole requires a .torrent file; this release only provides a magnet link" + raise ValueError(msg) + + self._directory.mkdir(parents=True, exist_ok=True) + filename = f"{sanitize_filename(name) or 'torrent'}.torrent" + destination = self._next_destination(filename) + file_descriptor, temporary_path = tempfile.mkstemp( + dir=self._directory, + prefix=".blackhole-", + suffix=".tmp", + ) + try: + with os.fdopen(file_descriptor, "wb") as temporary_file: + temporary_file.write(torrent_info.torrent_data) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + Path(temporary_path).replace(destination) + except Exception: + Path(temporary_path).unlink(missing_ok=True) + raise + + return str(destination) + + def get_status(self, download_id: str) -> DownloadStatus: + file_path = Path(download_id) + if file_path.is_file(): + return DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Torrent file saved", + complete=True, + file_path=str(file_path), + ) + return DownloadStatus.error("Blackhole torrent file was not created") + + def remove(self, download_id: str, *, delete_files: bool = False) -> bool: + return False + + def get_download_path(self, download_id: str) -> str | None: + return download_id if Path(download_id).is_file() else None + + def _next_destination(self, filename: str) -> Path: + candidate = self._directory / filename + if not candidate.exists(): + return candidate + + stem = Path(filename).stem + suffix = Path(filename).suffix + index = 1 + while True: + candidate = self._directory / f"{stem}_{index}{suffix}" + if not candidate.exists(): + return candidate + index += 1 diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 6e2a04df..9cd17099 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -590,6 +590,7 @@ def prowlarr_clients_settings() -> list[SettingsField]: options=[ {"value": "", "label": "None"}, {"value": "alldebrid", "label": "AllDebrid"}, + {"value": "blackhole", "label": "Blackhole"}, {"value": "qbittorrent", "label": "qBittorrent"}, {"value": "realdebrid", "label": "Real-Debrid"}, {"value": "transmission", "label": "Transmission"}, @@ -598,6 +599,13 @@ def prowlarr_clients_settings() -> list[SettingsField]: ], default="", ), + TextField( + key="BLACKHOLE_DIRECTORY", + label="Blackhole Directory", + description="Directory where Shelfmark saves .torrent files for another downloader", + placeholder="/blackhole", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "blackhole"}, + ), # --- AllDebrid Settings --- PasswordField( key="ALLDEBRID_API_KEY", diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index cfc03b94..cc8a6f74 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -29,6 +29,7 @@ from shelfmark.download.fs import run_blocking_io from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path from shelfmark.download.postprocess.router import post_process_download from shelfmark.release_sources import ( + HandoffResult, get_handler, get_source, get_source_display_name, @@ -750,6 +751,21 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None: if not temp_path: return None + if isinstance(temp_path, HandoffResult): + handoff_path = Path(temp_path.path) + if not run_blocking_io(handoff_path.exists): + logger.error("Handler returned non-existent handoff path: %s", handoff_path) + _capture_task_error( + task, + message=f"Download file missing: {handoff_path}", + exc_type="MissingDownloadPath", + ) + return None + status_callback("complete", temp_path.message) + handler.post_process_cleanup(task, success=True) + _clear_task_error_state(task) + return str(handoff_path) + temp_file = Path(temp_path) if not run_blocking_io(temp_file.exists): logger.error("Handler returned non-existent path: %s", temp_path) diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py index 1b0d69fa..8901ee7e 100644 --- a/shelfmark/release_sources/__init__.py +++ b/shelfmark/release_sources/__init__.py @@ -368,12 +368,21 @@ class ReleaseSource(ABC): return None +@dataclass(frozen=True) +class HandoffResult: + """An external handoff that completed without a Shelfmark book payload.""" + + path: str + message: str + + class DownloadHandler(ABC): """Interface for executing downloads. A handler may either: - download directly into ``TMP_DIR`` (managed by Shelfmark), or - return a path owned by an external client (e.g. torrent/usenet). + - finish an external handoff without producing a book payload. The orchestrator is responsible for post-processing (archive extraction, output mode handling) and transferring files into their final destination. @@ -386,8 +395,8 @@ class DownloadHandler(ABC): cancel_flag: Event, progress_callback: Callable[[float], None], status_callback: Callable[[str, str | None], None], - ) -> str | None: - """Execute download and return a path to the downloaded payload.""" + ) -> str | HandoffResult | None: + """Execute download and return a payload path or completed external handoff.""" def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None: """Run optional cleanup after orchestrator post-processing. diff --git a/shelfmark/release_sources/newznab/handler.py b/shelfmark/release_sources/newznab/handler.py index cac65c93..adae2609 100644 --- a/shelfmark/release_sources/newznab/handler.py +++ b/shelfmark/release_sources/newznab/handler.py @@ -9,7 +9,12 @@ if TYPE_CHECKING: from shelfmark.core.logger import setup_logger from shelfmark.core.request_helpers import normalize_optional_text -from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients +from shelfmark.download.clients import ( + DownloadClient, + client_prefers_torrent_file, + get_client, + list_configured_clients, +) from shelfmark.download.clients.base_handler import ( COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS, ) @@ -54,13 +59,15 @@ def _get_protocol(result: dict) -> str: return "usenet" -def _get_download_url(result: dict) -> str: +def _get_download_url(result: dict, *, prefer_torrent_file: bool = False) -> str: """Pick the best URL to hand to a download client.""" protocol = _get_protocol(result) magnet_url = str(result.get("magnetUrl") or "").strip() download_url = str(result.get("downloadUrl") or "").strip() if protocol == "torrent": + if prefer_torrent_file: + return download_url or magnet_url return magnet_url or download_url return download_url or magnet_url @@ -93,9 +100,15 @@ class NewznabHandler(ExternalClientHandler): if result is None: return {} + protocol = _get_protocol(result) return { - "retry_download_url": normalize_optional_text(_get_download_url(result)), - "retry_download_protocol": normalize_optional_text(_get_protocol(result)), + "retry_download_url": normalize_optional_text( + _get_download_url( + result, + prefer_torrent_file=client_prefers_torrent_file(protocol), + ) + ), + "retry_download_protocol": normalize_optional_text(protocol), } @classmethod @@ -137,16 +150,19 @@ class NewznabHandler(ExternalClientHandler): status_callback("error", "Release not found in cache (may have expired)") return None - download_url = _get_download_url(result) - if not download_url: - status_callback("error", "No download URL available") - return None - protocol = _get_protocol(result) if protocol not in ("torrent", "usenet"): status_callback("error", "Could not determine download protocol") return None + download_url = _get_download_url( + result, + prefer_torrent_file=client_prefers_torrent_file(protocol), + ) + if not download_url: + status_callback("error", "No download URL available") + return None + release_name = result.get("title") or task.title or "Unknown" expected_hash = str(result.get("infoHash") or "").strip() or None diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 78ca74c5..57c4fa82 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -12,6 +12,7 @@ from shelfmark.core.search_plan import build_release_search_plan from shelfmark.core.utils import normalize_http_url from shelfmark.download.clients import ( DownloadClient, + client_prefers_torrent_file, get_client, list_configured_clients, ) @@ -247,18 +248,20 @@ class ProwlarrHandler(ExternalClientHandler): status_callback("error", EXPIRED_LINK_REFRESH_ERROR) return None - # Extract download URL - download_url = get_preferred_download_url(prowlarr_result) - if not download_url: - status_callback("error", "No download URL available") - return None - # Determine protocol protocol = get_protocol(prowlarr_result) if protocol == "unknown": status_callback("error", "Could not determine download protocol") return None + download_url = get_preferred_download_url( + prowlarr_result, + prefer_torrent_file=client_prefers_torrent_file(protocol), + ) + if not download_url: + status_callback("error", "No download URL available") + return None + release_name = prowlarr_result.get("title") or task.title or "Unknown" expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None diff --git a/shelfmark/release_sources/prowlarr/utils.py b/shelfmark/release_sources/prowlarr/utils.py index c5b1b516..f6ca58d0 100644 --- a/shelfmark/release_sources/prowlarr/utils.py +++ b/shelfmark/release_sources/prowlarr/utils.py @@ -149,17 +149,19 @@ def get_protocol(result: dict) -> str: return "unknown" -def get_preferred_download_url(result: dict) -> str: +def get_preferred_download_url(result: dict, *, prefer_torrent_file: bool = False) -> str: """Pick the best URL to hand to a download client. - For torrent results, prefer magnetUrl when available (downloadUrl may be a - Prowlarr proxy URL that needs auth/headers). + For torrent results, prefer magnetUrl when available unless the configured + client needs the fetched .torrent bytes. """ protocol = str(result.get("protocol", "")).lower() magnet_url = str(result.get("magnetUrl") or "").strip() download_url = sanitize_download_url(str(result.get("downloadUrl") or "").strip()) if protocol == "torrent": + if prefer_torrent_file: + return download_url or magnet_url return magnet_url or download_url if protocol == "usenet": return download_url or magnet_url diff --git a/tests/download/test_orchestrator_lifecycle.py b/tests/download/test_orchestrator_lifecycle.py index dd62116f..26b145ad 100644 --- a/tests/download/test_orchestrator_lifecycle.py +++ b/tests/download/test_orchestrator_lifecycle.py @@ -1,9 +1,13 @@ from __future__ import annotations +from threading import Event from unittest.mock import ANY, MagicMock import pytest +from shelfmark.core.models import DownloadTask +from shelfmark.release_sources import HandoffResult + class _StopLoop(BaseException): """Sentinel used to stop the infinite coordinator loop during tests.""" @@ -225,3 +229,29 @@ def test_start_replaces_dead_coordinator_thread(monkeypatch): ) new_thread.start.assert_called_once_with() assert orchestrator._coordinator_thread is new_thread + + +def test_download_task_completes_blackhole_handoff_without_post_processing(monkeypatch, tmp_path): + import shelfmark.download.orchestrator as orchestrator + + handoff_file = tmp_path / "release.torrent" + handoff_file.write_bytes(b"torrent-bytes") + task = DownloadTask(task_id="blackhole-task", source="prowlarr", title="Book") + queue = MagicMock() + queue.get_task.return_value = task + handler = MagicMock() + handler.download.return_value = HandoffResult( + path=str(handoff_file), + message=f"Torrent file saved to {handoff_file}", + ) + + monkeypatch.setattr(orchestrator, "book_queue", queue) + monkeypatch.setattr(orchestrator, "get_handler", lambda _source: handler) + monkeypatch.setattr(orchestrator, "_source_unavailable_message", lambda _source: None) + monkeypatch.setattr(orchestrator, "post_process_download", MagicMock()) + + result = orchestrator._download_task(task.task_id, Event()) + + assert result == str(handoff_file) + orchestrator.post_process_download.assert_not_called() + handler.post_process_cleanup.assert_called_once_with(task, success=True) diff --git a/tests/newznab/test_handler.py b/tests/newznab/test_handler.py index e8cbe12b..b9bcd492 100644 --- a/tests/newznab/test_handler.py +++ b/tests/newznab/test_handler.py @@ -84,6 +84,17 @@ class TestGetDownloadUrl: } assert _get_download_url(result) == "magnet:?xt=urn:btih:abc" + def test_blackhole_prefers_torrent_file(self): + result = { + "protocol": "torrent", + "downloadUrl": "https://example.com/file.torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc", + } + assert ( + _get_download_url(result, prefer_torrent_file=True) + == "https://example.com/file.torrent" + ) + def test_falls_back_to_download_url_when_no_magnet(self): result = { "protocol": "torrent", diff --git a/tests/prowlarr/test_blackhole_client.py b/tests/prowlarr/test_blackhole_client.py new file mode 100644 index 00000000..58d35cfe --- /dev/null +++ b/tests/prowlarr/test_blackhole_client.py @@ -0,0 +1,73 @@ +"""Unit tests for the Blackhole torrent-file handoff client.""" + +from __future__ import annotations + +import pytest + +from shelfmark.download.clients.torrent_utils import TorrentInfo + + +def make_config_getter(values: dict[str, str]): + def getter(key: str, default: str = "") -> str: + return values.get(key, default) + + return getter + + +def test_blackhole_saves_torrent_file_and_reports_completed_handoff(monkeypatch, tmp_path): + from shelfmark.download.clients.blackhole import BlackholeClient + + monkeypatch.setattr( + "shelfmark.download.clients.blackhole.config.get", + make_config_getter( + { + "PROWLARR_TORRENT_CLIENT": "blackhole", + "BLACKHOLE_DIRECTORY": str(tmp_path), + } + ), + ) + monkeypatch.setattr( + "shelfmark.download.clients.blackhole.extract_torrent_info", + lambda *_args, **_kwargs: TorrentInfo( + info_hash="abc123", + torrent_data=b"torrent-bytes", + is_magnet=False, + ), + ) + + client = BlackholeClient() + download_id = client.add_download( + "https://indexer.example/release.torrent", + "A Test Book", + ) + + saved_file = tmp_path / "A Test Book.torrent" + assert download_id == str(saved_file) + assert saved_file.read_bytes() == b"torrent-bytes" + assert client.get_status(download_id).complete is True + assert client.get_download_path(download_id) == str(saved_file) + + +def test_blackhole_rejects_magnet_only_release(monkeypatch, tmp_path): + from shelfmark.download.clients.blackhole import BlackholeClient + + monkeypatch.setattr( + "shelfmark.download.clients.blackhole.config.get", + make_config_getter({"BLACKHOLE_DIRECTORY": str(tmp_path)}), + ) + monkeypatch.setattr( + "shelfmark.download.clients.blackhole.extract_torrent_info", + lambda *_args, **_kwargs: TorrentInfo( + info_hash="abc123", + torrent_data=None, + is_magnet=True, + magnet_url="magnet:?xt=urn:btih:abc123", + ), + ) + + client = BlackholeClient() + + with pytest.raises(ValueError, match="requires a .torrent file"): + client.add_download("magnet:?xt=urn:btih:abc123", "A Test Book") + + assert list(tmp_path.iterdir()) == [] diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index 48336e24..f70a1a10 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -18,7 +18,11 @@ from shelfmark.download.clients import ( from shelfmark.release_sources import Release, ReleaseProtocol from shelfmark.release_sources.prowlarr.cache import cache_release, remove_release from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler -from shelfmark.release_sources.prowlarr.utils import build_source_id, get_protocol +from shelfmark.release_sources.prowlarr.utils import ( + build_source_id, + get_preferred_download_url, + get_protocol, +) class ProgressRecorder: @@ -77,6 +81,16 @@ class TestGetProtocol: assert get_protocol({"protocol": "USENET"}) == "usenet" +def test_blackhole_prefers_torrent_file_over_magnet(): + result = { + "protocol": "torrent", + "downloadUrl": "https://prowlarr.example/download/123", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + } + + assert get_preferred_download_url(result, prefer_torrent_file=True) == result["downloadUrl"] + + class TestProwlarrHandlerDownloadErrors: """Tests for error handling in ProwlarrHandler.download()."""