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:
@@ -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", {})
|
||||
|
||||
Reference in New Issue
Block a user