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/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 7cc8a98..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 @@ -46,28 +47,44 @@ 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: - 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: 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.info("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 +115,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 +137,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")