Live testing surfaced: every download attempt failed with 'Torrent client refused the URL', but qBit was actually accepting the add request fine. The bug was in our hash-lookup strategy. qBittorrent's /api/v2/torrents/add returns 200 'Ok.' regardless of whether the URL was actually valid / accepted / registered. The previous code then queried /torrents/info?category=soulsync to find the just-added torrent — but qBit hadn't categorised the new torrent yet on the first poll, AND a fresh install has no 'soulsync' category configured, so the lookup returned empty and the adapter reported failure for every working download. New strategy: - Snapshot every torrent hash qBit currently tracks BEFORE posting to /add. - POST /add, accept its (uninformative) 200 OK. - Poll the all-torrents list for up to 5 seconds, looking for a hash that wasn't present in the before-snapshot. First new hash wins. The diff strategy works the same for /add with urls= (HTTP URL / magnet) and /add with files= (raw .torrent upload), so both paths now share the same _all_hashes + _poll_for_new_hash helpers. Adds a warning log when qBit returns an unexpected body and an error log when no new hash appears (so future investigation has breadcrumbs).
325 lines
13 KiB
Python
325 lines
13 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]:
|
|
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)
|