"""Minimal qBittorrent Web API client (read-only: sync/maindata only). Modeled on :class:`~media_library_viewer_api.clients.jellyfin.JellyfinClient`'s session pattern. Authentication uses username/password login which stores an SID cookie in the requests session. The client re-logins transparently on 403. """ from __future__ import annotations import logging import threading import time from typing import Any import requests from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout logger = logging.getLogger(__name__) # qBittorrent's built-in web server is effectively single-threaded; collapse # concurrent widget polls onto one fetch and back off when it struggles. MAINDATA_CACHE_TTL = 3.0 # seconds a snapshot is served without re-hitting qBittorrent MAINDATA_BACKOFF_MAX = 30.0 # cap exponential backoff after repeated failures class QbittorrentClient: """Small wrapper around the qBittorrent Web API. Only the endpoints needed by the dashboard widgets are implemented (currently just ``/sync/maindata``). All calls share a single :class:`requests.Session` that carries the login cookie. """ def __init__(self, base_url: str, username: str, password: str, timeout: float = DEFAULT_READ_TIMEOUT) -> None: if not base_url: raise ValueError("qBittorrent base_url is required") if not username: raise ValueError("qBittorrent username is required") self.base_url = base_url.rstrip("/") if not self.base_url.endswith("/api/v2"): self.base_url += "/api/v2" self._username = username self._password = password # timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s. self.timeout = http_timeout(timeout) self._session = requests.Session() self._logged_in = False # /sync/maindata is the only hot endpoint. Maintain a rid-merged # snapshot (incremental updates -> small payloads), a short-TTL cache # + lock so concurrent widgets share one fetch, and back off when # qBittorrent is struggling rather than piling on (its web server is # single-threaded and otherwise hangs the Web UI for everyone). self._rid: int | None = None self._snapshot: dict[str, Any] = { "server_state": {}, "torrents": {}, "categories": {}, "tags": [], "trackers": [], } self._maindata_lock = threading.Lock() self._maindata_fetched_at: float = 0.0 self._maindata_ttl: float = MAINDATA_CACHE_TTL self._backoff_until: float = 0.0 self._consecutive_failures = 0 def _login(self) -> None: """POST username/password to ``/auth/login``; store the SID cookie. qBittorrent replies with the plain text ``"Ok."`` and a ``SID`` cookie on success, ``"Fails."`` on bad credentials, and ``403 Forbidden`` when the source IP is banned (too many failed attempts). The ``Referer`` header is required by qBittorrent's CSRF protection. Any other body — in particular an *empty* 200 — means the request did not reach qBittorrent's login handler, almost always because ``base_url`` is wrong (wrong host/port/path) or a reverse proxy is misrouting ``/api/v2/auth/login``. We surface a diagnostic error in that case instead of the useless ``"login failed: "`` message. """ resp = self._session.post( f"{self.base_url}/auth/login", data={"username": self._username, "password": self._password}, timeout=self.timeout, headers={"Referer": self.base_url}, ) # 502/503/504 come from the reverse proxy when qBittorrent is down, # starting up, or can't answer within the proxy's forwarding timeout # (qBittorrent's PBKDF2 password check is intentionally slow, so a # flood of concurrent logins can trip this). Surface it clearly rather # than as a bare HTTPError. if resp.status_code in (502, 503, 504): raise RuntimeError( f"qBittorrent is unreachable: reverse proxy returned HTTP {resp.status_code} " f"for {resp.url}. qBittorrent may be down, starting up, or unable to " "answer within the proxy's forwarding timeout." ) resp.raise_for_status() body = resp.text.strip() # qBittorrent signals a successful login with the body "Ok." and/or by # setting a session cookie. The cookie is named "SID" in older versions # and "QBT_SID" / "QBT_SID_" in newer ones. Some setups return 204 # No Content with the cookie and no body, and ``requests`` doesn't always # populate the cookie jar, so check both the jar and the raw Set-Cookie # header. qBittorrent only sets this cookie on a valid login. def _is_session_cookie(name: str) -> bool: upper = name.strip().upper() return upper == "SID" or upper.startswith("QBT_SID") set_cookie_hdr = resp.headers.get("Set-Cookie", "") or "" first_cookie_name = set_cookie_hdr.split("=", 1)[0].strip() sid_ok = any(_is_session_cookie(k) for k in resp.cookies.keys()) or ( bool(first_cookie_name) and _is_session_cookie(first_cookie_name) ) if body == "Ok." or sid_ok: self._logged_in = True logger.info("qBittorrent login successful for %s", self.base_url) return if body == "Fails.": raise RuntimeError(f"qBittorrent login failed (HTTP {resp.status_code}): invalid username or password") cookie_names = sorted(resp.cookies.keys()) or ([""] if set_cookie_hdr else []) raise RuntimeError( f"Unexpected response from qBittorrent login endpoint (HTTP {resp.status_code}, " f"body={body!r}, cookies={cookie_names}). Expected the text 'Ok.' or a session " "cookie (SID / QBT_SID) from /api/v2/auth/login — this usually means base_url does " "not reach the qBittorrent Web API (check the URL, path, and any reverse proxy in " "front of qBittorrent)." ) def _get(self, path: str, **params: Any) -> dict[str, Any]: """GET an endpoint with auto-login on first call and re-login on 403.""" if not self._logged_in: self._login() url = f"{self.base_url}{path}" resp = self._session.get(url, params=params, timeout=self.timeout) if resp.status_code == 403: logger.debug("qBittorrent 403 on %s, re-logging in", path) self._logged_in = False self._login() resp = self._session.get(url, params=params, timeout=self.timeout) resp.raise_for_status() return resp.json() def maindata(self) -> dict[str, Any]: """Return the current ``/sync/maindata`` snapshot. Uses qBittorrent's incremental ``rid`` protocol (first call is a full update, subsequent calls send the last rid and get a small diff that is merged into the cached snapshot), so payloads stay small. A short-TTL cache + lock collapses concurrent widget polls onto a single fetch, and on repeated failures the client backs off instead of hammering qBittorrent's single-threaded web server (serving the last good snapshot when available). Returns a dict with ``server_state`` and ``torrents``. """ now = time.time() with self._maindata_lock: # Serve a fresh-enough cached snapshot without re-hitting qBittorrent. if self._snapshot.get("torrents") and (now - self._maindata_fetched_at) < self._maindata_ttl: return self._copy_snapshot() # While backing off, don't pile on; serve stale or raise. if now < self._backoff_until: if self._snapshot.get("torrents"): return self._copy_snapshot() raise RuntimeError( "qBittorrent maindata unavailable (backing off after repeated failures)" ) try: update = self._fetch_maindata_incremental() self._apply_update(update) except Exception as exc: self._consecutive_failures += 1 delay = min(2 ** self._consecutive_failures, MAINDATA_BACKOFF_MAX) self._backoff_until = time.time() + delay logger.warning( "qBittorrent maindata fetch failed (#%s); backing off %.0fs: %s", self._consecutive_failures, delay, exc, ) if self._snapshot.get("torrents"): return self._copy_snapshot() raise RuntimeError(f"qBittorrent maindata failed: {exc}") from exc self._maindata_fetched_at = time.time() self._consecutive_failures = 0 self._backoff_until = 0.0 return self._copy_snapshot() def _fetch_maindata_incremental(self) -> dict[str, Any]: """GET /sync/maindata, sending the last rid for an incremental update.""" params: dict[str, Any] = {} if self._rid is not None: params["rid"] = self._rid return self._get("/sync/maindata", **params) def _apply_update(self, update: dict[str, Any]) -> None: """Merge a full or partial maindata update into the cached snapshot.""" is_full = bool(update.get("full_update")) or self._rid is None self._rid = update.get("rid", self._rid) snap = self._snapshot if is_full: snap.clear() snap["server_state"] = dict(update.get("server_state") or {}) snap["torrents"] = dict(update.get("torrents") or {}) snap["categories"] = dict(update.get("categories") or {}) snap["tags"] = list(update.get("tags") or []) snap["trackers"] = list(update.get("trackers") or []) return # Partial update — merge the diff. server_state = update.get("server_state") if isinstance(server_state, dict): snap["server_state"].update(server_state) changed = update.get("torrents") if isinstance(changed, dict): for hash_, fields in changed.items(): if fields is None: snap["torrents"].pop(hash_, None) else: previous = snap["torrents"].get(hash_) snap["torrents"][hash_] = ( {**previous, **fields} if isinstance(previous, dict) and isinstance(fields, dict) else fields ) for hash_ in update.get("torrents_removed") or []: snap["torrents"].pop(hash_, None) categories = update.get("categories") if isinstance(categories, dict): snap["categories"].update(categories) for name in update.get("categories_removed") or []: snap["categories"].pop(name, None) if "tags" in update: snap["tags"] = list(update.get("tags") or []) if "trackers" in update: snap["trackers"] = list(update.get("trackers") or []) def _copy_snapshot(self) -> dict[str, Any]: """Return a shallow, race-safe copy of the current snapshot.""" snap = self._snapshot return { "rid": self._rid, "server_state": dict(snap.get("server_state") or {}), "torrents": dict(snap.get("torrents") or {}), "categories": dict(snap.get("categories") or {}), "tags": list(snap.get("tags") or []), "trackers": list(snap.get("trackers") or []), }