From e7007865a4b8b1d8b2a2403f9b64796d7168b449 Mon Sep 17 00:00:00 2001 From: CaliBrain Date: Fri, 21 Aug 2026 09:07:03 -0400 Subject: [PATCH] fix(prowlarr): stop turning indexer failures into empty results and 404s (#1251) Two independent bugs, both from an indexer that Prowlarr proxies rather than answers for itself: the search never reported that it had failed, and the grab never resolved what it was handed. Search. A Torznab search is Prowlarr proxying a live request out to the tracker, so for a Cloudflare-fronted indexer it waits on FlareSolverr. The client gave it the 30s budget sized for Prowlarr's own JSON endpoints, then swallowed every failure -- the timeout, the 429 Prowlarr returns once it has disabled an indexer, a parse error -- into the same empty list that means "this indexer has nothing". A cold challenge routinely runs past a minute, so the UI said "No releases found for this book" while FlareSolverr was still solving. That empty list also drove the auto-expand retry, which fires on "no results with the category filter". A timeout satisfies it, so Shelfmark sent a second search to an indexer still busy with the first -- two Chromes at once, enough to take FlareSolverr's down on a small host. torznab_search now raises ProwlarrSearchError, and an empty list strictly means the indexer answered with no matches. The source records which indexer searches failed: one dead indexer no longer sinks the others, auto-expand runs only when every indexer genuinely answered, and zero results with at least one failure raises SourceUnavailableError, which the releases endpoint already turns into a 503 carrying a real message. Prowlarr being unreachable was the same lie by another route -- the indexer list came back empty, leaving nothing to query -- and now says so. Indexer searches also get their own timeout, PROWLARR_INDEXER_TIMEOUT, defaulting to 90s and clamped to 5-300. Prowlarr's status and indexer list keep 30s so Test Connection stays responsive, and the connect timeout is split out at 10s so an unreachable Prowlarr fails fast rather than hanging for the whole read budget. The overall per-request search budget now scales to twice the indexer timeout, capped at 240s, so raising the setting is not undone by the cap one level up while staying under the 300s gunicorn worker timeout. Grab. Prowlarr hands out a proxy download URL, with no magnetUrl and no infoHash, for any indexer that only publishes torrent files. The native Real-Debrid client built its magnet as "if not url.startswith('magnet:') and expected_hash", so with no hash to work from it left the URL alone and POSTed it to /torrents/addMagnet as the magnet field. Real-Debrid answered 404 and the grab died on a raw HTTP error. AllDebrid carried the same line and the same bug. Both now resolve the URL first, through the extract_torrent_info path the torrent clients have used since #1108: pass a magnet through untouched, follow a redirect or a response body that turns out to be a magnet, otherwise upload the fetched .torrent, and fall back to a magnet built from the infoHash only when the fetch failed. The file is preferred over a synthesized urn:btih: magnet because it carries the tracker list; a bare hash leaves the service to find the swarm on DHT alone. Fetches are shared with the rest of the add path through the torrent fetch cache, so resolving costs at most one request. Real-Debrid takes the file on PUT /torrents/addTorrent with the raw bytes as the request body, AllDebrid on POST /magnet/upload/file as multipart files[]. A URL that resolves to neither form now raises before any request reaches the service, so the user reads why instead of a 404. Neither debrid client had any test coverage; both have some now. Fixes #1249 Fixes #1250 --- docs/environment-variables.md | 11 + shelfmark/download/clients/alldebrid.py | 98 ++++++--- shelfmark/download/clients/realdebrid.py | 59 ++++-- shelfmark/download/clients/torrent_utils.py | 54 +++++ shelfmark/release_sources/prowlarr/api.py | 84 +++++++- .../release_sources/prowlarr/settings.py | 20 ++ shelfmark/release_sources/prowlarr/source.py | 140 ++++++++++--- tests/prowlarr/test_api_timeout.py | 126 ++++++++++++ tests/prowlarr/test_debrid_clients.py | 190 ++++++++++++++++++ tests/prowlarr/test_source.py | 178 +++++++++++++++- 10 files changed, 882 insertions(+), 78 deletions(-) create mode 100644 tests/prowlarr/test_api_timeout.py create mode 100644 tests/prowlarr/test_debrid_clients.py diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 659f193..6e395f2 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1223,6 +1223,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480 | `PROWLARR_URL` | Base URL of your Prowlarr instance | string | _none_ | | `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ | | `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ | +| `PROWLARR_INDEXER_TIMEOUT` | How long to wait for a single indexer to answer a search. Indexers behind FlareSolverr can need 90 seconds or more while a cold Cloudflare challenge is solved; raise this if searches come back empty and the Prowlarr log shows the search still running. | number | `90` | | `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` | | `PROWLARR_COLLAPSE_DUPLICATES` | Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible. | boolean | `true` | | `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` | @@ -1268,6 +1269,16 @@ Select which indexers to search. 📚 = has book categories. Leave empty to sear - **Type:** string (comma-separated) - **Default:** _empty list_ +#### `PROWLARR_INDEXER_TIMEOUT` + +**Indexer Search Timeout (seconds)** + +How long to wait for a single indexer to answer a search. Indexers behind FlareSolverr can need 90 seconds or more while a cold Cloudflare challenge is solved; raise this if searches come back empty and the Prowlarr log shows the search still running. + +- **Type:** number +- **Default:** `90` +- **Constraints:** min: 5, max: 300 + #### `PROWLARR_AUTO_EXPAND` **Auto-expand search on no results** diff --git a/shelfmark/download/clients/alldebrid.py b/shelfmark/download/clients/alldebrid.py index e5e6850..193f98f 100644 --- a/shelfmark/download/clients/alldebrid.py +++ b/shelfmark/download/clients/alldebrid.py @@ -26,6 +26,11 @@ from shelfmark.download.clients import ( register_client, ) from shelfmark.download.clients._coercion import config_text +from shelfmark.download.clients.torrent_utils import ( + DebridMagnet, + DebridUpload, + resolve_debrid_upload, +) from shelfmark.download.http import download_url from shelfmark.download.network import get_ssl_verify @@ -202,41 +207,19 @@ class AllDebridClient(DownloadClient): expected_hash: str | None = None, **kwargs: object, ) -> str: - """Upload a magnet link to AllDebrid and return the magnet ID.""" + """Send a torrent to AllDebrid and return the magnet ID. + + Accepts a magnet link, a .torrent URL, or an indexer proxy URL; anything + that is not already a magnet is resolved first, since an HTTP URL posted + as a magnet is rejected rather than downloaded (#1250). + """ if not self._api_key: msg = "AllDebrid API key is not configured" raise RuntimeError(msg) - magnet_link = url - if not magnet_link.startswith("magnet:") and expected_hash: - magnet_link = f"magnet:?xt=urn:btih:{expected_hash}" - - api_url = f"{_API_BASE}/magnet/upload" try: - resp = requests.post( - api_url, - headers=self._auth_headers(), - data={"magnets[]": magnet_link}, - timeout=_API_TIMEOUT, - verify=get_ssl_verify(api_url), - ) - resp.raise_for_status() - data = resp.json() - if data.get("status") != "success": - code = data.get("error", {}).get("code", "UNKNOWN") - msg = f"AllDebrid upload failed: {code}" - _raise_runtime_error(msg) - - magnets = data.get("data", {}).get("magnets", []) - if not magnets: - msg = "No magnet returned from AllDebrid" - _raise_runtime_error(msg) - - info = magnets[0] - if info.get("error"): - code = info["error"].get("code", "UNKNOWN") - msg = f"AllDebrid magnet error: {code}" - _raise_runtime_error(msg) + upload = resolve_debrid_upload(url, expected_hash=expected_hash) + info = self._send_torrent(upload) magnet_id = str(info.get("id", "")) if not magnet_id: @@ -262,12 +245,65 @@ class AllDebridClient(DownloadClient): ) except Exception: - logger.exception("Failed to upload magnet to AllDebrid") + logger.exception("Failed to add torrent to AllDebrid") raise else: return magnet_id + def _send_torrent(self, upload: DebridUpload) -> dict[str, Any]: + """Hand the torrent to AllDebrid, as a magnet or as a file upload. + + Both endpoints answer with the same envelope and the same per-entry + error shape, differing only in which key holds the entries. + """ + if isinstance(upload, DebridMagnet): + api_url = f"{_API_BASE}/magnet/upload" + entries_key = "magnets" + resp = requests.post( + api_url, + headers=self._auth_headers(), + data={"magnets[]": upload.magnet_url}, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(api_url), + ) + else: + api_url = f"{_API_BASE}/magnet/upload/file" + entries_key = "files" + resp = requests.post( + api_url, + headers=self._auth_headers(), + files={ + "files[]": ( + "release.torrent", + upload.torrent_data, + "application/x-bittorrent", + ) + }, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(api_url), + ) + + resp.raise_for_status() + data = resp.json() + if data.get("status") != "success": + code = data.get("error", {}).get("code", "UNKNOWN") + msg = f"AllDebrid upload failed: {code}" + _raise_runtime_error(msg) + + entries = data.get("data", {}).get(entries_key, []) + if not entries: + msg = "AllDebrid accepted the upload but returned no torrent" + _raise_runtime_error(msg) + + info = entries[0] + if info.get("error"): + code = info["error"].get("code", "UNKNOWN") + msg = f"AllDebrid rejected the torrent: {code}" + _raise_runtime_error(msg) + + return info + def get_status(self, download_id: str) -> DownloadStatus: """Poll AllDebrid for magnet status and drive the download.""" state = self._ensure_state(download_id) diff --git a/shelfmark/download/clients/realdebrid.py b/shelfmark/download/clients/realdebrid.py index f2eb00a..1ceab58 100644 --- a/shelfmark/download/clients/realdebrid.py +++ b/shelfmark/download/clients/realdebrid.py @@ -24,6 +24,11 @@ from shelfmark.download.clients import ( register_client, ) from shelfmark.download.clients._coercion import config_text +from shelfmark.download.clients.torrent_utils import ( + DebridMagnet, + DebridUpload, + resolve_debrid_upload, +) from shelfmark.download.http import download_url from shelfmark.download.network import get_ssl_verify @@ -173,26 +178,19 @@ class RealDebridClient(DownloadClient): expected_hash: str | None = None, **kwargs: object, ) -> str: - """Upload a magnet link to Real-Debrid and select all files.""" + """Send a torrent to Real-Debrid and select all files. + + Accepts a magnet link, a .torrent URL, or an indexer proxy URL; anything + that is not already a magnet is resolved first, because Real-Debrid + answers a non-magnet body on addMagnet with a bare 404 (#1250). + """ if not self._api_key: msg = "Real-Debrid API key is not configured" raise RuntimeError(msg) - magnet_link = url - if not magnet_link.startswith("magnet:") and expected_hash: - magnet_link = f"magnet:?xt=urn:btih:{expected_hash}" - - add_url = f"{_API_BASE}/torrents/addMagnet" try: - resp = requests.post( - add_url, - headers=self._auth_headers(), - data={"magnet": magnet_link}, - timeout=_API_TIMEOUT, - verify=get_ssl_verify(add_url), - ) - resp.raise_for_status() - data = resp.json() + upload = resolve_debrid_upload(url, expected_hash=expected_hash) + data = self._send_torrent(upload) torrent_id = str(data.get("id", "")) if not torrent_id: @@ -229,12 +227,41 @@ class RealDebridClient(DownloadClient): ) except Exception: - logger.exception("Failed to upload magnet to Real-Debrid") + logger.exception("Failed to add torrent to Real-Debrid") raise else: return torrent_id + def _send_torrent(self, upload: DebridUpload) -> dict[str, Any]: + """Hand the torrent to Real-Debrid, as a magnet or as a file upload.""" + if isinstance(upload, DebridMagnet): + add_url = f"{_API_BASE}/torrents/addMagnet" + resp = requests.post( + add_url, + headers=self._auth_headers(), + data={"magnet": upload.magnet_url}, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(add_url), + ) + else: + # addTorrent is a PUT that takes the raw file as the request body, + # not a form field: https://api.real-debrid.com/ + add_url = f"{_API_BASE}/torrents/addTorrent" + resp = requests.put( + add_url, + headers={ + **self._auth_headers(), + "Content-Type": "application/x-bittorrent", + }, + data=upload.torrent_data, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(add_url), + ) + + resp.raise_for_status() + return resp.json() + def get_status(self, download_id: str) -> DownloadStatus: """Poll Real-Debrid for torrent status and drive the download.""" state = self._ensure_state(download_id) diff --git a/shelfmark/download/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py index 1e116d7..fd81054 100644 --- a/shelfmark/download/clients/torrent_utils.py +++ b/shelfmark/download/clients/torrent_utils.py @@ -82,6 +82,60 @@ class TorrentInfo: return self +@dataclass +class DebridMagnet: + """A magnet link, ready to hand to a debrid service as-is.""" + + magnet_url: str + + +@dataclass +class DebridTorrentFile: + """Raw .torrent bytes, for a debrid service's file-upload endpoint.""" + + torrent_data: bytes + + +# A debrid service takes one or the other, never an indexer page or a proxy URL. +type DebridUpload = DebridMagnet | DebridTorrentFile + + +def resolve_debrid_upload(url: str, *, expected_hash: str | None = None) -> DebridUpload: + """Resolve a release download URL into a magnet link or .torrent bytes. + + Prowlarr hands out a proxy URL, with no magnetUrl and no infoHash, for any + indexer that only publishes torrent files - 1337x among them. Posting that + URL to a debrid service as if it were a magnet is what produced a bare 404 + from the service instead of a download (#1250). + + The torrent file is preferred over a synthesized `urn:btih:` magnet because + it carries the tracker list, which is how the service finds a swarm that is + not already cached. Fetches are shared with the rest of the add path through + the torrent fetch cache, so resolving here costs at most one request. + + Raises: + ValueError: The URL resolved to neither form, so there is nothing to send. + + """ + if url.startswith("magnet:"): + return DebridMagnet(magnet_url=url) + + info = extract_torrent_info(url, expected_hash=expected_hash) + + if info.is_magnet and info.magnet_url: + # The download URL redirected to, or returned, a magnet link. + return DebridMagnet(magnet_url=info.magnet_url) + if info.torrent_data: + return DebridTorrentFile(torrent_data=info.torrent_data) + if info.info_hash: + # No file to upload, but the hash alone still identifies the torrent. + return DebridMagnet(magnet_url=f"magnet:?xt=urn:btih:{info.info_hash}") + + reason = info.fetch_error or "no magnet link, info hash, or torrent file was available" + msg = f"Could not resolve a torrent to send from {url[:120]} ({reason})" + raise ValueError(msg) + + def extract_torrent_info( url: str, *, diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py index 557518e..b131f12 100644 --- a/shelfmark/release_sources/prowlarr/api.py +++ b/shelfmark/release_sources/prowlarr/api.py @@ -7,6 +7,7 @@ from typing import Any, TypedDict import requests +from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url from shelfmark.download.network import get_ssl_verify @@ -18,6 +19,19 @@ logger = setup_logger(__name__) _HTTP_STATUS_UNAUTHORIZED = HTTPStatus.UNAUTHORIZED _BOOK_CATEGORY_RANGE_START = 7000 _BOOK_CATEGORY_RANGE_END = 8000 + +# Prowlarr's own JSON endpoints (status, indexer list) read local state and answer +# in milliseconds, so they keep a short timeout. A Torznab search is different: it +# is Prowlarr proxying a live request to the tracker, which for a Cloudflare-fronted +# indexer means waiting on FlareSolverr to solve a challenge. A cold challenge +# routinely runs past a minute, so indexer searches get their own, longer budget. +DEFAULT_INDEXER_TIMEOUT_SECONDS = 90 +MIN_INDEXER_TIMEOUT_SECONDS = 5 +MAX_INDEXER_TIMEOUT_SECONDS = 300 + +# Connecting to Prowlarr itself is a LAN hop; only the read is allowed to be slow. +_CONNECT_TIMEOUT_SECONDS = 10.0 + _PROWLARR_CLIENT_ERRORS = ( requests.exceptions.RequestException, OSError, @@ -27,6 +41,37 @@ _PROWLARR_CLIENT_ERRORS = ( ) +class ProwlarrSearchError(RuntimeError): + """A Torznab search could not be completed. + + Deliberately distinct from an empty result list. Reporting a failed search as + "this indexer has nothing" is what turns a slow FlareSolverr challenge into + "No releases found for this book" in the UI (#1249), and it also makes the + auto-expand retry fire a second request on top of the one still running. + """ + + +def resolve_indexer_timeout(timeout: object = None) -> int: + """Resolve the per-indexer search timeout, falling back to config. + + Out-of-range and unparsable values are clamped rather than rejected: this + feeds an HTTP timeout, and a bad setting should not take searching down. + """ + if timeout is None: + timeout = config.get("PROWLARR_INDEXER_TIMEOUT", DEFAULT_INDEXER_TIMEOUT_SECONDS) + + resolved = coerce_int_like(timeout) + if resolved is None: + logger.warning( + "Invalid PROWLARR_INDEXER_TIMEOUT %r - using %ss", + timeout, + DEFAULT_INDEXER_TIMEOUT_SECONDS, + ) + return DEFAULT_INDEXER_TIMEOUT_SECONDS + + return max(MIN_INDEXER_TIMEOUT_SECONDS, min(MAX_INDEXER_TIMEOUT_SECONDS, resolved)) + + class IndexerSeedSettings(TypedDict, total=False): ratio_limit: float seeding_time_limit_minutes: int @@ -77,11 +122,23 @@ def _get_field_value(fields: object, name: str) -> object | None: class ProwlarrClient: """Client for interacting with the Prowlarr API.""" - def __init__(self, url: str, api_key: str, timeout: int = 30) -> None: - """Initialize the API client with base URL, key, and timeout.""" + def __init__( + self, url: str, api_key: str, timeout: int = 30, indexer_timeout: int | None = None + ) -> None: + """Initialize the API client with base URL, key, and timeouts. + + Args: + url: Prowlarr base URL. + api_key: Prowlarr API key. + timeout: Timeout for Prowlarr's own JSON endpoints. + indexer_timeout: Timeout for Torznab searches, which Prowlarr proxies + out to the tracker. Defaults to PROWLARR_INDEXER_TIMEOUT. + + """ self.base_url = normalize_http_url(url) self.api_key = api_key self.timeout = timeout + self.indexer_timeout = resolve_indexer_timeout(indexer_timeout) self._session = requests.Session() self._session.headers.update( { @@ -307,6 +364,12 @@ class ProwlarrClient: This returns richer fields (e.g., author/booktitle, torznab tags like FreeLeech) than the JSON /api/v1/search endpoint. + + Raises: + ProwlarrSearchError: The search could not be completed. An empty list + strictly means the indexer answered with no matches, never that + the request timed out or errored. + """ if not query: return [] @@ -329,7 +392,7 @@ class ProwlarrClient: response = self._session.get( url=url, params=params, - timeout=self.timeout, + timeout=(_CONNECT_TIMEOUT_SECONDS, self.indexer_timeout), headers={ # Override the session default JSON accept header. "Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8" @@ -347,9 +410,20 @@ class ProwlarrClient: for r in results: if r.get("indexerId") is None: r["indexerId"] = int(indexer_id) - except Exception: + except requests.exceptions.Timeout as e: + logger.warning( + "Prowlarr Torznab search for indexer %s timed out after %ss. An indexer " + "behind FlareSolverr can need far longer than that on a cold Cloudflare " + "challenge - raise PROWLARR_INDEXER_TIMEOUT if this keeps happening.", + indexer_id, + self.indexer_timeout, + ) + msg = f"indexer {indexer_id} did not respond within {self.indexer_timeout}s" + raise ProwlarrSearchError(msg) from e + except Exception as e: logger.exception("Prowlarr Torznab search failed for indexer %s", indexer_id) - return [] + msg = f"indexer {indexer_id} search failed: {e}" + raise ProwlarrSearchError(msg) from e else: return results diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index e3c4c30..85a9b45 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -10,12 +10,18 @@ from shelfmark.core.settings_registry import ( CheckboxField, HeadingField, MultiSelectField, + NumberField, PasswordField, SettingsField, TextField, register_settings, ) from shelfmark.core.utils import normalize_http_url +from shelfmark.release_sources.prowlarr.api import ( + DEFAULT_INDEXER_TIMEOUT_SECONDS, + MAX_INDEXER_TIMEOUT_SECONDS, + MIN_INDEXER_TIMEOUT_SECONDS, +) # ==================== Dynamic Options Loaders ==================== @@ -183,6 +189,20 @@ def prowlarr_config_settings() -> list[SettingsField]: default=[], show_when={"field": "PROWLARR_ENABLED", "value": True}, ), + NumberField( + key="PROWLARR_INDEXER_TIMEOUT", + label="Indexer Search Timeout (seconds)", + description=( + "How long to wait for a single indexer to answer a search. Indexers behind " + "FlareSolverr can need 90 seconds or more while a cold Cloudflare challenge " + "is solved; raise this if searches come back empty and the Prowlarr log " + "shows the search still running." + ), + default=DEFAULT_INDEXER_TIMEOUT_SECONDS, + min_value=MIN_INDEXER_TIMEOUT_SECONDS, + max_value=MAX_INDEXER_TIMEOUT_SECONDS, + show_when={"field": "PROWLARR_ENABLED", "value": True}, + ), CheckboxField( key="PROWLARR_AUTO_EXPAND", label="Auto-expand search on no results", diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index 527e9d9..16e6779 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -2,6 +2,7 @@ import re import time +from dataclasses import dataclass from threading import Lock from typing import TYPE_CHECKING, ClassVar, NoReturn @@ -30,9 +31,14 @@ from shelfmark.release_sources import ( ReleaseProtocol, ReleaseSource, SortOption, + SourceUnavailableError, register_source, ) -from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient +from shelfmark.release_sources.prowlarr.api import ( + IndexerSeedSettings, + ProwlarrClient, + ProwlarrSearchError, +) from shelfmark.release_sources.prowlarr.cache import cache_release from shelfmark.release_sources.prowlarr.utils import ( build_source_id, @@ -50,10 +56,10 @@ _PROWLARR_SOURCE_ERRORS = (AttributeError, OSError, RuntimeError, TypeError, Val # Prowlarr indexer priority is 1-50 and lower is preferred; unknown sorts last. _UNRANKED_INDEXER_RANK = 51 -# Errors that can surface from ProwlarrClient.get_indexer_seed_settings(). The +# Errors that can surface from a ProwlarrClient call that talks to Prowlarr. 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) +_PROWLARR_REQUEST_ERRORS = (*_PROWLARR_SOURCE_ERRORS, requests.exceptions.RequestException) def _raise_timeout_error(message: str) -> NoReturn: @@ -232,6 +238,35 @@ ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS # Backend safeguard: cap total Prowlarr search time per request. PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0 +# The overall budget has to leave room for at least a couple of indexers to spend +# their full per-indexer timeout, otherwise raising PROWLARR_INDEXER_TIMEOUT for a +# Cloudflare-fronted tracker just moves the cutoff here. Capped short of the +# gunicorn worker timeout (300s) so the worker is never the thing that gives up. +_MAX_SEARCH_BUDGET_SECONDS = 240.0 + + +def _search_budget_seconds(indexer_timeout: int) -> float: + """Total time one Prowlarr search may spend, scaled to the per-indexer timeout.""" + return min( + _MAX_SEARCH_BUDGET_SECONDS, + max(PROWLARR_SEARCH_TIMEOUT_SECONDS, indexer_timeout * 2.0), + ) + + +@dataclass +class _IndexerSearchOutcome: + """What one pass over the target indexers produced. + + Separates "every indexer answered, none had this book" from "the indexers + never answered", which the caller has to tell apart before it decides to + auto-expand or to report the search as failed. + """ + + results: list[dict] + attempted: int = 0 + failed: int = 0 + last_error: str | None = None + def _extract_format(title: str) -> str | None: """Extract ebook/audiobook format from release title (extension, bracketed, or standalone).""" @@ -539,7 +574,7 @@ def _fetch_indexer_seed_settings( """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: + except _PROWLARR_REQUEST_ERRORS: with _seed_settings_lock: fallback = dict(_last_known_seed_settings) logger.warning( @@ -895,8 +930,16 @@ class ProwlarrSource(ReleaseSource): try: auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False) - deadline = time.monotonic() + PROWLARR_SEARCH_TIMEOUT_SECONDS - enabled_indexers = client.get_enabled_indexers_detailed() + search_budget = _search_budget_seconds(client.indexer_timeout) + deadline = time.monotonic() + search_budget + try: + enabled_indexers = client.get_enabled_indexers_detailed(raise_on_error=True) + except _PROWLARR_REQUEST_ERRORS as e: + # Prowlarr itself is unreachable. Swallowing this leaves the search + # with no indexers to query, which the UI renders as "No releases + # found for this book" - the same lie as a swallowed timeout (#1249). + msg = f"could not reach Prowlarr: {e}" + raise SourceUnavailableError(msg) from e indexer_priority = _build_indexer_priority(enabled_indexers) # Some indexers benefit from title+author queries and extra format detection. enriched_indexer_ids = client.get_enriched_indexer_ids( @@ -911,18 +954,16 @@ class ProwlarrSource(ReleaseSource): def _check_timeout() -> None: if time.monotonic() > deadline: - _raise_timeout_error( - f"Prowlarr search timed out after {int(PROWLARR_SEARCH_TIMEOUT_SECONDS)}s" - ) + _raise_timeout_error(f"Prowlarr search timed out after {int(search_budget)}s") def search_indexers( query: str, cats: list[int] | None, *, enriched_query: str | None = None - ) -> list[dict]: + ) -> _IndexerSearchOutcome: """Search indexers with given categories via Torznab/Newznab.""" - results: list[dict] = [] + outcome = _IndexerSearchOutcome(results=[]) target_indexer_ids = self._get_search_indexer_ids(client, indexer_ids, cats) if not target_indexer_ids: - return results + return outcome for indexer_id in target_indexer_ids: _check_timeout() @@ -931,19 +972,31 @@ class ProwlarrSource(ReleaseSource): if indexer_id in enriched_indexer_ids_set and enriched_query else query ) - raw = client.torznab_search( - indexer_id=indexer_id, - query=indexer_query, - categories=cats, - search_type="book", - ) + outcome.attempted += 1 + try: + raw = client.torznab_search( + indexer_id=indexer_id, + query=indexer_query, + categories=cats, + search_type="book", + ) + except ProwlarrSearchError as e: + # One unreachable indexer must not sink the others, but it + # is not "no results" either - record it so the caller can + # report a failed search instead of an empty one. + outcome.failed += 1 + outcome.last_error = str(e) + continue if raw: - results.extend(raw) + outcome.results.extend(raw) - return results + return outcome seen_keys: set[tuple[int | None, str]] = set() all_results: list[dict] = [] + attempted_searches = 0 + failed_searches = 0 + last_search_error: str | None = None for idx, variant in enumerate(variants, start=1): _check_timeout() @@ -953,23 +1006,39 @@ class ProwlarrSource(ReleaseSource): if len(variants) > 1: logger.debug("Prowlarr query %s/%s: '%s'", idx, len(variants), query) - raw_results = search_indexers( + outcome = search_indexers( query=query, cats=categories, enriched_query=enriched_query ) - # Auto-expand: if no results with categories and auto-expand enabled, retry without - if not raw_results and categories and auto_expand_enabled: + # Auto-expand: if no results with categories and auto-expand enabled, retry without. + # Only when every indexer actually answered: a failed search says nothing about + # whether the category filter is what hid the book, and retrying it stacks a second + # request on an indexer that is still busy solving a Cloudflare challenge (#1249). + if ( + not outcome.results + and not outcome.failed + and categories + and auto_expand_enabled + ): _check_timeout() logger.info( "Prowlarr: no results for query '%s' with category filter, auto-expanding search", query, ) - raw_results = search_indexers( + expanded = search_indexers( query=query, cats=None, enriched_query=enriched_query ) + outcome.results = expanded.results + outcome.attempted += expanded.attempted + outcome.failed += expanded.failed + outcome.last_error = expanded.last_error or outcome.last_error self.last_search_type = "expanded" - for r in raw_results: + attempted_searches += outcome.attempted + failed_searches += outcome.failed + last_search_error = outcome.last_error or last_search_error + + for r in outcome.results: key = _result_dedup_key(r) if key is not None: if key in seen_keys: @@ -977,6 +1046,14 @@ class ProwlarrSource(ReleaseSource): seen_keys.add(key) all_results.append(r) + if failed_searches: + logger.warning( + "Prowlarr: %s of %s indexer searches failed (%s)", + failed_searches, + attempted_searches, + last_search_error, + ) + if config.get("PROWLARR_COLLAPSE_DUPLICATES", True): before_collapse = len(all_results) all_results = _collapse_duplicate_indexer_results(all_results, indexer_priority) @@ -1033,6 +1110,10 @@ class ProwlarrSource(ReleaseSource): else: logger.debug("Prowlarr: no results found") + except SourceUnavailableError: + # Already carries its own message for the caller to surface; the blanket + # handler below would turn it back into a silent empty result. + raise except TimeoutError as e: logger.warning("Prowlarr search timed out: %s", e) raise @@ -1040,6 +1121,15 @@ class ProwlarrSource(ReleaseSource): logger.exception("Prowlarr search failed") return [] else: + # An empty list is the UI's "No releases found for this book", so it has + # to mean the indexers answered and had nothing. When they failed instead, + # say so rather than blaming the book (#1249). + if not results and failed_searches: + msg = ( + f"{failed_searches} of {attempted_searches} indexer searches failed " + f"({last_search_error})" + ) + raise SourceUnavailableError(msg) return results def is_available(self) -> bool: diff --git a/tests/prowlarr/test_api_timeout.py b/tests/prowlarr/test_api_timeout.py new file mode 100644 index 0000000..af26e45 --- /dev/null +++ b/tests/prowlarr/test_api_timeout.py @@ -0,0 +1,126 @@ +"""Prowlarr Torznab timeout handling (#1249). + +A Torznab search is Prowlarr proxying a live request to the tracker, so for a +Cloudflare-fronted indexer it waits on FlareSolverr. Those searches need their +own budget, and when one runs out the caller has to hear about it instead of +receiving an empty list that reads as "this book has no releases". +""" + +import pytest +import requests + +import shelfmark.release_sources.prowlarr.api as prowlarr_api +from shelfmark.release_sources.prowlarr.api import ( + DEFAULT_INDEXER_TIMEOUT_SECONDS, + MAX_INDEXER_TIMEOUT_SECONDS, + MIN_INDEXER_TIMEOUT_SECONDS, + ProwlarrClient, + ProwlarrSearchError, + resolve_indexer_timeout, +) + +_TORZNAB_EMPTY = """ +""" + + +class _Response: + def __init__(self, text="", status_code=200, reason="OK"): + self.text = text + self.status_code = status_code + self.reason = reason + self.ok = status_code < 400 + + def raise_for_status(self): + if not self.ok: + raise requests.exceptions.HTTPError(f"{self.status_code} {self.reason}", response=self) + + def json(self): + return {} + + +@pytest.fixture +def no_config(monkeypatch): + """Keep the client off any persisted PROWLARR_INDEXER_TIMEOUT.""" + monkeypatch.setattr(prowlarr_api.config, "get", lambda key, default=None: default) + + +class TestResolveIndexerTimeout: + def test_defaults_when_unset(self, no_config): + assert resolve_indexer_timeout() == DEFAULT_INDEXER_TIMEOUT_SECONDS + + def test_reads_config(self, monkeypatch): + monkeypatch.setattr( + prowlarr_api.config, + "get", + lambda key, default=None: 150 if key == "PROWLARR_INDEXER_TIMEOUT" else default, + ) + assert resolve_indexer_timeout() == 150 + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (1, MIN_INDEXER_TIMEOUT_SECONDS), + (9000, MAX_INDEXER_TIMEOUT_SECONDS), + ("120", 120), + ], + ) + def test_clamps_and_coerces(self, value, expected): + assert resolve_indexer_timeout(value) == expected + + def test_unparsable_value_falls_back_rather_than_raising(self): + assert resolve_indexer_timeout("soon") == DEFAULT_INDEXER_TIMEOUT_SECONDS + + +class TestTorznabSearchFailures: + def _client(self, monkeypatch, response_or_error): + client = ProwlarrClient("http://prowlarr:9696", "apikey", indexer_timeout=90) + captured: dict[str, object] = {} + + def fake_get(**kwargs): + captured.update(kwargs) + if isinstance(response_or_error, Exception): + raise response_or_error + return response_or_error + + monkeypatch.setattr(client._session, "get", fake_get) + return client, captured + + def test_read_timeout_raises_instead_of_returning_empty(self, monkeypatch): + client, _ = self._client(monkeypatch, requests.exceptions.ReadTimeout("read timeout=90")) + + with pytest.raises(ProwlarrSearchError, match="did not respond within 90s"): + client.torznab_search(indexer_id=1, query="Dune") + + def test_http_error_raises_instead_of_returning_empty(self, monkeypatch): + """Prowlarr answers 429 once it has disabled an indexer for recent failures.""" + client, _ = self._client(monkeypatch, _Response("", status_code=429, reason="Too Many")) + + with pytest.raises(ProwlarrSearchError, match="indexer 1 search failed"): + client.torznab_search(indexer_id=1, query="Dune") + + def test_an_indexer_with_nothing_still_returns_empty(self, monkeypatch): + client, _ = self._client(monkeypatch, _Response(_TORZNAB_EMPTY)) + + assert client.torznab_search(indexer_id=1, query="Dune") == [] + + def test_search_uses_the_indexer_timeout_with_a_short_connect_timeout(self, monkeypatch): + client, captured = self._client(monkeypatch, _Response(_TORZNAB_EMPTY)) + client.torznab_search(indexer_id=1, query="Dune") + + connect_timeout, read_timeout = captured["timeout"] + assert read_timeout == 90 + assert connect_timeout < read_timeout + + def test_json_endpoints_keep_the_short_timeout(self, monkeypatch, no_config): + client = ProwlarrClient("http://prowlarr:9696", "apikey") + captured: dict[str, object] = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return _Response("{}") + + monkeypatch.setattr(client._session, "request", fake_request) + client.test_connection() + + assert captured["timeout"] == client.timeout + assert client.timeout < client.indexer_timeout diff --git a/tests/prowlarr/test_debrid_clients.py b/tests/prowlarr/test_debrid_clients.py new file mode 100644 index 0000000..1b7f637 --- /dev/null +++ b/tests/prowlarr/test_debrid_clients.py @@ -0,0 +1,190 @@ +"""Debrid clients must resolve a real torrent before uploading (#1250). + +Prowlarr hands out a proxy download URL - no magnetUrl, no infoHash - for any +indexer that only publishes torrent files. Posting that URL as if it were a +magnet is what made Real-Debrid answer /torrents/addMagnet with a bare 404. +""" + +import hashlib +from unittest.mock import MagicMock + +import pytest + +from shelfmark.download.clients.alldebrid import AllDebridClient +from shelfmark.download.clients.realdebrid import RealDebridClient +from shelfmark.download.clients.torrent_utils import ( + DebridMagnet, + DebridTorrentFile, + bencode_encode, + resolve_debrid_upload, +) + +_PROWLARR_PROXY_URL = "https://prowlarr.example/api/v1/indexer/1/download?apikey=k&link=abc" +_MAGNET = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Dune" + + +def _valid_torrent() -> tuple[bytes, str]: + info_dict = { + b"name": b"book.epub", + b"length": 100, + b"piece length": 16384, + b"pieces": b"\x00" * 20, + } + return ( + bencode_encode({b"info": info_dict}), + hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower(), + ) + + +def _mock_fetch(monkeypatch, *, content=b"", status_code=200, error=None): + """Stand in for the .torrent prefetch inside extract_torrent_info.""" + if error is not None: + mock_get = MagicMock(side_effect=error) + else: + response = MagicMock(status_code=status_code, content=content) + response.raise_for_status = MagicMock() + mock_get = MagicMock(return_value=response) + monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get) + return mock_get + + +class TestResolveDebridUpload: + def test_magnet_url_passes_through_untouched(self): + assert resolve_debrid_upload(_MAGNET) == DebridMagnet(magnet_url=_MAGNET) + + def test_proxy_url_becomes_the_torrent_file(self, monkeypatch): + torrent_data, _ = _valid_torrent() + _mock_fetch(monkeypatch, content=torrent_data) + + assert resolve_debrid_upload(_PROWLARR_PROXY_URL) == DebridTorrentFile( + torrent_data=torrent_data + ) + + def test_proxy_url_returning_a_magnet_body_becomes_that_magnet(self, monkeypatch): + _mock_fetch(monkeypatch, content=_MAGNET.encode()) + + assert resolve_debrid_upload(_PROWLARR_PROXY_URL) == DebridMagnet(magnet_url=_MAGNET) + + def test_falls_back_to_a_magnet_built_from_the_prowlarr_info_hash(self, monkeypatch): + _mock_fetch(monkeypatch, error=OSError("tracker unreachable")) + info_hash = "0123456789abcdef0123456789abcdef01234567" + + upload = resolve_debrid_upload(_PROWLARR_PROXY_URL, expected_hash=info_hash) + + assert upload == DebridMagnet(magnet_url=f"magnet:?xt=urn:btih:{info_hash}") + + def test_prefers_the_torrent_file_over_the_info_hash(self, monkeypatch): + """The file carries the tracker list; a bare btih magnet does not.""" + torrent_data, info_hash = _valid_torrent() + _mock_fetch(monkeypatch, content=torrent_data) + + upload = resolve_debrid_upload(_PROWLARR_PROXY_URL, expected_hash=info_hash) + + assert upload == DebridTorrentFile(torrent_data=torrent_data) + + def test_unresolvable_url_raises_rather_than_handing_back_the_url(self, monkeypatch): + _mock_fetch(monkeypatch, error=OSError("tracker unreachable")) + + with pytest.raises(ValueError, match="Could not resolve a torrent to send") as excinfo: + resolve_debrid_upload(_PROWLARR_PROXY_URL) + + assert "tracker unreachable" in str(excinfo.value) + + +class TestRealDebridAdd: + @staticmethod + def _client(monkeypatch): + monkeypatch.setattr( + "shelfmark.download.clients.realdebrid.config.get", + lambda key, default="": {"REALDEBRID_API_KEY": "rd-key"}.get(key, default), + ) + return RealDebridClient() + + @staticmethod + def _mock_api(monkeypatch): + ok = MagicMock(status_code=201) + ok.raise_for_status = MagicMock() + ok.json = MagicMock(return_value={"id": "RD1"}) + post = MagicMock(return_value=ok) + put = MagicMock(return_value=ok) + monkeypatch.setattr("shelfmark.download.clients.realdebrid.requests.post", post) + monkeypatch.setattr("shelfmark.download.clients.realdebrid.requests.put", put) + return post, put + + def test_proxy_url_is_uploaded_as_a_torrent_file_not_posted_as_a_magnet(self, monkeypatch): + torrent_data, _ = _valid_torrent() + _mock_fetch(monkeypatch, content=torrent_data) + post, put = self._mock_api(monkeypatch) + client = self._client(monkeypatch) + + assert client.add_download(_PROWLARR_PROXY_URL, "Dune") == "RD1" + + put.assert_called_once() + assert put.call_args.args[0].endswith("/torrents/addTorrent") + assert put.call_args.kwargs["data"] == torrent_data + # The only POST is selectFiles; addMagnet must never see the proxy URL. + assert [c.args[0] for c in post.call_args_list] == [ + "https://api.real-debrid.com/rest/1.0/torrents/selectFiles/RD1" + ] + + def test_magnet_still_goes_to_add_magnet(self, monkeypatch): + post, put = self._mock_api(monkeypatch) + client = self._client(monkeypatch) + + assert client.add_download(_MAGNET, "Dune") == "RD1" + + put.assert_not_called() + assert post.call_args_list[0].args[0].endswith("/torrents/addMagnet") + assert post.call_args_list[0].kwargs["data"] == {"magnet": _MAGNET} + + def test_unresolvable_url_reports_the_reason_instead_of_a_service_error(self, monkeypatch): + _mock_fetch(monkeypatch, error=OSError("tracker unreachable")) + post, put = self._mock_api(monkeypatch) + client = self._client(monkeypatch) + + with pytest.raises(ValueError, match="Could not resolve a torrent to send"): + client.add_download(_PROWLARR_PROXY_URL, "Dune") + + post.assert_not_called() + put.assert_not_called() + + +class TestAllDebridAdd: + @staticmethod + def _client(monkeypatch): + monkeypatch.setattr( + "shelfmark.download.clients.alldebrid.config.get", + lambda key, default="": {"ALLDEBRID_API_KEY": "ad-key"}.get(key, default), + ) + return AllDebridClient() + + @staticmethod + def _mock_api(monkeypatch, entries_key="files"): + ok = MagicMock(status_code=200) + ok.raise_for_status = MagicMock() + ok.json = MagicMock( + return_value={"status": "success", "data": {entries_key: [{"id": 4242}]}} + ) + post = MagicMock(return_value=ok) + monkeypatch.setattr("shelfmark.download.clients.alldebrid.requests.post", post) + return post + + def test_proxy_url_is_uploaded_to_the_file_endpoint(self, monkeypatch): + torrent_data, _ = _valid_torrent() + _mock_fetch(monkeypatch, content=torrent_data) + post = self._mock_api(monkeypatch) + client = self._client(monkeypatch) + + assert client.add_download(_PROWLARR_PROXY_URL, "Dune") == "4242" + + assert post.call_args.args[0].endswith("/magnet/upload/file") + assert post.call_args.kwargs["files"]["files[]"][1] == torrent_data + + def test_magnet_still_goes_to_the_magnet_endpoint(self, monkeypatch): + post = self._mock_api(monkeypatch, entries_key="magnets") + client = self._client(monkeypatch) + + assert client.add_download(_MAGNET, "Dune") == "4242" + + assert post.call_args.args[0].endswith("/magnet/upload") + assert post.call_args.kwargs["data"] == {"magnets[]": _MAGNET} diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index 8172c92..556aa70 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -6,10 +6,13 @@ Tests the utility functions for parsing release metadata. # Import the functions to test import pytest +import requests from shelfmark.metadata_providers import BookMetadata -from shelfmark.release_sources.prowlarr.api import ProwlarrClient +from shelfmark.release_sources import SourceUnavailableError +from shelfmark.release_sources.prowlarr.api import ProwlarrClient, ProwlarrSearchError from shelfmark.release_sources.prowlarr.source import ( + PROWLARR_SEARCH_TIMEOUT_SECONDS, ProwlarrSource, _build_indexer_priority, _collapse_duplicate_indexer_results, @@ -21,6 +24,7 @@ from shelfmark.release_sources.prowlarr.source import ( _parse_size, _release_identity, _result_dedup_key, + _search_budget_seconds, ) from shelfmark.release_sources.prowlarr.utils import ( build_source_id, @@ -244,6 +248,7 @@ class FakeTorznabClient: self.seed_settings_calls: list[object] = [] self.search_results = search_results or [] self.seed_settings = seed_settings or {} + self.indexer_timeout = 90 def get_enabled_indexers_detailed(self, *, raise_on_error=False): return [ @@ -788,6 +793,7 @@ class _MultiIndexerClient: def __init__(self, results_by_indexer: dict[int, list[dict]], priorities=None): self.results_by_indexer = results_by_indexer self.priorities = priorities or {} + self.indexer_timeout = 90 def get_enabled_indexers_detailed(self, *, raise_on_error=False): del raise_on_error @@ -1222,3 +1228,173 @@ class TestIndexerPrioritySortOption: assert options["Indexer priority"]["sort_key"] == "extra.indexer_priority" assert options["Indexer priority"]["default_direction"] == "asc" assert options["Peers"]["default_direction"] == "desc" + + +class _FailingIndexerClient: + """Torznab client where chosen indexers raise instead of answering. + + Mirrors #1249: Prowlarr proxies the search to a Cloudflare-fronted tracker, + FlareSolverr is still solving the challenge when the HTTP read times out. + """ + + def __init__(self, failing_indexers: set[int], results_by_indexer=None): + self.failing_indexers = failing_indexers + self.results_by_indexer = results_by_indexer or {} + self.indexer_timeout = 90 + self.calls: list[tuple[int, object]] = [] + + def get_enabled_indexers_detailed(self, *, raise_on_error=False): + del raise_on_error + indexer_ids = sorted(self.failing_indexers | set(self.results_by_indexer)) + return [ + { + "id": indexer_id, + "enable": True, + "capabilities": {"categories": [{"id": 7000, "subCategories": []}]}, + } + for indexer_id in indexer_ids + ] + + def torznab_search( + self, + *, + indexer_id: int, + query: str, + categories=None, + search_type="book", + limit=100, + offset=0, + ): + del query, search_type, limit, offset + self.calls.append((indexer_id, categories)) + if indexer_id in self.failing_indexers: + msg = f"indexer {indexer_id} did not respond within 90s" + raise ProwlarrSearchError(msg) + return self.results_by_indexer.get(indexer_id, []) + + def get_enriched_indexer_ids(self, restrict_to=None, indexers=None): + del restrict_to, indexers + return [] + + def get_indexer_seed_settings(self, restrict_to=None): + del restrict_to + return {} + + +class TestFailedIndexerSearchIsNotNoResults: + """A Torznab timeout must not read as "this book has no releases" (#1249).""" + + def _search(self, monkeypatch, client, config_values=None): + import shelfmark.release_sources.prowlarr.source as prowlarr_source + from shelfmark.core.search_plan import build_release_search_plan + + values = {"PROWLARR_INDEXERS": "", "PROWLARR_AUTO_EXPAND": False} + values.update(config_values or {}) + monkeypatch.setattr( + prowlarr_source.config, "get", lambda key, default=None: values.get(key, default) + ) + + source = ProwlarrSource() + monkeypatch.setattr(source, "_get_client", lambda: client) + + book = BookMetadata( + provider="hardcover", provider_id="123", title="Dune", authors=["Frank Herbert"] + ) + plan = build_release_search_plan(book, languages=["en"]) + return source.search(book, plan) + + def test_sole_indexer_timing_out_raises_instead_of_returning_empty(self, monkeypatch): + client = _FailingIndexerClient({1}) + + with pytest.raises(SourceUnavailableError) as excinfo: + self._search(monkeypatch, client) + + assert "1 of 1 indexer searches failed" in str(excinfo.value) + assert "did not respond within 90s" in str(excinfo.value) + + def test_one_failure_does_not_sink_an_indexer_that_answered(self, monkeypatch): + client = _FailingIndexerClient( + {1}, + { + 2: [ + { + "guid": "g2", + "title": "Dune", + "indexerId": 2, + "indexer": "working", + "protocol": "torrent", + "size": 1048576, + "seeders": 5, + } + ] + }, + ) + + releases = self._search(monkeypatch, client) + + assert [r.indexer for r in releases] == ["working"] + + def test_partial_failure_with_no_results_still_reports_the_failure(self, monkeypatch): + client = _FailingIndexerClient({1}, {2: []}) + + with pytest.raises(SourceUnavailableError) as excinfo: + self._search(monkeypatch, client) + + assert "1 of 2 indexer searches failed" in str(excinfo.value) + + def test_auto_expand_does_not_retry_on_top_of_a_failed_search(self, monkeypatch): + """The second search is what crashes FlareSolverr's Chrome on a small host.""" + client = _FailingIndexerClient({1}) + + with pytest.raises(SourceUnavailableError): + self._search(monkeypatch, client, {"PROWLARR_AUTO_EXPAND": True}) + + assert client.calls == [(1, [7000])] + + def test_auto_expand_still_retries_when_the_indexer_answered_empty(self, monkeypatch): + client = _FailingIndexerClient(set(), {1: []}) + + assert self._search(monkeypatch, client, {"PROWLARR_AUTO_EXPAND": True}) == [] + assert client.calls == [(1, [7000]), (1, None)] + + +class TestUnreachableProwlarrIsNotNoResults: + """Prowlarr itself being down must not read as "this book has no releases" (#1249).""" + + class _UnreachableClient: + indexer_timeout = 90 + + def get_enabled_indexers_detailed(self, *, raise_on_error=False): + del raise_on_error + raise requests.exceptions.ConnectionError("connection refused") + + def test_search_reports_the_connection_failure(self, monkeypatch): + import shelfmark.release_sources.prowlarr.source as prowlarr_source + from shelfmark.core.search_plan import build_release_search_plan + + monkeypatch.setattr( + prowlarr_source.config, + "get", + lambda key, default=None: {"PROWLARR_INDEXERS": ""}.get(key, default), + ) + source = ProwlarrSource() + monkeypatch.setattr(source, "_get_client", lambda: self._UnreachableClient()) + + book = BookMetadata( + provider="hardcover", provider_id="123", title="Dune", authors=["Frank Herbert"] + ) + plan = build_release_search_plan(book, languages=["en"]) + + with pytest.raises(SourceUnavailableError, match="could not reach Prowlarr"): + source.search(book, plan) + + +class TestSearchBudgetScalesWithIndexerTimeout: + def test_default_budget_is_unchanged(self): + assert _search_budget_seconds(30) == PROWLARR_SEARCH_TIMEOUT_SECONDS + + def test_a_long_indexer_timeout_widens_the_budget(self): + assert _search_budget_seconds(120) == 240.0 + + def test_budget_stays_under_the_gunicorn_worker_timeout(self): + assert _search_budget_seconds(300) == 240.0