Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions quasarr/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Submodules are packages with all code in their `__init__.py`: `arr/` (emulation
- Newznab emulation (`GET /api` with `?t=`): movie/tv searches (`t=movie`, `t=tvsearch`) are imdbid-only (q-only requests are deliberately ignored, except q-as-episode alongside an imdbid) and require configured Radarr/Sonarr clients respectively; `t=book`/`t=music` search by author/title phrase; `t=search` is honored only for magazarr/lidarr User-Agents; RSS titles are prefixed `[<XY>]` except for magazarr; enclosures use type `application/x-nzb`; a placeholder "No results found" item is returned when neither imdbid nor q is present and the search produced no items - it keeps *arr connectivity tests passing.
- The fake-NZB roundtrip: search results embed `/download/?payload=...`; `GET /download/` renders `<nzb><file title url size_mb password imdb_id source_key/></nzb>` (the free-text `title`/`url`/`password` attributes are XML-escaped via `_xml_attr`, so source-specific characters like `&` in hoster URLs or accents in French titles do not corrupt the NZB); the *arr app posts that file back via `POST /api` (multipart field `name`) or `mode=addurl`, and both paths decode the same payload and call `quasarr.downloads.download()`. The payload format is owned by `providers/utils.py` (`generate_download_link`/`parse_payload`).
- Client identity comes exclusively from the User-Agent header (`extract_client_type` and the category resolvers in `providers/utils.py`).
- A search request computes one `SEARCH_FANOUT_DEADLINE_SECONDS` deadline and passes it into every `get_search_results` call it makes. Cache-sharing categories run sequentially, so a per-call deadline would let a multi-category request take a multiple of what the *arr client waits.
- Every successful CAPTCHA flow ends in `downloads.submit_final_download_urls(..., remove_protected=True, notification_details=...)` and increments the matching `StatsHelper` counters; manual flows identify their solution method and SponsorsHelper passes solver details. Terminal submission failures increment failed counters and update the same tracked release notification. SponsorsHelper disable persists the advanced notification case/silence state so a later manual solution evaluates the next transition correctly.
- Download-category mirror-whitelist order is also the protected-link priority order: the config UI preserves and reorders that ranking, and both the manual CAPTCHA redirect and SponsorsHelper package handoff sort candidate links by it. Categories without a mirror whitelist retain the legacy Rapidgator-first fallback.
- SponsorsHelper routes use trailing slashes; most require an active helper (HTTP 402 otherwise), tracked via `helper_last_seen` with a 300s timeout; `POST to_decrypt/` requires the helper's `supported_urls` and only hands out packages matching its URL patterns or optional `supported_mirrors`.
Expand Down
11 changes: 11 additions & 0 deletions quasarr/api/arr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Quasarr
# Project by https://github.com/rix1337

import time
import traceback
import xml.sax.saxutils as sax_utils
from concurrent.futures import ThreadPoolExecutor, as_completed
Expand All @@ -11,6 +12,7 @@

from bottle import request

from quasarr.constants import SEARCH_FANOUT_DEADLINE_SECONDS
from quasarr.downloads import download
from quasarr.downloads.packages import delete_package, get_packages
from quasarr.providers import shared_state
Expand Down Expand Up @@ -365,6 +367,12 @@ def quasarr_api():
elif mode in ["movie", "tvsearch", "book", "music", "search"]:
releases = []

# One deadline for the whole request: cache-sharing categories
# run one after another, so a per-run deadline would let a
# two-category request take twice as long as the *arr client
# is willing to wait.
request_deadline = time.time() + SEARCH_FANOUT_DEADLINE_SECONDS

try:
offset = int(getattr(request.query, "offset", 0) or 0)
except (AttributeError, ValueError) as e:
Expand Down Expand Up @@ -475,6 +483,7 @@ def run_cache_group(group_categories):
episode=episode,
offset=request_offset,
limit=request_limit,
deadline=request_deadline,
)
)
)
Expand All @@ -497,6 +506,7 @@ def run_cache_group(group_categories):
search_phrase=search_phrase,
offset=request_offset,
limit=request_limit,
deadline=request_deadline,
)
)
)
Expand All @@ -516,6 +526,7 @@ def run_cache_group(group_categories):
search_phrase=search_phrase,
offset=request_offset,
limit=request_limit,
deadline=request_deadline,
)
)
)
Expand Down
6 changes: 6 additions & 0 deletions quasarr/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ def apply_timeout_slow_mode_settings(settings=None):
TIMEOUT_SLOW_MODE_DEFINITIONS["session"]["base_seconds"]
)

# Ceiling for one *arr-facing search or feed response. Radarr and Sonarr disable
# an indexer that outlives their own request timeout (100s by default), so the
# fan-out stops waiting for a slow source well before that. Deliberately not part
# of slow mode: the *arr timeout does not grow with it.
SEARCH_FANOUT_DEADLINE_SECONDS = 60

# Notification providers exposed in config/UI.
NOTIFICATION_PROVIDERS = ("discord", "telegram")

Expand Down
4 changes: 2 additions & 2 deletions quasarr/providers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ The shared-services layer consumed by every other subsystem: cross-process state
- `auth.py` - route auth modes (`public_endpoint`/`require_api_key`/browser default), Bottle auth hook, startup audit
- `myjd_api.py` - vendored, modified My.JDownloader client (MIT, third-party) - keep modifications minimal
- `jd_cache.py` - `JDPackageCache`, valid for exactly one `get_packages()`/`delete_package()` call, never reused across requests
- `imdb_metadata.py` / `xem_metadata.py` - cached metadata chains: IMDb IDs resolve basic title/year/poster and title searches through the local Radarr or Sonarr client; localized titles use locale-specific IMDb release-info HTML and preserve that cache across Arr refreshes; German localized titles are transliterated once at this provider boundary for every source; TheXEM supplies season names
- `radarr_api.py` / `sonarr_api.py` - minimal clients cached in shared_state via `set_client`/`get_client`; IMDb and free-title lookup, IMDb鈫扵MDB/TVDB resolution (`get_tmdb_id`/`get_tvdb_id`), plus library-feed seeds `get_wanted_imdb_ids` / `get_wanted_episodes` (wanted = missing + cutoff-unmet, missing first, capped at the passed `limit`, paging past filtered entries; the movie helper skips not-yet-released titles and the episode helper skips not-yet-aired ones). All return safe empties when the client is unconfigured.
- `imdb_metadata.py` / `xem_metadata.py` - cached metadata chains: IMDb IDs resolve basic title/year/poster and title searches through the local Radarr or Sonarr client; localized titles use locale-specific IMDb release-info HTML and preserve that cache across Arr refreshes; German localized titles are transliterated once at this provider boundary for every source; `get_localized_title` takes an optional caller `deadline`, rechecked at each gate because the Arr refresh between them costs its own request, and then answers from cache only; the 60s FlareSolverr stage is also skipped when the direct IMDb request used the rest of the budget; TheXEM supplies season names
- `radarr_api.py` / `sonarr_api.py` - minimal clients cached in shared_state via `set_client`/`get_client`; IMDb and free-title lookup, IMDb鈫扵MDB/TVDB resolution (`get_tmdb_id`/`get_tvdb_id`), plus library-feed seeds `get_wanted_imdb_ids` / `get_wanted_episodes` (wanted = missing + cutoff-unmet, missing first, capped at the passed `limit`, paging past filtered entries and stopping at an optional caller `deadline` and clamping each page request's timeout to what is left of it, since every page is its own *arr request - a caller timeout only ever tightens the client's own. `client.wanted()` returns `None` on a failed request so paging does not read it as the last page, and an optional `status` dict reports whether the walk completed, which callers persisting progress need; the movie helper skips not-yet-released titles and the episode helper skips not-yet-aired ones). All return safe empties when the client is unconfigured.
- `statistics.py` - DB-backed counters, constructed inline at call sites
- `version.py` - `__version__`, the single source of version truth
- `obfuscated.py` - generated obfuscated userscripts; consumed only by `api/captcha`; regenerated only through the private Quasarr-UserScripts generator repo
Expand Down
39 changes: 33 additions & 6 deletions quasarr/providers/imdb_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import html
import re
import time
from datetime import datetime, timedelta
from json import dumps, loads

Expand Down Expand Up @@ -100,7 +101,7 @@ class IMDbHTML:
}

@staticmethod
def _request(url, language):
def _request(url, language, deadline=None):
headers = {
"Accept-Language": IMDbHTML._LANGUAGE_HEADERS.get(
language, f"{language},en;q=0.8"
Expand All @@ -118,6 +119,12 @@ def _request(url, language):

# Browser fallback preserves the old AKA parsing path when direct HTML
# is unavailable. FlareSolverr cannot reliably set localization headers.
if deadline is not None and time.time() >= deadline:
# It allows 60s on its own and the caller stopped waiting, so this
# is the one stage worth giving up rather than starting.
debug("Skipped IMDb FlareSolverr fallback: caller out of time")
return None

flaresolverr_url = _get_config("FlareSolverr").get("url")
flaresolverr_skipped = _get_db("skip_flaresolverr").retrieve("skipped")

Expand Down Expand Up @@ -358,7 +365,7 @@ def _parse_localized_title(html_content, language):
return localized_page_title

@staticmethod
def get_localized_title(imdb_id, language):
def get_localized_title(imdb_id, language, deadline=None):
# The locale-specific HTML metadata is primary. The parser retains an
# AKA-section fallback for older or browser-rendered responses.
language = language.lower()
Expand All @@ -367,7 +374,7 @@ def get_localized_title(imdb_id, language):
url = f"{IMDbHTML._WEB_URL}/title/{imdb_id}/releaseinfo/"
else:
url = f"{IMDbHTML._WEB_URL}/{language}/title/{imdb_id}/releaseinfo/"
html_content = IMDbHTML._request(url, language)
html_content = IMDbHTML._request(url, language, deadline=deadline)

if html_content:
try:
Expand Down Expand Up @@ -568,7 +575,23 @@ def get_poster_link(shared_state, imdb_id, search_category=None):
return None


def get_localized_title(shared_state, imdb_id, language="de", search_category=None):
def get_localized_title(
shared_state, imdb_id, language="de", search_category=None, deadline=None
):
"""Resolve a localized title, optionally within a caller's wall-clock budget.

``deadline`` is an absolute time. Cache hits always answer; the Arr refresh
and the IMDb HTML/FlareSolverr fallbacks are skipped once it has passed,
because those cost real requests (the browser fallback alone allows 70s) and
would otherwise run long after the caller stopped waiting for them.
"""

def out_of_time():
# Re-read on every gate: the Arr refresh between them costs a request of
# its own, so a budget that was intact on entry can be gone by the time
# the far more expensive IMDb fallbacks would start.
return deadline is not None and time.time() >= deadline

imdb_metadata = _get_cached_metadata(imdb_id)
cache_is_fresh = bool(
imdb_metadata and imdb_metadata.get("ttl", 0) > datetime.now().timestamp()
Expand All @@ -577,15 +600,19 @@ def get_localized_title(shared_state, imdb_id, language="de", search_category=No
localized = imdb_metadata.get("localized", {}).get(language)
if localized:
return _normalize_localized_title(localized, language)
else:
elif not out_of_time():
imdb_metadata, arr_localized = _refresh_imdb_metadata(
shared_state, imdb_id, search_category, imdb_metadata
)
localized = arr_localized.get(language)
if localized:
return _normalize_localized_title(localized, language)

title = IMDbHTML.get_localized_title(imdb_id, language)
if out_of_time():
debug(f"Skipped localized-title lookup for {imdb_id}: caller out of time")
return None
Comment thread
rix1337 marked this conversation as resolved.

title = IMDbHTML.get_localized_title(imdb_id, language, deadline=deadline)
if title:
sanitized_title = TitleCleaner.sanitize(title)
_update_cache(imdb_id, "localized", sanitized_title, language)
Expand Down
56 changes: 44 additions & 12 deletions quasarr/providers/radarr_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# Quasarr
# Project by https://github.com/rix1337

import time

import requests

from quasarr.providers.log import error, trace, warn
Expand Down Expand Up @@ -34,15 +36,21 @@ def __init__(self, base_url, api_key, timeout=10):
self._api_key = api_key
self._timeout = timeout

def _get(self, path, params=None):
def _get(self, path, params=None, timeout=None):
# A caller timeout only ever tightens the client's own: it says how much
# of its budget is left, not that this request may take longer.
timeout = min(self._timeout, timeout) if timeout else self._timeout
url = f"{self._base_url}/api/v3{path}"
headers = {
"X-Api-Key": self._api_key,
"Accept": "application/json",
}
try:
response = requests.get(
url, headers=headers, params=params, timeout=self._timeout
url,
headers=headers,
params=params,
timeout=timeout,
)
response.raise_for_status()
return response.json()
Expand All @@ -66,14 +74,16 @@ def movie_lookup(self, term):
return []
return self._get("/movie/lookup", params={"term": term}) or []

def wanted(self, kind, page=1, page_size=50):
"""Return a wanted movies page (``kind`` is ``missing`` or ``cutoff``)."""
return (
self._get(
f"/wanted/{kind}",
params={"page": page, "pageSize": page_size, "monitored": "true"},
)
or {}
def wanted(self, kind, page=1, page_size=50, timeout=None):
"""Return a wanted movies page (``kind`` is ``missing`` or ``cutoff``).

``None`` means the request failed. A caller walking pages must not read
that as "no more pages".
"""
return self._get(
f"/wanted/{kind}",
params={"page": page, "pageSize": page_size, "monitored": "true"},
timeout=timeout,
)


Expand Down Expand Up @@ -108,7 +118,7 @@ def get_tmdb_id(shared_state, imdb_id):
_WANTED_MAX_PAGES = 5


def get_wanted_imdb_ids(shared_state, limit=50):
def get_wanted_imdb_ids(shared_state, limit=50, deadline=None, status=None):
"""Return IMDb IDs of monitored movies Radarr wants as a list.

Covers both missing movies (no file) and cutoff-unmet ones (present but
Expand All @@ -119,6 +129,11 @@ def get_wanted_imdb_ids(shared_state, limit=50):
still yields released ones instead of an empty seed. Empty when Radarr is
not configured or the request fails.
"""
if status is not None:
# Callers that persist progress across runs need to know whether this is
# the whole wanted list or as far as paging got.
status["complete"] = False

client = get_client(shared_state)
if client is None:
return []
Expand All @@ -129,7 +144,20 @@ def get_wanted_imdb_ids(shared_state, limit=50):
for page in range(1, _WANTED_MAX_PAGES + 1):
if len(imdb_ids) >= limit:
return imdb_ids
records = client.wanted(kind, page=page, page_size=limit).get("records", [])
# Every page is its own Radarr request, so a slow instance must not
# spend a caller's whole budget before it gets any seeds - and the
# last page before the deadline must not overrun it either.
page_timeout = None
if deadline is not None:
page_timeout = deadline - time.time()
if page_timeout <= 0:
return imdb_ids
page_data = client.wanted(
kind, page=page, page_size=limit, timeout=page_timeout
)
if page_data is None:
return imdb_ids # request failed: what we have is partial
records = page_data.get("records", [])
if not records:
break # no more pages for this kind
for movie in records:
Expand All @@ -141,6 +169,10 @@ def get_wanted_imdb_ids(shared_state, limit=50):
seen.add(imdb_id)
imdb_ids.append(imdb_id)
if len(imdb_ids) >= limit:
if status is not None:
status["complete"] = True
return imdb_ids

if status is not None:
status["complete"] = True
return imdb_ids
Loading
Loading