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