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/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", {})