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.
289 lines
11 KiB
Python
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)
|