diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py index 5f51997d..e37fdbc3 100644 --- a/shelfmark/release_sources/prowlarr/api.py +++ b/shelfmark/release_sources/prowlarr/api.py @@ -158,24 +158,35 @@ class ProwlarrClient: logger.info("Prowlarr connection successful: version %s", version) return True, f"Connected to Prowlarr {version}" - def get_indexers(self) -> list[dict[str, Any]]: - """Get all configured indexers.""" + def get_indexers(self, *, raise_on_error: bool = False) -> list[dict[str, Any]]: + """Get all configured indexers. + + Args: + raise_on_error: When True, propagate API failures instead of + returning an empty list. Callers that must distinguish + "no indexers" from "the request failed" should set this. + + """ try: return _normalize_json_object_list( self._request("GET", "/api/v1/indexer"), context="Prowlarr indexer list", ) except _PROWLARR_CLIENT_ERRORS: + if raise_on_error: + raise logger.exception("Failed to get indexers") return [] - def get_enabled_indexers_detailed(self) -> list[dict[str, Any]]: + def get_enabled_indexers_detailed( + self, *, raise_on_error: bool = False + ) -> list[dict[str, Any]]: """Get enabled indexers, including implementation metadata. Note: Prowlarr indexer "name" is user-configurable; prefer "implementation"/"implementationName" for stable identification. """ - indexers = self.get_indexers() + indexers = self.get_indexers(raise_on_error=raise_on_error) return [idx for idx in indexers if idx.get("enable", False)] def get_enriched_indexer_ids(self, *, restrict_to: list[int] | None = None) -> list[int]: @@ -214,10 +225,17 @@ class ProwlarrClient: Prowlarr exposes seedTime in minutes, which is also the unit expected by torrent clients. + + Raises: + requests.exceptions.RequestException (and other client errors) when + the indexer list cannot be fetched. An empty dict strictly means + "no share limits are configured", never "the request failed" - + callers rely on this to avoid silently dropping seed limits. + """ settings_by_indexer: dict[int, IndexerSeedSettings] = {} - for idx in self.get_enabled_indexers_detailed(): + for idx in self.get_enabled_indexers_detailed(raise_on_error=True): idx_id_int = coerce_int_like(idx.get("id")) if idx_id_int is None: continue diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index b75dcab1..b6fb4669 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -2,9 +2,12 @@ from typing import TYPE_CHECKING, Any +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.utils import normalize_http_url from shelfmark.download.clients import ( DownloadClient, get_client, @@ -24,6 +27,7 @@ from shelfmark.download.clients.base_handler import ( ExternalClientHandler, ) 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.utils import ( coerce_int_like, @@ -37,6 +41,16 @@ if TYPE_CHECKING: from shelfmark.core.models import DownloadTask logger = setup_logger(__name__) + +# Errors that ProwlarrClient can raise when fetching indexer settings. +_SEED_SETTINGS_FALLBACK_ERRORS = ( + requests.exceptions.RequestException, + OSError, + RuntimeError, + TypeError, + ValueError, +) + __all__ = [ "ProwlarrHandler", "POLL_INTERVAL", @@ -62,6 +76,47 @@ def _coerce_positive_minutes(raw_minutes: object) -> int | None: class ProwlarrHandler(ExternalClientHandler): """Handler for Prowlarr downloads via configured torrent or usenet client.""" + @staticmethod + def _build_prowlarr_client() -> ProwlarrClient | None: + """Build a ProwlarrClient from config, or None if not configured.""" + raw_url = config.get("PROWLARR_URL", "") + raw_api_key = config.get("PROWLARR_API_KEY", "") + url = normalize_optional_text(raw_url) if isinstance(raw_url, str) else None + api_key = normalize_optional_text(raw_api_key) if isinstance(raw_api_key, str) else None + if not url or not api_key: + return None + normalized_url = normalize_http_url(url) + if not normalized_url: + return None + return ProwlarrClient(normalized_url, api_key) + + def _fetch_seed_settings_fallback(self, raw_indexer_id: object) -> IndexerSeedSettings | None: + """Fetch share limits for one indexer directly from Prowlarr. + + Used when the cached release is missing its search-time seed-limit + enrichment so that transient failures during search cannot cause a + torrent to be added without its configured share limits. + """ + indexer_id = coerce_int_like(raw_indexer_id) + if indexer_id is None: + return None + + client = self._build_prowlarr_client() + if client is None: + return None + + try: + settings = client.get_indexer_seed_settings(restrict_to=[indexer_id]) + except _SEED_SETTINGS_FALLBACK_ERRORS: + logger.warning( + "Grab-time seed settings fallback failed for indexerId=%s", + indexer_id, + exc_info=True, + ) + return None + + return settings.get(indexer_id) + def _get_client(self, protocol: str) -> DownloadClient | None: """Compatibility shim so module-level patching still works in tests.""" return get_client(protocol) @@ -171,6 +226,28 @@ class ProwlarrHandler(ExternalClientHandler): seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time) ratio_limit = float(raw_configured_ratio) if raw_configured_ratio is not None else None + # Fallback: search-time enrichment can be missing when the indexer + # settings fetch transiently failed during the search (#795). + # Re-resolve the limits from Prowlarr at grab time so torrents are + # never sent to the client without their configured share limits. + if seeding_time_limit is None and ratio_limit is None and protocol == "torrent": + fallback = self._fetch_seed_settings_fallback(prowlarr_result.get("indexerId")) + if fallback: + seeding_time_limit = _coerce_positive_minutes( + fallback.get("seeding_time_limit_minutes") + ) + raw_ratio = fallback.get("ratio_limit") + ratio_limit = float(raw_ratio) if raw_ratio is not None else None + + if seeding_time_limit is None and ratio_limit is None and protocol == "torrent": + logger.warning( + "Prowlarr seed preferences are enabled but no share limits " + "could be resolved for release '%s' (indexerId=%s); the " + "torrent will use the client's global limits", + release_name, + prowlarr_result.get("indexerId"), + ) + return DownloadRequest( url=download_url, protocol=protocol, diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index 79a66f46..9106cbad 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -2,8 +2,11 @@ import re import time +from threading import Lock from typing import TYPE_CHECKING, ClassVar, NoReturn +import requests + if TYPE_CHECKING: from shelfmark.core.search_plan import ReleaseSearchPlan from shelfmark.metadata_providers import BookMetadata @@ -41,6 +44,11 @@ _SIZE_UNIT_BASE = 1024 _TWO_FORMATS = 2 _PROWLARR_SOURCE_ERRORS = (AttributeError, OSError, RuntimeError, TypeError, ValueError) +# Errors that can surface from ProwlarrClient.get_indexer_seed_settings(). The +# client raises requests exceptions (subclasses of OSError via IOError lineage +# is not guaranteed), so include RequestException explicitly. +_PROWLARR_SEED_SETTINGS_ERRORS = (*_PROWLARR_SOURCE_ERRORS, requests.exceptions.RequestException) + def _raise_timeout_error(message: str) -> NoReturn: raise TimeoutError(message) @@ -443,6 +451,36 @@ def _prowlarr_result_to_release( ) +# Last successfully fetched per-indexer share limits. Used as a fallback when +# a transient Prowlarr API failure prevents fetching fresh settings during a +# search, so results are never silently cached without seed limits (#795). +_seed_settings_lock = Lock() +_last_known_seed_settings: dict[int, IndexerSeedSettings] = {} + + +def _fetch_indexer_seed_settings( + client: ProwlarrClient, + indexer_ids: list[int] | None, +) -> dict[int, IndexerSeedSettings]: + """Fetch per-indexer share limits, falling back to last-known-good on failure.""" + try: + fetched = client.get_indexer_seed_settings(restrict_to=indexer_ids) + except _PROWLARR_SEED_SETTINGS_ERRORS: + with _seed_settings_lock: + fallback = dict(_last_known_seed_settings) + logger.warning( + "Failed to fetch Prowlarr indexer seed settings; " + "falling back to last known settings for %s indexer(s)", + len(fallback), + exc_info=True, + ) + return fallback + + with _seed_settings_lock: + _last_known_seed_settings.update(fetched) + return fetched + + def _apply_indexer_seed_settings( result: dict, indexer_seed_settings: dict[int, IndexerSeedSettings], @@ -783,7 +821,7 @@ class ProwlarrSource(ReleaseSource): enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids) enriched_indexer_ids_set = set(enriched_indexer_ids) indexer_seed_settings = ( - client.get_indexer_seed_settings(restrict_to=indexer_ids) + _fetch_indexer_seed_settings(client, indexer_ids) if config.get("PROWLARR_USE_SEED_PREFERENCES", False) else {} ) diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index 487a0268..ff228d78 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -316,6 +316,124 @@ class TestProwlarrHandlerSeedCriteria: assert request.seeding_time_limit is None assert request.ratio_limit is None + def test_resolve_download_falls_back_to_prowlarr_when_enrichment_missing(self): + """Regression test for #795: when search-time enrichment is missing, + share limits are re-resolved from Prowlarr at grab time.""" + mock_client = MagicMock() + mock_client.get_indexer_seed_settings.return_value = { + 5: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0} + } + + def config_get(key, default=None): + return True if key == "PROWLARR_USE_SEED_PREFERENCES" else default + + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "title": "Test Release", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + "indexerId": 5, + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=config_get, + ), + patch.object( + ProwlarrHandler, + "_build_prowlarr_client", + return_value=mock_client, + ), + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="seed-time-fallback", + source="prowlarr", + title="Test Book", + ) + + request = handler._resolve_download(task, lambda *_: None) + + assert request is not None + assert request.seeding_time_limit == 4320 + assert request.ratio_limit == 1.0 + mock_client.get_indexer_seed_settings.assert_called_once_with(restrict_to=[5]) + + def test_resolve_download_fallback_failure_leaves_limits_unset(self): + mock_client = MagicMock() + mock_client.get_indexer_seed_settings.side_effect = RuntimeError("prowlarr down") + + def config_get(key, default=None): + return True if key == "PROWLARR_USE_SEED_PREFERENCES" else default + + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "title": "Test Release", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + "indexerId": 5, + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=config_get, + ), + patch.object( + ProwlarrHandler, + "_build_prowlarr_client", + return_value=mock_client, + ), + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="seed-time-fallback-failure", + source="prowlarr", + title="Test Book", + ) + + request = handler._resolve_download(task, lambda *_: None) + + assert request is not None + assert request.seeding_time_limit is None + assert request.ratio_limit is None + + def test_resolve_download_skips_fallback_when_enrichment_present(self): + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "title": "Test Release", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + "indexerId": 5, + "configuredSeedTimeMinutes": 7200, + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=lambda key, default=None: ( + True if key == "PROWLARR_USE_SEED_PREFERENCES" else default + ), + ), + patch.object(ProwlarrHandler, "_build_prowlarr_client") as mock_builder, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="seed-time-no-fallback", + source="prowlarr", + title="Test Book", + ) + + request = handler._resolve_download(task, lambda *_: None) + + assert request is not None + assert request.seeding_time_limit == 7200 + mock_builder.assert_not_called() + def test_download_passes_seed_limits_to_client(self): mock_client = MagicMock() mock_client.name = "qbittorrent" diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index c5fd3718..e360b255 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -13,6 +13,8 @@ from shelfmark.release_sources.prowlarr.source import ( ProwlarrSource, _detect_content_type_from_categories, _extract_format, + _fetch_indexer_seed_settings, + _last_known_seed_settings, _parse_size, ) from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url @@ -234,7 +236,7 @@ class FakeTorznabClient: self.search_results = search_results or [] self.seed_settings = seed_settings or {} - def get_enabled_indexers_detailed(self): + def get_enabled_indexers_detailed(self, *, raise_on_error=False): return [ { "id": 1, @@ -278,7 +280,7 @@ class TestProwlarrIndexerSeedSettings: monkeypatch.setattr( client, "get_enabled_indexers_detailed", - lambda: [ + lambda *, raise_on_error=False: [ { "id": 13, "protocol": "torrent", @@ -640,7 +642,7 @@ class TestProwlarrLocalizedQueries: source = ProwlarrSource() class FailingClient: - def get_enabled_indexers_detailed(self): + def get_enabled_indexers_detailed(self, *, raise_on_error=False): raise RuntimeError("indexers unavailable") monkeypatch.setattr(source, "_get_client", lambda: FailingClient()) @@ -655,7 +657,7 @@ class TestProwlarrLocalizedQueries: source = ProwlarrSource() class FailingClient: - def get_enabled_indexers_detailed(self): + def get_enabled_indexers_detailed(self, *, raise_on_error=False): raise RuntimeError("indexers unavailable") assert source._resolve_indexer_ids_from_names(FailingClient(), ["Alpha"]) is None @@ -664,7 +666,52 @@ class TestProwlarrLocalizedQueries: source = ProwlarrSource() class FailingClient: - def get_enabled_indexers_detailed(self): + def get_enabled_indexers_detailed(self, *, raise_on_error=False): raise RuntimeError("indexers unavailable") assert source._get_search_indexer_ids(FailingClient(), None, [7000]) == [] + + +class TestFetchIndexerSeedSettingsFallback: + """Regression tests for #795: transient Prowlarr API failures must not + silently strip per-indexer share limits from search results.""" + + @pytest.fixture(autouse=True) + def _clear_last_known_seed_settings(self): + _last_known_seed_settings.clear() + yield + _last_known_seed_settings.clear() + + def test_success_updates_last_known_good(self): + class Client: + def get_indexer_seed_settings(self, restrict_to=None): + del restrict_to + return {13: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}} + + fetched = _fetch_indexer_seed_settings(Client(), None) + + assert fetched == {13: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}} + assert _last_known_seed_settings == fetched + + def test_failure_falls_back_to_last_known_good(self): + _last_known_seed_settings[13] = {"seeding_time_limit_minutes": 4320} + + class FailingClient: + def get_indexer_seed_settings(self, restrict_to=None): + del restrict_to + raise RuntimeError("indexers unavailable") + + fetched = _fetch_indexer_seed_settings(FailingClient(), None) + + assert fetched == {13: {"seeding_time_limit_minutes": 4320}} + # The fallback must be a copy so callers cannot mutate the cache. + fetched[99] = {"ratio_limit": 2.0} + assert 99 not in _last_known_seed_settings + + def test_failure_with_no_history_returns_empty(self): + class FailingClient: + def get_indexer_seed_settings(self, restrict_to=None): + del restrict_to + raise RuntimeError("indexers unavailable") + + assert _fetch_indexer_seed_settings(FailingClient(), None) == {}