soulsync/core/torrent_clients/qbittorrent.py
Broque Thomas de2faf290b feat(torrent): add adapter layer for qBittorrent, Transmission, Deluge
Second commit in the torrent + usenet rollout. SoulSync now speaks
three different BitTorrent client APIs through one uniform adapter
contract — picks the active client by config and dispatches the same
verbs to whichever backend the user uses. Each adapter handles its
own auth quirk (qBit cookie + CSRF Referer, Transmission session-id
renegotiation, Deluge JSON-RPC session) and maps native state
strings onto a shared 7-value set so the rest of the app stays
client-agnostic.

- core/torrent_clients/base.py: TorrentClientAdapter Protocol +
  TorrentStatus dataclass. Eight verbs: is_configured, check_connection,
  add_torrent (URL/magnet), add_torrent_file (raw bytes), get_status,
  get_all, remove, pause, resume.
- core/torrent_clients/__init__.py: adapter_for_type factory +
  get_active_adapter that reads torrent_client.type each call so
  settings changes take effect without restart.
- core/torrent_clients/qbittorrent.py: WebUI v2 adapter. Cookie auth
  via /api/v2/auth/login, transparent 403 re-login, Referer header
  to satisfy qBit's CSRF guard. add_torrent returns the just-added
  hash via /torrents/info sort=added_on (qBit's add endpoint doesn't
  echo the hash).
- core/torrent_clients/transmission.py: RPC adapter. Auto-resolves
  bare host URLs to /transmission/rpc, handles the 409 + new
  X-Transmission-Session-Id renegotiation transparently, accepts
  HTTP basic auth. add_torrent_file base64-encodes payload per spec.
- core/torrent_clients/deluge.py: Deluge 2.x JSON-RPC adapter.
  Password-only auth, distinguishes magnet vs HTTP URL at the RPC
  method layer, applies category via Label plugin (best-effort —
  label plugin is optional).
- core/connection_test.py: 'torrent_client' branch picks the right
  adapter, runs check_connection, surfaces a per-client error
  message.
- config/settings.py: torrent_client.{type, url, username, password,
  category, save_path} defaults + torrent_client.password in the
  encrypted-at-rest secrets list.
- web_server.py: 'torrent_client' added to the /api/settings POST
  allow-list so saved config persists.
- webui/index.html: new Torrent Client panel on the Indexers &
  Downloaders tab — client-type dropdown, URL, username, password,
  category, optional save path, Test Connection.
- webui/static/settings.js: load/save wiring + testTorrentClientConnection.
- webui/static/helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS entry.
2026-05-20 15:10:30 -07:00

289 lines
11 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
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 = (config_manager.get('torrent_client.url', '') or '').rstrip('/')
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,
)
if not resp.ok or resp.text.strip() != '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]:
data = {'urls': url_or_magnet, 'category': category or self._category}
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:
return None
# qBittorrent's /add endpoint returns 200 "Ok." on success but
# NOT the resulting info-hash. We have to look it up via
# /torrents/info filtered by category — the just-added torrent
# will be the newest entry in that category.
return self._lookup_latest_hash(category or self._category)
def _lookup_latest_hash(self, category: str) -> Optional[str]:
resp = self._call('GET', '/api/v2/torrents/info', params={'category': category, 'sort': 'added_on', 'reverse': 'true', 'limit': 1})
if not resp or not resp.ok:
return None
try:
items = resp.json()
if items:
return items[0].get('hash')
except Exception as e:
logger.error("qBittorrent /torrents/info parse failed: %s", e)
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]:
data = {'category': category or self._category}
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._lookup_latest_hash(category or self._category)
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)