From f8d3f990caa870481cf6bfbbbc71deb9c171702b Mon Sep 17 00:00:00 2001 From: Aidan Abbott Date: Tue, 14 Jul 2026 19:22:29 +0100 Subject: [PATCH] fix: Prowlarr expired download link refresh (#1109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes Prowlarr downloads that fail after a queued torrent result’s tracker download link expires. Prowlarr torrent results can expose a `downloadUrl` that is only a short-lived proxy to the upstream tracker. Some trackers, including MAM, include expiring credentials in that URL. Shelfmark was persisting that URL as retry data and later treating it as durable. If the in-memory Prowlarr cache was gone, or if qBittorrent tried to add a stale URL, Shelfmark could fail with a misleading torrent-hash error instead of refreshing the release. ## What changed - Stop persisting Prowlarr `downloadUrl` values as durable retry data. - Persist only source context needed to refresh the release later. - On a Prowlarr cache miss, re-query Prowlarr using the queued task context. - Accept refreshed results only when the stable identity matches the original release: - `guid == task.task_id` - or `infoUrl == task.task_id` - Cache the fresh raw Prowlarr result and build the download request from its current `downloadUrl` / `magnetUrl`. - If qBittorrent add fails with `Could not determine torrent hash from URL`, remove the stale cached Prowlarr result, refresh once, and retry with the fresh URL. - Preserve existing magnet handling: torrent results continue to use `magnetUrl` first, so the refresh path only targets Prowlarr proxy `downloadUrl` values that can expire. - Improve the user-facing failure when refresh cannot find the same release: `The indexer download link expired and the release could not be refreshed. Search again for a fresh result.` ## Why this approach The important constraint is avoiding accidental downloads of a different edition or format after the original tracker link expires. Re-running a search by title can return many plausible results, so the refresh path deliberately requires an exact stable identity match before using any new URL. This treats Prowlarr `downloadUrl` as a short-lived hint, while still allowing retries to recover when Prowlarr can find the same release again. The one-shot retry after qBittorrent add failure handles the case where Shelfmark still has a cached Prowlarr result, but that cached result contains an expired proxy URL. The refresh hook is source-specific and defaults to no-op for other external download handlers, so Newznab and other sources keep their existing retry behavior. ## Bug Fixes #1012 Context: https://github.com/calibrain/shelfmark/issues/1012#issuecomment-4917148398 ## Validation - `uv run pytest tests/prowlarr -q` - `uv run pytest tests/download/test_orchestrator_user_output_mode.py -q` - `uv run pytest tests/newznab/test_handler.py tests/audiobookbay/test_handler.py -q` - `uv run ruff check shelfmark/core/models.py shelfmark/download/orchestrator.py shelfmark/download/clients/base_handler.py shelfmark/release_sources/prowlarr/handler.py tests/prowlarr/test_handler.py tests/prowlarr/test_integration_handler.py tests/prowlarr/test_failure_scenarios.py tests/prowlarr/test_source.py tests/download/test_orchestrator_user_output_mode.py` - after resolving conflicts with latest `main`: `uv run pytest tests/prowlarr -q` - after resolving conflicts with latest `main`: `uv run ruff check shelfmark/release_sources/prowlarr/handler.py` - after resolving conflicts with latest `main`: `uv run pytest tests/prowlarr/test_handler.py tests/prowlarr/test_source.py -q` Co-authored-by: Aidan Abbott --- shelfmark/core/models.py | 3 + shelfmark/download/clients/base_handler.py | 57 +++- shelfmark/download/orchestrator.py | 26 +- shelfmark/release_sources/prowlarr/handler.py | 124 +++++++-- .../test_orchestrator_user_output_mode.py | 16 +- tests/prowlarr/test_failure_scenarios.py | 2 +- tests/prowlarr/test_handler.py | 254 +++++++++++++++++- tests/prowlarr/test_integration_handler.py | 2 +- tests/prowlarr/test_source.py | 8 +- 9 files changed, 439 insertions(+), 53 deletions(-) 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):