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
This commit is contained in:
CaliBrain
2026-08-21 09:07:03 -04:00
committed by GitHub
parent 5b3df2a463
commit e7007865a4
10 changed files with 882 additions and 78 deletions
+11
View File
@@ -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**
+67 -31
View File
@@ -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)
+43 -16
View File
@@ -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)
@@ -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,
*,
+79 -5
View File
@@ -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
@@ -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",
+115 -25
View File
@@ -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:
+126
View File
@@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel></channel></rss>"""
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
+190
View File
@@ -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}
+177 -1
View File
@@ -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