diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py index d646115..d26515e 100644 --- a/shelfmark/core/models.py +++ b/shelfmark/core/models.py @@ -108,6 +108,9 @@ class DownloadTask: retry_expected_hash: str | None = None # Optional torrent hash used to match client downloads retry_ratio_limit: float | None = None # Optional post-download seeding ratio retry_seeding_time_limit_minutes: int | None = None # Optional post-download seeding time limit + retry_source_context: dict[str, Any] = field( + default_factory=dict + ) # Source-private context for retry/re-resolution can_retry_without_staged_source: bool = ( True # Whether the source can restart without a preserved staged file ) diff --git a/shelfmark/download/clients/base_handler.py b/shelfmark/download/clients/base_handler.py index 7a1d69a..4fb5893 100644 --- a/shelfmark/download/clients/base_handler.py +++ b/shelfmark/download/clients/base_handler.py @@ -220,6 +220,17 @@ class ExternalClientHandler(DownloadHandler, ABC): configured = config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback) return _coerce_completed_path_timeout_seconds(configured, fallback) + def _refresh_download_request_after_add_failure( + self, + *, + task: DownloadTask, + request: DownloadRequest, + error: Exception, + status_callback: Callable[[str, str | None], None], + ) -> DownloadRequest | None: + """Give source handlers one chance to refresh stale resolved download data.""" + return None + def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> str | None: """Get audiobook category if configured and applicable, else None for default.""" if not is_audiobook(task.content_type): @@ -791,20 +802,38 @@ class ExternalClientHandler(DownloadHandler, ABC): status_callback("downloading", "Resuming existing download") else: # No existing download - add new - status_callback("resolving", f"Sending to {client.name}") - try: - download_id = client.add_download( - url=request.url, - name=request.release_name, - category=category, - expected_hash=request.expected_hash, - seeding_time_limit=request.seeding_time_limit, - ratio_limit=request.ratio_limit, - ) - except Exception as e: - logger.exception("Failed to add to %s", client.name) - status_callback("error", f"Failed to add to {client.name}: {e}") - return None + refresh_attempted = False + while True: + status_callback("resolving", f"Sending to {client.name}") + try: + download_id = client.add_download( + url=request.url, + name=request.release_name, + category=category, + expected_hash=request.expected_hash, + seeding_time_limit=request.seeding_time_limit, + ratio_limit=request.ratio_limit, + ) + except Exception as e: + if not refresh_attempted: + refresh_attempted = True + refreshed_request = self._refresh_download_request_after_add_failure( + task=task, + request=request, + error=e, + status_callback=status_callback, + ) + if ( + refreshed_request is not None + and refreshed_request.protocol == request.protocol + ): + request = refreshed_request + continue + + logger.exception("Failed to add to %s", client.name) + status_callback("error", f"Failed to add to {client.name}: {e}") + return None + break logger.info( "Added to %s: %s for '%s'", client.name, download_id, request.release_name diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index dc7617d..2cf0cd3 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -170,16 +170,19 @@ def _build_retry_resolution_fields( retry_download_url = normalize_optional_text(release_data.get("download_url")) protocol = normalize_optional_text(release_data.get("protocol")) source = normalize_optional_text(release_data.get("source")) + retry_source_context: dict[str, Any] = {} if source is not None: handler = get_handler(source) source_retry_fields = handler.build_retry_resolution_fields(release_data) - retry_download_url = ( - normalize_optional_text(source_retry_fields.get("retry_download_url")) - or retry_download_url - ) - protocol = ( - normalize_optional_text(source_retry_fields.get("retry_download_protocol")) or protocol - ) + if "retry_download_url" in source_retry_fields: + retry_download_url = normalize_optional_text( + source_retry_fields.get("retry_download_url") + ) + if "retry_download_protocol" in source_retry_fields: + protocol = normalize_optional_text(source_retry_fields.get("retry_download_protocol")) + raw_retry_source_context = source_retry_fields.get("retry_source_context") + if isinstance(raw_retry_source_context, dict): + retry_source_context = dict(raw_retry_source_context) ratio_limit = _optional_number(release_data.get("ratio_limit")) if ratio_limit is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False): @@ -202,6 +205,7 @@ def _build_retry_resolution_fields( ), "retry_ratio_limit": ratio_limit, "retry_seeding_time_limit_minutes": seeding_time_limit_minutes, + "retry_source_context": retry_source_context, "can_retry_without_staged_source": True, } @@ -400,6 +404,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]: search_mode = normalized_search_mode or None raw_output_args = getattr(task, "output_args", None) + raw_retry_source_context = getattr(task, "retry_source_context", None) return { "task_id": getattr(task, "task_id", None), @@ -428,6 +433,9 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]: "retry_expected_hash": getattr(task, "retry_expected_hash", None), "retry_ratio_limit": getattr(task, "retry_ratio_limit", None), "retry_seeding_time_limit_minutes": getattr(task, "retry_seeding_time_limit_minutes", None), + "retry_source_context": ( + dict(raw_retry_source_context) if isinstance(raw_retry_source_context, dict) else {} + ), "can_retry_without_staged_source": bool( getattr(task, "can_retry_without_staged_source", True) ), @@ -453,6 +461,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None: search_mode = None output_args = payload.get("output_args") + retry_source_context = payload.get("retry_source_context") return DownloadTask( task_id=task_id, @@ -483,6 +492,9 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None: retry_seeding_time_limit_minutes=_optional_positive_int( payload.get("retry_seeding_time_limit_minutes") ), + retry_source_context=( + dict(retry_source_context) if isinstance(retry_source_context, dict) else {} + ), can_retry_without_staged_source=bool(payload.get("can_retry_without_staged_source", True)), ) diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index b6fb466..4ba7940 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -1,12 +1,14 @@ """Prowlarr download handler - resolves releases and delegates lifecycle to shared clients.""" from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.request_helpers import normalize_optional_text +from shelfmark.core.search_plan import build_release_search_plan from shelfmark.core.utils import normalize_http_url from shelfmark.download.clients import ( DownloadClient, @@ -26,9 +28,11 @@ from shelfmark.download.clients.base_handler import ( DownloadRequest, ExternalClientHandler, ) +from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources import register_handler from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient -from shelfmark.release_sources.prowlarr.cache import get_release, remove_release +from shelfmark.release_sources.prowlarr.cache import cache_release, get_release, remove_release +from shelfmark.release_sources.prowlarr.source import ProwlarrSource from shelfmark.release_sources.prowlarr.utils import ( coerce_int_like, get_preferred_download_url, @@ -63,6 +67,11 @@ __all__ = [ POLL_INTERVAL = _DEFAULT_POLL_INTERVAL COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS +EXPIRED_LINK_REFRESH_ERROR = ( + "The indexer download link expired and the release could not be refreshed. " + "Search again for a fresh result." +) +HASH_DETECTION_ERROR = "Could not determine torrent hash from URL" def _coerce_positive_minutes(raw_minutes: object) -> int | None: @@ -136,18 +145,30 @@ class ProwlarrHandler(ExternalClientHandler): def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]: source_id = normalize_optional_text(release_data.get("source_id")) - if source_id is None: - return {} + extra = release_data.get("extra") + if not isinstance(extra, dict): + extra = {} - prowlarr_result = get_release(source_id) - if prowlarr_result is None: - return {} + retry_source_context: dict[str, Any] = {} + indexer_id = release_data.get("indexer_id") or extra.get("indexer_id") + if indexer_id is not None: + retry_source_context["indexer_id"] = indexer_id + + indexer = normalize_optional_text(release_data.get("indexer") or extra.get("indexer")) + if indexer is not None and indexer.lower() != "unknown": + retry_source_context["indexer"] = indexer + + info_url = normalize_optional_text(release_data.get("info_url") or extra.get("info_url")) + if info_url is not None: + retry_source_context["info_url"] = info_url + + if source_id is not None: + retry_source_context["source_id"] = source_id return { - "retry_download_url": normalize_optional_text( - get_preferred_download_url(prowlarr_result) - ), - "retry_download_protocol": normalize_optional_text(get_protocol(prowlarr_result)), + "retry_download_url": None, + "retry_download_protocol": None, + "retry_source_context": retry_source_context, } @classmethod @@ -194,13 +215,12 @@ class ProwlarrHandler(ExternalClientHandler): # Look up the cached release prowlarr_result = get_release(task.task_id) if not prowlarr_result: - restored_request = self._restore_download_request_from_task(task) - if restored_request is None: - logger.warning("Release cache miss: %s", task.task_id) - status_callback("error", "Release not found in cache (may have expired)") + logger.info("Prowlarr release cache miss, refreshing: %s", task.task_id) + prowlarr_result = self._refresh_release(task) + if prowlarr_result is None: + logger.warning("Prowlarr release refresh failed: %s", task.task_id) + status_callback("error", EXPIRED_LINK_REFRESH_ERROR) return None - logger.info("Restored Prowlarr download request for retry: %s", task.task_id) - return restored_request # Extract download URL download_url = get_preferred_download_url(prowlarr_result) @@ -257,6 +277,78 @@ class ProwlarrHandler(ExternalClientHandler): ratio_limit=ratio_limit, ) + def _refresh_release(self, task: DownloadTask) -> dict[str, Any] | None: + """Re-query Prowlarr and cache the exact original release if it still exists.""" + title = normalize_optional_text(task.title) + if title is None: + return None + + context = getattr(task, "retry_source_context", None) + if not isinstance(context, dict): + context = {} + + indexer = normalize_optional_text(context.get("indexer")) + book = BookMetadata( + provider="shelfmark", + provider_id=task.task_id, + title=title, + authors=[task.author] if task.author else [], + search_title=title, + search_author=task.author, + ) + plan = build_release_search_plan( + book, + indexers=[indexer] if indexer is not None else None, + ) + + source = ProwlarrSource() + results = source.search(book, plan, content_type=task.content_type or "ebook") + for release in results: + raw_release = get_release(release.source_id) + if raw_release is None: + continue + if not self._raw_release_matches_task(raw_release, task.task_id): + continue + + cache_release(task.task_id, raw_release) + logger.info("Refreshed Prowlarr release: %s", task.task_id) + return raw_release + + return None + + @staticmethod + def _raw_release_matches_task(raw_release: dict[str, Any], task_id: str) -> bool: + identities = ( + normalize_optional_text(raw_release.get("guid")), + normalize_optional_text(raw_release.get("infoUrl")), + ) + return any(identity == task_id for identity in identities) + + def _refresh_download_request_after_add_failure( + self, + *, + task: DownloadTask, + request: DownloadRequest, + error: Exception, + status_callback: Callable[[str, str | None], None], + ) -> DownloadRequest | None: + """Refresh once when a cached Prowlarr torrent proxy URL has expired.""" + if request.protocol != "torrent": + return None + if HASH_DETECTION_ERROR not in str(error): + return None + + parsed = urlparse(request.url) + if parsed.scheme.lower() not in {"http", "https"}: + return None + + logger.info("Refreshing stale Prowlarr torrent URL for %s", task.task_id) + remove_release(task.task_id) + refreshed_request = self._resolve_download(task, status_callback) + if refreshed_request is None: + raise RuntimeError(EXPIRED_LINK_REFRESH_ERROR) from error + return refreshed_request + def _on_download_complete(self, task: DownloadTask) -> None: """Remove completed release from the Prowlarr cache.""" remove_release(task.task_id) diff --git a/tests/download/test_orchestrator_user_output_mode.py b/tests/download/test_orchestrator_user_output_mode.py index e62623e..c759b3a 100644 --- a/tests/download/test_orchestrator_user_output_mode.py +++ b/tests/download/test_orchestrator_user_output_mode.py @@ -205,7 +205,7 @@ def test_download_task_rejects_unavailable_source_before_handler(monkeypatch): orchestrator.get_handler.assert_not_called() -def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch): +def test_queue_release_persists_prowlarr_retry_context_without_download_url(monkeypatch): import shelfmark.download.orchestrator as orchestrator captured: dict[str, object] = {} @@ -227,6 +227,7 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch): "protocol": "torrent", "indexer": "MyIndexer", "extra": { + "indexer_id": 12, "configured_ratio_limit": 1.25, "configured_seed_time_minutes": 90, "info_hash": "ABC123", @@ -239,14 +240,23 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch): assert success is True assert error is None task = captured["task"] - assert task.retry_download_url == "magnet:?xt=urn:btih:abc123" - assert task.retry_download_protocol == "torrent" + assert task.retry_download_url is None + assert task.retry_download_protocol is None + assert task.retry_source_context == { + "source_id": "prowlarr-release-1", + "indexer": "MyIndexer", + "indexer_id": 12, + } assert task.retry_release_name == "Queued Prowlarr Release" assert task.retry_expected_hash == "ABC123" assert task.retry_ratio_limit == 1.25 assert task.retry_seeding_time_limit_minutes == 90 assert task.can_retry_without_staged_source is True + payload = orchestrator.serialize_task_for_retry(task) + assert payload["retry_download_url"] is None + assert payload["retry_source_context"] == task.retry_source_context + def test_queue_release_prefers_configured_seed_time_minutes_for_retry(monkeypatch): import shelfmark.download.orchestrator as orchestrator diff --git a/tests/prowlarr/test_failure_scenarios.py b/tests/prowlarr/test_failure_scenarios.py index 06f2804..cdd1b05 100644 --- a/tests/prowlarr/test_failure_scenarios.py +++ b/tests/prowlarr/test_failure_scenarios.py @@ -520,7 +520,7 @@ class TestCacheFailures: assert result is None assert recorder.had_error - assert "not found in cache" in recorder.last_message.lower() + assert "could not be refreshed" in recorder.last_message def test_release_missing_download_url(self, handler, recorder, cancel_flag, sample_task): """Handler should error when release has no download URL.""" diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index ff228d7..5617480 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -15,6 +15,8 @@ from shelfmark.download.clients import ( DownloadState, DownloadStatus, ) +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 get_protocol @@ -103,13 +105,16 @@ class TestProwlarrHandlerDownloadErrors: assert result is None assert recorder.last_status == "error" assert recorder.last_message is not None - assert "cache" in recorder.last_message.lower() + assert "could not be refreshed" in recorder.last_message - def test_resolve_download_uses_task_retry_fields_when_cache_is_missing(self): - """Generic retry fields should let restarts recover without the in-memory cache.""" + def test_download_fails_clearly_when_cache_miss_cannot_refresh(self): + """Prowlarr retry URLs are not durable; cache misses must refresh by identity.""" with patch( "shelfmark.release_sources.prowlarr.handler.get_release", return_value=None, + ), patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + return_value=[], ): handler = ProwlarrHandler() task = DownloadTask( @@ -122,14 +127,245 @@ class TestProwlarrHandlerDownloadErrors: retry_seeding_time_limit_minutes=60, retry_ratio_limit=1.5, ) + recorder = ProgressRecorder() - request = handler._resolve_download(task, lambda *_: None) + request = handler._resolve_download(task, recorder.status_callback) - assert request is not None - assert request.url == "magnet:?xt=urn:btih:abc123" - assert request.protocol == "torrent" - assert request.seeding_time_limit == 60 - assert request.ratio_limit == 1.5 + assert request is None + assert recorder.last_status == "error" + assert recorder.last_message is not None + assert "could not be refreshed" in recorder.last_message + + def test_cache_miss_re_resolves_and_uses_fresh_download_url(self): + """Cache miss should re-query Prowlarr and use a fresh exact-match URL.""" + task_id = "fresh-guid-1" + fresh_url = "https://prowlarr.example.com/download/fresh-token" + + def mock_search(*_args, **_kwargs): + cache_release( + task_id, + { + "guid": task_id, + "protocol": "torrent", + "downloadUrl": fresh_url, + "title": "Fresh Release", + }, + ) + return [ + Release( + source="prowlarr", + source_id=task_id, + title="Fresh Release", + info_url="https://tracker.example.com/release/1", + protocol=ReleaseProtocol.TORRENT, + ) + ] + + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + + remove_release(task_id) + try: + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + side_effect=mock_search, + ) as mock_search_method, + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch.object(ProwlarrHandler, "_poll_and_complete", return_value=None), + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id=task_id, + source="prowlarr", + title="Fresh Book", + retry_source_context={"indexer": "MyIndexer"}, + ) + recorder = ProgressRecorder() + + handler.download( + task=task, + cancel_flag=Event(), + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + mock_search_method.assert_called_once() + assert mock_client.add_download.call_args.kwargs["url"] == fresh_url + finally: + remove_release(task_id) + + def test_stale_cached_url_add_failure_re_resolves_once_and_succeeds(self): + """Expired cached proxy URL should be refreshed once after qBittorrent hash failure.""" + task_id = "stale-guid-1" + stale_url = "https://prowlarr.example.com/download/stale-token" + fresh_url = "https://prowlarr.example.com/download/fresh-token" + + def mock_search(*_args, **_kwargs): + cache_release( + task_id, + { + "guid": task_id, + "protocol": "torrent", + "downloadUrl": fresh_url, + "title": "Fresh Release", + }, + ) + return [ + Release( + source="prowlarr", + source_id=task_id, + title="Fresh Release", + protocol=ReleaseProtocol.TORRENT, + ) + ] + + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + mock_client.add_download.side_effect = [ + RuntimeError("Could not determine torrent hash from URL"), + "download_id", + ] + + cache_release( + task_id, + { + "guid": task_id, + "protocol": "torrent", + "downloadUrl": stale_url, + "title": "Stale Release", + }, + ) + try: + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + side_effect=mock_search, + ) as mock_search_method, + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch.object(ProwlarrHandler, "_poll_and_complete", return_value=None), + ): + handler = ProwlarrHandler() + task = DownloadTask(task_id=task_id, source="prowlarr", title="Stale Book") + recorder = ProgressRecorder() + + handler.download( + task=task, + cancel_flag=Event(), + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert mock_client.add_download.call_count == 2 + assert mock_client.add_download.call_args_list[0].kwargs["url"] == stale_url + assert mock_client.add_download.call_args_list[1].kwargs["url"] == fresh_url + mock_search_method.assert_called_once() + finally: + remove_release(task_id) + + def test_refresh_without_exact_match_fails_clearly(self): + """Refresh must not pick a different Prowlarr result when identity differs.""" + task_id = "missing-guid-1" + other_id = "other-guid-1" + + def mock_search(*_args, **_kwargs): + cache_release( + other_id, + { + "guid": other_id, + "protocol": "torrent", + "downloadUrl": "https://prowlarr.example.com/download/other", + "title": "Other Release", + }, + ) + return [ + Release( + source="prowlarr", + source_id=other_id, + title="Other Release", + protocol=ReleaseProtocol.TORRENT, + ) + ] + + remove_release(task_id) + remove_release(other_id) + try: + with patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + side_effect=mock_search, + ): + handler = ProwlarrHandler() + task = DownloadTask(task_id=task_id, source="prowlarr", title="Missing Book") + recorder = ProgressRecorder() + + request = handler._resolve_download(task, recorder.status_callback) + + assert request is None + assert recorder.last_status == "error" + assert recorder.last_message is not None + assert "could not be refreshed" in recorder.last_message + finally: + remove_release(task_id) + remove_release(other_id) + + def test_magnet_result_does_not_trigger_prowlarr_url_refresh(self): + """Magnet failures should not be treated as expired Prowlarr proxy URLs.""" + task_id = "magnet-guid-1" + magnet = "magnet:?xt=urn:btih:abc123&dn=test" + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + mock_client.add_download.side_effect = RuntimeError( + "Could not determine torrent hash from URL" + ) + + cache_release( + task_id, + { + "guid": task_id, + "protocol": "torrent", + "downloadUrl": "https://prowlarr.example.com/download/stale-token", + "magnetUrl": magnet, + "title": "Magnet Release", + }, + ) + try: + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + return_value=[], + ) as mock_search_method, + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + ): + handler = ProwlarrHandler() + task = DownloadTask(task_id=task_id, source="prowlarr", title="Magnet Book") + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=Event(), + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert mock_client.add_download.call_count == 1 + assert mock_client.add_download.call_args.kwargs["url"] == magnet + mock_search_method.assert_not_called() + finally: + remove_release(task_id) def test_download_fails_without_download_url(self): """Test that download fails when release has no download URL.""" diff --git a/tests/prowlarr/test_integration_handler.py b/tests/prowlarr/test_integration_handler.py index 2de75b7..f1f0819 100644 --- a/tests/prowlarr/test_integration_handler.py +++ b/tests/prowlarr/test_integration_handler.py @@ -126,7 +126,7 @@ class TestHandlerCacheOperations: assert result is None assert recorder.last_status == "error" - assert "cache" in recorder.last_message.lower() + assert "could not be refreshed" in recorder.last_message def test_download_fails_without_download_url(self): """Test that download fails when release has no download URL.""" diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index e360b25..7b34b45 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -519,8 +519,12 @@ class TestProwlarrLocalizedQueries: assert success is True assert error is None - assert captured_tasks[0].retry_download_url == secret_download_url - assert captured_tasks[0].retry_download_protocol == "usenet" + assert captured_tasks[0].retry_download_url is None + assert captured_tasks[0].retry_download_protocol is None + assert captured_tasks[0].retry_source_context == { + "source_id": "secret-prowlarr-release", + "info_url": "secret-prowlarr-release", + } assert "retry_download_url" not in orchestrator._task_to_dict(captured_tasks[0]) def test_search_uses_localized_titles_when_available(self, monkeypatch):