"""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 from typing import Any import requests logger = logging.getLogger(__name__) 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: int = 10) -> 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 self.timeout = timeout self._session = requests.Session() self._logged_in = False def _login(self) -> None: """POST username/password to ``/auth/login``; store the SID cookie. qBittorrent returns the plain text ``"Ok."`` on success. The ``Referer`` header is required by some qBittorrent CSRF protections. """ 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}, ) resp.raise_for_status() if resp.text.strip() != "Ok.": raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}") self._logged_in = True logger.info("qBittorrent login successful for %s", self.base_url) 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]: """Fetch ``/sync/maindata``. Returns a dict with ``server_state`` (containing ``dl_info_speed``, ``up_info_speed``, etc.) and ``torrents`` (a dict of ``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``). """ return self._get("/sync/maindata")