soulsync/core/torrent_clients/qbittorrent.py
BoulderBadgeDad b5d22bede5 Fix #790: torrent client URL without http:// scheme fails to connect
A bare host like '192.168.1.5:8080' or 'qbittorrent.lan:8080' (no scheme)
is what users naturally type, but requests then raises 'No connection
adapters were found for ...' — it can't pick an http/https adapter, and a
bare host:port even gets misparsed as scheme=host. This surfaced as the
generic 'qbittorrent probe failed' with a 'login error: No connection
adapters were found' in the logs.

Add normalize_client_url() in torrent_clients/base: default a missing scheme
to http:// (+ trim trailing slash), and route all three adapters'
_load_config through it. Transmission normalizes the base before appending
/transmission/rpc.

Tests: normalizer unit cases + per-adapter regression (bare host -> http://).

Note: usenet adapters (sabnzbd/nzbget) share the same pattern and need the
same treatment in a follow-up.
2026-06-04 11:57:53 -07:00

337 lines
14 KiB
Python

"""qBittorrent WebUI v2 adapter.
Auth model: POST ``/api/v2/auth/login`` with form-encoded
``username`` + ``password`` returns a ``SID`` cookie that's required
on every subsequent call. The cookie lives on a ``requests.Session``
maintained by this adapter; we lazily re-login on 403 in case the
server expired the session.
Reference: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)
"""
from __future__ import annotations
import asyncio
import threading
from typing import List, Optional
import requests as http_requests
from config.settings import config_manager
from core.torrent_clients.base import TorrentStatus, normalize_client_url
from utils.logging_config import get_logger
logger = get_logger("torrent.qbittorrent")
# qBittorrent's native state strings. Mapped onto the adapter-uniform
# set in ``_map_state``. See https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#torrent-list
_QBIT_STATE_MAP = {
"allocating": "queued",
"checkingDL": "queued",
"checkingUP": "seeding",
"checkingResumeData": "queued",
"downloading": "downloading",
"error": "error",
"forcedDL": "downloading",
"forcedUP": "seeding",
"metaDL": "downloading",
"missingFiles": "error",
"moving": "queued",
"pausedDL": "paused",
"pausedUP": "completed",
"queuedDL": "queued",
"queuedUP": "queued",
"stalledDL": "stalled",
"stalledUP": "seeding",
"uploading": "seeding",
"unknown": "error",
}
def _map_state(qbit_state: str) -> str:
return _QBIT_STATE_MAP.get(qbit_state, "error")
class QBittorrentAdapter:
"""qBittorrent WebUI v2 adapter."""
DEFAULT_TIMEOUT = 15
def __init__(self) -> None:
self._session: Optional[http_requests.Session] = None
self._session_lock = threading.Lock()
self._load_config()
def _load_config(self) -> None:
self._url = normalize_client_url(config_manager.get('torrent_client.url', ''))
self._username = config_manager.get('torrent_client.username', '') or ''
self._password = config_manager.get('torrent_client.password', '') or ''
self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync'
self._save_path = config_manager.get('torrent_client.save_path', '') or ''
# Drop any existing session — credentials may have changed.
with self._session_lock:
self._session = None
def reload_settings(self) -> None:
self._load_config()
def is_configured(self) -> bool:
# qBittorrent allows no-auth setups (LAN), so credentials are
# optional — URL is the only hard requirement.
return bool(self._url)
async def check_connection(self) -> bool:
if not self.is_configured():
return False
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._check_connection_sync)
def _check_connection_sync(self) -> bool:
try:
sess = self._ensure_session_sync()
if sess is None:
return False
resp = sess.get(f"{self._url}/api/v2/app/version", timeout=self.DEFAULT_TIMEOUT)
return resp.ok
except Exception as e:
logger.error("qBittorrent connection probe failed: %s", e)
return False
def _ensure_session_sync(self) -> Optional[http_requests.Session]:
with self._session_lock:
if self._session is not None:
return self._session
sess = http_requests.Session()
# No-auth setup — skip login.
if not self._username and not self._password:
self._session = sess
return sess
try:
resp = sess.post(
f"{self._url}/api/v2/auth/login",
data={'username': self._username, 'password': self._password},
# qBittorrent rejects login attempts that arrive without a
# Referer matching its configured host (CSRF guard). Sending
# the WebUI's own URL satisfies the check.
headers={'Referer': self._url},
timeout=self.DEFAULT_TIMEOUT,
)
body = resp.text.strip()
has_sid = bool(sess.cookies.get('SID'))
# qBittorrent reports BAD credentials as HTTP 200 + body "Fails."
# (it does NOT use a 4xx). SUCCESS is the SID auth cookie and/or a
# success body: "Ok." on <= 5.1, or an empty HTTP 204 on 5.2.0+,
# which changed /api/v2/auth/login to return 204 No Content.
# The old check required body == "Ok." and so rejected 5.2.0+.
login_ok = (
resp.ok
and body.lower() != 'fails.'
and (has_sid or resp.status_code == 204 or body in ('', 'Ok.'))
)
if not login_ok:
logger.error("qBittorrent login failed: HTTP %s body=%r", resp.status_code, resp.text[:200])
return None
self._session = sess
return sess
except Exception as e:
logger.error("qBittorrent login error: %s", e)
return None
def _call(self, method: str, path: str, **kwargs) -> Optional[http_requests.Response]:
sess = self._ensure_session_sync()
if sess is None:
return None
try:
kwargs.setdefault('timeout', self.DEFAULT_TIMEOUT)
kwargs.setdefault('headers', {}).setdefault('Referer', self._url)
resp = sess.request(method, f"{self._url}{path}", **kwargs)
# Session expired — try one re-login and retry.
if resp.status_code == 403:
with self._session_lock:
self._session = None
sess = self._ensure_session_sync()
if sess is None:
return None
resp = sess.request(method, f"{self._url}{path}", **kwargs)
return resp
except Exception as e:
logger.error("qBittorrent %s %s failed: %s", method, path, e)
return None
async def add_torrent(
self,
url_or_magnet: str,
category: str = "soulsync",
save_path: Optional[str] = None,
) -> Optional[str]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, self._add_torrent_sync, url_or_magnet, category, save_path
)
def _add_torrent_sync(
self,
url_or_magnet: str,
category: str,
save_path: Optional[str],
) -> Optional[str]:
cat = category or self._category
# Snapshot the current set of torrent hashes BEFORE adding —
# qBittorrent's /add endpoint returns 200 "Ok." regardless of
# whether the URL was actually accepted or registered, and
# category-filtered lookups race the add (qBit hasn't
# categorised the new torrent yet on the first poll). Diffing
# before / after is the only reliable way to recover the hash.
before = self._all_hashes()
if before is None:
return None
data = {'urls': url_or_magnet, 'category': cat}
if save_path or self._save_path:
data['savepath'] = save_path or self._save_path
resp = self._call('POST', '/api/v2/torrents/add', data=data)
if not resp or not resp.ok:
logger.warning("qBittorrent /torrents/add returned HTTP %s body=%r",
resp.status_code if resp else 'no-response',
(resp.text[:200] if resp else ''))
return None
if resp.text and resp.text.strip() and resp.text.strip() != 'Ok.':
logger.warning("qBittorrent /torrents/add unexpected body: %r", resp.text[:200])
return None
new_hash = self._poll_for_new_hash(before)
if not new_hash:
logger.error("qBittorrent accepted the request but no new torrent appeared — "
"URL may have been rejected (bad magnet, unreachable HTTPS, "
"duplicate hash, etc.)")
return new_hash
def _all_hashes(self) -> Optional[set]:
"""Return the set of every torrent hash qBit currently tracks,
or None on lookup failure."""
resp = self._call('GET', '/api/v2/torrents/info')
if not resp or not resp.ok:
return None
try:
return {item.get('hash') for item in resp.json() if item.get('hash')}
except Exception as e:
logger.error("qBittorrent /torrents/info parse failed: %s", e)
return None
def _poll_for_new_hash(self, before: set) -> Optional[str]:
"""Poll up to ~5s for a new torrent to appear (qBit takes a
moment to fetch the .torrent file from the URL and register
it). Returns the new hash, or None if nothing showed up."""
import time as _time
for _ in range(10):
_time.sleep(0.5)
current = self._all_hashes()
if current is None:
continue
new = current - before
if new:
return next(iter(new))
return None
async def add_torrent_file(
self,
file_bytes: bytes,
category: str = "soulsync",
save_path: Optional[str] = None,
) -> Optional[str]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, self._add_torrent_file_sync, file_bytes, category, save_path
)
def _add_torrent_file_sync(
self,
file_bytes: bytes,
category: str,
save_path: Optional[str],
) -> Optional[str]:
cat = category or self._category
before = self._all_hashes()
if before is None:
return None
data = {'category': cat}
if save_path or self._save_path:
data['savepath'] = save_path or self._save_path
files = {'torrents': ('soulsync.torrent', file_bytes, 'application/x-bittorrent')}
resp = self._call('POST', '/api/v2/torrents/add', data=data, files=files)
if not resp or not resp.ok:
return None
return self._poll_for_new_hash(before)
async def get_status(self, torrent_id: str) -> Optional[TorrentStatus]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_status_sync, torrent_id)
def _get_status_sync(self, torrent_id: str) -> Optional[TorrentStatus]:
resp = self._call('GET', '/api/v2/torrents/info', params={'hashes': torrent_id})
if not resp or not resp.ok:
return None
try:
items = resp.json()
except Exception as e:
logger.error("qBittorrent get_status parse failed: %s", e)
return None
if not items:
return None
return self._parse_status(items[0])
async def get_all(self) -> List[TorrentStatus]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_all_sync)
def _get_all_sync(self) -> List[TorrentStatus]:
resp = self._call('GET', '/api/v2/torrents/info')
if not resp or not resp.ok:
return []
try:
return [self._parse_status(item) for item in resp.json()]
except Exception as e:
logger.error("qBittorrent get_all parse failed: %s", e)
return []
def _parse_status(self, item: dict) -> TorrentStatus:
return TorrentStatus(
id=str(item.get('hash') or ''),
name=item.get('name') or '',
state=_map_state(item.get('state') or 'unknown'),
progress=float(item.get('progress') or 0.0),
size=int(item.get('size') or 0),
downloaded=int(item.get('downloaded') or 0),
download_speed=int(item.get('dlspeed') or 0),
upload_speed=int(item.get('upspeed') or 0),
seeders=int(item.get('num_seeds') or 0),
peers=int(item.get('num_leechs') or 0),
eta=item.get('eta') if isinstance(item.get('eta'), int) and item.get('eta', 0) > 0 else None,
save_path=item.get('save_path'),
)
async def remove(self, torrent_id: str, delete_files: bool = False) -> bool:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._remove_sync, torrent_id, delete_files)
def _remove_sync(self, torrent_id: str, delete_files: bool) -> bool:
resp = self._call('POST', '/api/v2/torrents/delete', data={
'hashes': torrent_id,
'deleteFiles': 'true' if delete_files else 'false',
})
return bool(resp and resp.ok)
async def pause(self, torrent_id: str) -> bool:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._pause_sync, torrent_id)
def _pause_sync(self, torrent_id: str) -> bool:
resp = self._call('POST', '/api/v2/torrents/pause', data={'hashes': torrent_id})
return bool(resp and resp.ok)
async def resume(self, torrent_id: str) -> bool:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._resume_sync, torrent_id)
def _resume_sync(self, torrent_id: str) -> bool:
resp = self._call('POST', '/api/v2/torrents/resume', data={'hashes': torrent_id})
return bool(resp and resp.ok)