fix(qbittorrent): reuse authenticated client across widget fetches

QbittorrentWidgetSource built a brand-new QbittorrentClient on every fetch,
logging in each time. With three qBittorrent widgets polling every 5-30s and
qBittorrent verifying passwords with slow PBKDF2 hashing, the concurrent login
load saturates its web thread pool and the reverse proxy returns 504 gateway
timeouts on /api/v2/auth/login. The client was already designed for reuse
(login once, SID cookie reuse, 403 re-login) — the source just wasn't using it.

Cache one QbittorrentClient per service (lru_cache keyed by service id, URL,
credentials, timeout) so the SID cookie persists across fetches and login
happens once. Mirrors dependencies._jellyfin_client_for. A credentials/URL
change produces a new cache key, so stale clients aren't reused after
reconfiguration.

Also surface 502/503/504 from the login as a clear "reverse proxy returned
HTTP <code> ... qBittorrent may be down/starting/overloaded" RuntimeError
instead of a bare HTTPError, so future gateway issues read as infrastructure,
not auth.

Tests: autouse fixture clears the client cache between tests; new gateway-error
login test. 385/385 backend tests pass; ruff clean.
This commit is contained in:
Developer
2026-07-11 12:19:19 +00:00
parent dad2202756
commit b011d2421b
8 changed files with 78 additions and 15 deletions
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/clients
## role
Provides external service integration layer with read-only API client wrappers and remote/local execution helpers for aggregating data from media, directory, torrent, and authentication services.
Provides external service integration clients (API wrappers and command executors) for media servers, identity providers, torrent clients, and remote/local filesystems.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -4,20 +4,20 @@ dir: backend/src/media_library_viewer_api/clients
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
## role
Provides external service integration layer with read-only API client wrappers and remote/local execution helpers for aggregating data from media, directory, torrent, and authentication services.
Provides external service integration clients (API wrappers and command executors) for media servers, identity providers, torrent clients, and remote/local filesystems.
## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- authentik.py | API client wrapper for Authentik directory service providing paginated user browsing and search via REST API. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- http_timeout.py | Builds decoupled (connect, read) timeout tuples for the `requests` library to allow short connect times with generous read budgets. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float
- http_timeout.py | Provides a helper function to build decoupled (connect, read) timeout tuples for the `requests` library, allowing different timeout budgets for connection and read phases. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float
- jellyfin.py | Wraps the Jellyfin/Emby HTTP API to provide methods for fetching users, libraries, media items, playback sessions, and image URLs as plain Python dictionaries. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:resolve_user_id(self, identifier: str | None) → str, call:self.users, call:any, call:str, call:u.get, call:next, call:logger.info, call:logger.warning, raise:RuntimeError, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- jellyseerr.py | HTTP API client wrapper for Jellyseerr that fetches user data and metadata to enrich Jellyfin user lists. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- jellyseerr.py | HTTP API client wrapper for Jellyseerr to fetch user data and enrich Jellyfin user lists. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
- qbittorrent.py | Provides a minimal read-only API client for fetching sync data from a qBittorrent Web API with transparent re-authentication. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:bool, call:resp.cookies.get, call:logger.info, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:self._get | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- qbittorrent.py | Minimal read-only API client for qBittorrent that handles authenticated session management to fetch sync data. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:bool, call:resp.cookies.get, call:logger.info, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:self._get | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
## arch
Collection of decoupled HTTP client wrappers using the `requests` library with shared timeout configuration, alongside a command-execution abstraction (SSH/local) that unifies remote and local filesystem operations under a common interface.
Adapter/wrapper pattern with each client encapsulating service-specific communication (HTTP REST, SSH, local shell) behind a uniform interface returning plain Python data structures.
## tags
call:logger.info, error, call:logger.debug, call:self.get, client, init, call:logger.warning, call:shlex.quote
call:logger.info, error, call:logger.debug, call:self.get, client, init, timeout, call:logger.warning
## symbols
- AuthentikClient
- JellyfinClient
@@ -61,6 +61,17 @@ class QbittorrentClient:
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 login failed: 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()
# Some reverse proxies forward the SID cookie but mangle the text body;
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/widgets
## role
Widget subsystem providing configurable dashboard widget definitions, source adapters, and data transformation helpers for the media library viewer API.
Provides widget data adapters and configuration definitions that fetch, normalize, and validate content from both built-in and external services for dashboard display.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
File diff suppressed because one or more lines are too long
@@ -14,6 +14,7 @@ import asyncio
import logging
import time
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Any, Protocol
import requests
@@ -386,6 +387,20 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
logger.exception("failed to record ssh task timeout")
@lru_cache(maxsize=16)
def _qbittorrent_client(cache_key: tuple[str, str, str, str, int]) -> QbittorrentClient:
"""Build (or reuse) a qBittorrent client for a service.
Cached per (service_id, base_url, username, password, timeout) so the
authenticated session/SID cookie persists across widget fetches. A
credentials or URL change produces a new cache key, so stale clients are
not reused after reconfiguration. Mirrors the Jellyfin client cache in
dependencies._jellyfin_client_for.
"""
_service_id, base_url, username, password, timeout = cache_key
return QbittorrentClient(base_url, username, password, timeout=timeout)
class QbittorrentWidgetSource:
"""Fetch qBittorrent data for totals, active, and speed widgets."""
@@ -400,7 +415,13 @@ class QbittorrentWidgetSource:
if not base_url or not username or not password:
return {"error": "qBittorrent service is missing base_url, username, or password"}
client = QbittorrentClient(base_url, username, password, timeout)
# Reuse one authenticated client per service so the SID cookie
# persists across fetches and we don't re-login on every widget
# poll. qBittorrent verifies passwords with slow PBKDF2 hashing,
# so logging in on every fetch (3 widgets x frequent polls)
# saturates its web thread pool and the reverse proxy returns 504
# gateway timeouts. The client re-logins itself on a 403.
client = _qbittorrent_client((service.id, base_url, username, password, timeout))
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
server_state = data.get("server_state", {})
torrents = data.get("torrents", {})
+15
View File
@@ -174,6 +174,21 @@ class QbittorrentClientTests(unittest.TestCase):
self.assertIn("HTTP 200", message)
self.assertIn("base_url", message)
def test_login_gateway_error_is_diagnostic(self) -> None:
"""A 502/503/504 from the reverse proxy surfaces a clear gateway message."""
resp = MagicMock()
resp.status_code = 504
resp.text = ""
resp.cookies = {}
self.session.post.return_value = resp
with self.assertRaises(RuntimeError) as ctx:
self.client._login()
message = str(ctx.exception)
self.assertIn("HTTP 504", message)
self.assertIn("reverse proxy", message.lower())
if __name__ == "__main__":
unittest.main()
+16
View File
@@ -30,6 +30,22 @@ def _encryption_key(monkeypatch):
yield
@pytest.fixture(autouse=True)
def _clear_qbittorrent_client_cache():
"""Reset the per-service qBittorrent client cache between tests.
QbittorrentWidgetSource reuses one authenticated client per service
(lru_cache) so the SID cookie persists across fetches. Without clearing,
a mock client cached by one test would leak into later tests that patch
QbittorrentClient differently.
"""
from media_library_viewer_api.widgets.sources import _qbittorrent_client
_qbittorrent_client.cache_clear()
yield
_qbittorrent_client.cache_clear()
@pytest.fixture
def client(tmp_path):
"""FastAPI test client with a fresh settings store and auth disabled."""