Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions backend/app/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions backend/app/clients/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 38 additions & 27 deletions backend/app/clients/qbittorrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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")

Expand Down