From 023c2dcf61340710aa002a218c65ab156f1828a6 Mon Sep 17 00:00:00 2001 From: Corey E <24890651+coreyeaston@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:43:50 +1000 Subject: [PATCH 1/2] fix: qBittorrent/SABnzbd test_connection crash and 403 session recovery test_connection for qBittorrent and SABnzbd accessed legacy config fields (app_config.qbittorrent/sabnzbd) which are None when using the multi-client download_clients system, causing "'NoneType' object has no attribute 'password'" crashes. Updated both to use _find_existing_client() matching the pattern already used by NZBGet, Transmission, and Deluge. Added _request() wrapper to qBittorrent client that automatically re-authenticates on 403 responses, preventing persistent auth failures after session expiry. Replaced FormData with plain dicts for retry safety and added raise_for_status() calls for clearer error messages. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/settings.py | 38 ++++++++++++++------ backend/app/clients/qbittorrent.py | 56 +++++++++++++++++------------- 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index d618c1f..e64419b 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -321,13 +321,25 @@ async def test_connection( elif service == "qbittorrent": from app.clients.qbittorrent import QBittorrentClient - url = config_data.get("url") or app_config.qbittorrent.url - username = config_data.get("username") or app_config.qbittorrent.username - password = config_data.get("password") + url = config_data.get("url", "") + username = config_data.get("username", "") + password = config_data.get("password", "") - # If use_existing or password is masked, use the saved config + # Get existing client config for use_existing if test_request.use_existing or password == "***REDACTED***": - password = app_config.qbittorrent.password + existing = _find_existing_client(app_config, config_data.get("id"), "qbittorrent") + if existing: + if not url: + url = existing.url + if not username: + username = existing.username + if existing.password and password in ("", "***REDACTED***"): + password = existing.password + elif password == "***REDACTED***": + return TestConnectionResponse( + success=False, + message="No qBittorrent password configured. Please enter a password.", + ) if not url or not username or not password: return TestConnectionResponse( @@ -352,14 +364,18 @@ async def test_connection( elif service == "sabnzbd": from app.clients.sabnzbd import SABnzbdClient - url = config_data.get("url") or app_config.sabnzbd.url - api_key = config_data.get("api_key") + url = config_data.get("url", "") + api_key = config_data.get("api_key", "") - # If use_existing or api_key is masked, use the saved config + # Get existing client config for use_existing if test_request.use_existing or api_key == "***REDACTED***": - if app_config.sabnzbd.api_key: - api_key = app_config.sabnzbd.api_key - else: + existing = _find_existing_client(app_config, config_data.get("id"), "sabnzbd") + if existing: + if not url: + url = existing.url + if existing.api_key and api_key in ("", "***REDACTED***"): + api_key = existing.api_key + elif api_key == "***REDACTED***": return TestConnectionResponse( success=False, message="No SABnzbd API key configured. Please enter an API key.", diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index 7cc8a98..2092927 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -46,9 +46,7 @@ async def _ensure_authenticated(self): return try: - data = aiohttp.FormData() - data.add_field("username", self.username) - data.add_field("password", self.password) + data = {"username": self.username, "password": self.password} async with self.session.post(f"{self.url}/api/v2/auth/login", data=data) as response: if response.status == 200: @@ -60,14 +58,28 @@ async def _ensure_authenticated(self): logger.error(f"Failed to authenticate with qBittorrent: {e}") raise - async def get_stats(self) -> Dict[str, Any]: - """Get current transfer statistics.""" + async def _request(self, method: str, endpoint: str, retry_on_auth_failure: bool = True, **kwargs): + """Make HTTP request with automatic re-authentication on 403.""" await self._ensure_authenticated() + url = f"{self.url}{endpoint}" + response = await self.session.request(method, url, **kwargs) + + if response.status == 403 and retry_on_auth_failure: + await response.release() + logger.debug("qBittorrent returned 403, re-authenticating...") + self._authenticated = False + await self._ensure_authenticated() + response = await self.session.request(method, url, **kwargs) + + return response + async def get_stats(self) -> Dict[str, Any]: + """Get current transfer statistics.""" try: # Get transfer info - async with self.session.get(f"{self.url}/api/v2/transfer/info") as response: - transfer_info = await response.json() + response = await self._request("GET", "/api/v2/transfer/info") + response.raise_for_status() + transfer_info = await response.json() # Determine if actually downloading based on speed, not torrent state # A torrent can be in "downloading" state but stalled with no data transfer @@ -98,16 +110,16 @@ async def get_stats(self) -> Dict[str, Any]: async def get_speed_limits(self) -> Dict[str, float]: """Get current speed limits in Mbps.""" - await self._ensure_authenticated() - try: - async with self.session.get(f"{self.url}/api/v2/transfer/downloadLimit") as response: - dl_limit_text = await response.text() - dl_limit_bytes = int(dl_limit_text.strip()) + response = await self._request("GET", "/api/v2/transfer/downloadLimit") + response.raise_for_status() + dl_limit_text = await response.text() + dl_limit_bytes = int(dl_limit_text.strip()) - async with self.session.get(f"{self.url}/api/v2/transfer/uploadLimit") as response: - ul_limit_text = await response.text() - ul_limit_bytes = int(ul_limit_text.strip()) + response = await self._request("GET", "/api/v2/transfer/uploadLimit") + response.raise_for_status() + ul_limit_text = await response.text() + ul_limit_bytes = int(ul_limit_text.strip()) # Convert bytes/sec to Mbps (0 means unlimited in qBit) return { @@ -120,23 +132,17 @@ async def get_speed_limits(self) -> Dict[str, float]: async def set_speed_limits(self, download_limit: Optional[float] = None, upload_limit: Optional[float] = None): """Set speed limits in Mbps.""" - await self._ensure_authenticated() - try: if download_limit is not None: # Convert Mbps to bytes/second limit_bytes = int(download_limit * 1_048_576 / 8) - data = aiohttp.FormData() - data.add_field("limit", str(limit_bytes)) - async with self.session.post(f"{self.url}/api/v2/transfer/setDownloadLimit", data=data) as response: - response.raise_for_status() + response = await self._request("POST", "/api/v2/transfer/setDownloadLimit", data={"limit": str(limit_bytes)}) + response.raise_for_status() if upload_limit is not None: limit_bytes = int(upload_limit * 1_048_576 / 8) - data = aiohttp.FormData() - data.add_field("limit", str(limit_bytes)) - async with self.session.post(f"{self.url}/api/v2/transfer/setUploadLimit", data=data) as response: - response.raise_for_status() + response = await self._request("POST", "/api/v2/transfer/setUploadLimit", data={"limit": str(limit_bytes)}) + response.raise_for_status() logger.debug(f"Set qBittorrent limits: DL={download_limit} Mbps, UL={upload_limit} Mbps") From 31aa73a3616a9b12406cad3d2f2b5bbe1d45c752 Mon Sep 17 00:00:00 2001 From: Corey E <24890651+coreyeaston@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:06:23 +1000 Subject: [PATCH 2/2] fix: use unsafe cookie jar for IP-based client URLs and validate qBittorrent auth response body aiohttp's default CookieJar silently rejects cookies from IP addresses, causing qBittorrent sessions at IP-based URLs to lose their SID cookie on every request (persistent 403 loop). Use CookieJar(unsafe=True) in both the qBittorrent client and base client class. Also validate the login response body for "Ok." since qBittorrent returns HTTP 200 for both success and failure, and upgrade 403 re-auth log to info. Co-Authored-By: Claude Opus 4.6 --- backend/app/clients/base.py | 1 + backend/app/clients/qbittorrent.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 3ce2959..5ac83c1 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -22,6 +22,7 @@ def session(self) -> aiohttp.ClientSession: """Get or create aiohttp session.""" if self._session is None or self._session.closed: self._session = aiohttp.ClientSession( + cookie_jar=aiohttp.CookieJar(unsafe=True), timeout=aiohttp.ClientTimeout(total=2) ) return self._session diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index 2092927..9bcd719 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -22,6 +22,7 @@ def session(self) -> aiohttp.ClientSession: """Get or create aiohttp session.""" if self._session is None or self._session.closed: self._session = aiohttp.ClientSession( + cookie_jar=aiohttp.CookieJar(unsafe=True), timeout=aiohttp.ClientTimeout(total=2) ) return self._session @@ -50,8 +51,12 @@ async def _ensure_authenticated(self): async with self.session.post(f"{self.url}/api/v2/auth/login", data=data) as response: if response.status == 200: - self._authenticated = True - logger.debug("Authenticated with qBittorrent") + text = await response.text() + if text.strip() == "Ok.": + self._authenticated = True + logger.debug("Authenticated with qBittorrent") + else: + raise Exception("qBittorrent login rejected (credentials may be wrong)") else: raise Exception(f"Authentication failed with status {response.status}") except Exception as e: @@ -66,7 +71,7 @@ async def _request(self, method: str, endpoint: str, retry_on_auth_failure: bool if response.status == 403 and retry_on_auth_failure: await response.release() - logger.debug("qBittorrent returned 403, re-authenticating...") + logger.info("qBittorrent returned 403, re-authenticating...") self._authenticated = False await self._ensure_authenticated() response = await self.session.request(method, url, **kwargs)