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.
This commit is contained in:
Broque Thomas 2026-05-20 15:10:30 -07:00
parent 579eff8807
commit de2faf290b
11 changed files with 1178 additions and 1 deletions

View file

@ -88,6 +88,7 @@ class ConfigManager:
'deezer_download.arl',
'lidarr_download.api_key',
'prowlarr.api_key',
'torrent_client.password',
# Enrichment services
'listenbrainz.token',
'acoustid.api_key',
@ -529,6 +530,17 @@ class ConfigManager:
# Empty = search all enabled indexers.
"indexer_ids": "",
},
# Torrent client — receives .torrent / magnet URIs from the
# torrent download plugin. ``type`` picks which adapter to
# instantiate (qbittorrent | transmission | deluge).
"torrent_client": {
"type": "qbittorrent",
"url": "",
"username": "",
"password": "",
"category": "soulsync",
"save_path": "",
},
"soundcloud_download": {
# Anonymous-only for now — SoundCloud Go+ OAuth tier could be
# added later, with credentials living under a "session" subkey

View file

@ -305,6 +305,25 @@ def run_service_test(service, test_config):
return False, "Invalid Genius access token."
except Exception as e:
return False, f"Genius connection error: {str(e)}"
elif service == "torrent_client":
client_type = (config_manager.get('torrent_client.type', '') or '').strip().lower()
url = config_manager.get('torrent_client.url', '')
if not url:
return False, "Torrent client URL is required."
if not client_type:
return False, "Pick a torrent client (qBittorrent, Transmission, or Deluge)."
try:
from core.torrent_clients import adapter_for_type
adapter = adapter_for_type(client_type)
if adapter is None:
return False, f"Unknown torrent client type: {client_type}"
if not adapter.is_configured():
return False, "Torrent client missing required credentials."
if run_async(adapter.check_connection()):
return True, f"Connected to {client_type}"
return False, f"{client_type} probe failed — check URL, credentials, and that the client is running."
except Exception as e:
return False, f"Torrent client connection error: {str(e)}"
elif service == "prowlarr":
url = config_manager.get('prowlarr.url', '')
api_key = config_manager.get('prowlarr.api_key', '')

View file

@ -0,0 +1,56 @@
"""Torrent client adapters.
Each adapter wraps one BitTorrent client (qBittorrent, Transmission,
Deluge) behind the ``TorrentClientAdapter`` Protocol so the rest of
SoulSync can talk to whichever client the user picked through one
uniform surface.
The active adapter is selected at runtime by the ``torrent_client.type``
config key. See ``get_active_adapter()`` for the factory.
"""
from __future__ import annotations
from typing import Optional
from config.settings import config_manager
from core.torrent_clients.base import TorrentClientAdapter, TorrentStatus
from core.torrent_clients.deluge import DelugeAdapter
from core.torrent_clients.qbittorrent import QBittorrentAdapter
from core.torrent_clients.transmission import TransmissionAdapter
__all__ = [
"TorrentClientAdapter",
"TorrentStatus",
"QBittorrentAdapter",
"TransmissionAdapter",
"DelugeAdapter",
"get_active_adapter",
"adapter_for_type",
]
def adapter_for_type(client_type: str) -> Optional[TorrentClientAdapter]:
"""Build a fresh adapter instance for the given client type string.
``None`` for unknown types so callers can present a helpful error
rather than crashing on a typo'd config value.
"""
if client_type == "qbittorrent":
return QBittorrentAdapter()
if client_type == "transmission":
return TransmissionAdapter()
if client_type == "deluge":
return DelugeAdapter()
return None
def get_active_adapter() -> Optional[TorrentClientAdapter]:
"""Return an adapter for whichever torrent client the user has
selected in Settings. Reads ``torrent_client.type`` each call so a
settings change is picked up without restarting the process."""
client_type = (config_manager.get('torrent_client.type', '') or '').strip().lower()
if not client_type:
return None
return adapter_for_type(client_type)

View file

@ -0,0 +1,110 @@
"""Torrent client adapter contract.
``TorrentClientAdapter`` is a structural Protocol any class with
these method signatures is treated as a valid adapter. The download
plugin layer (built in a later commit) dispatches generically against
this surface so it doesn't have to know whether the user picked
qBittorrent, Transmission, or Deluge.
The contract intentionally hides protocol-specific details:
- qBittorrent uses cookie auth + multipart form uploads.
- Transmission uses an X-Transmission-Session-Id header + JSON RPC.
- Deluge 2.x uses /json with a session cookie.
All three converge on the same eight verbs below.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Protocol, runtime_checkable
@dataclass
class TorrentStatus:
"""Adapter-uniform view of one torrent's live state.
Field semantics:
- ``state`` is one of: ``queued`` | ``downloading`` | ``seeding`` |
``paused`` | ``stalled`` | ``error`` | ``completed``. Each
adapter maps its native state names to this set.
- ``progress`` is 0.01.0.
- ``save_path`` is where files land on the torrent client's host.
For remote clients this is a path on the *remote* machine.
- ``files`` is the list of relative paths inside the torrent. Empty
until the client has finished fetching the metadata.
"""
id: str # torrent hash (qBit, Deluge) or numeric id (Transmission)
name: str
state: str
progress: float
size: int # total size in bytes
downloaded: int # bytes downloaded so far
download_speed: int # bytes/sec
upload_speed: int # bytes/sec
seeders: int = 0
peers: int = 0
eta: Optional[int] = None # seconds, None if unknown
save_path: Optional[str] = None
files: Optional[List[str]] = None
error: Optional[str] = None
@runtime_checkable
class TorrentClientAdapter(Protocol):
"""Structural contract every torrent-client adapter implements."""
def is_configured(self) -> bool:
"""True when the adapter has a URL and any credentials it
needs. Reads from config_manager never raises on missing
config, just returns False so the orchestrator can dim the
torrent download source in the UI."""
...
async def check_connection(self) -> bool:
"""Probe the client over the network. Logs in if required."""
...
async def add_torrent(
self,
url_or_magnet: str,
category: str = "soulsync",
save_path: Optional[str] = None,
) -> Optional[str]:
"""Hand the torrent client a HTTP/HTTPS URL pointing to a
``.torrent`` file or a ``magnet:`` URI. Returns the torrent's
client-side identifier (info-hash for qBit / Deluge, numeric
id for Transmission) or ``None`` on failure."""
...
async def add_torrent_file(
self,
file_bytes: bytes,
category: str = "soulsync",
save_path: Optional[str] = None,
) -> Optional[str]:
"""Upload a raw ``.torrent`` payload. Same return as
``add_torrent``. Used when the indexer doesn't expose a
direct download URL and SoulSync had to fetch the file
itself first."""
...
async def get_status(self, torrent_id: str) -> Optional[TorrentStatus]:
"""Return live status for one torrent, or ``None`` if the
client doesn't know about it."""
...
async def get_all(self) -> List[TorrentStatus]:
"""Return live status for every torrent the client currently
tracks. Used by the global download list."""
...
async def remove(self, torrent_id: str, delete_files: bool = False) -> bool:
"""Remove the torrent from the client. ``delete_files=True``
also deletes the downloaded data on disk."""
...
async def pause(self, torrent_id: str) -> bool: ...
async def resume(self, torrent_id: str) -> bool: ...

View file

@ -0,0 +1,307 @@
"""Deluge 2.x JSON-RPC adapter.
Auth model: POST ``/json`` with method ``auth.login`` returns a
``_session_id`` cookie. The session cookie + every subsequent JSON-RPC
call must include a monotonically-incrementing ``id`` field. ``params``
is a list (positional), not an object.
Reference: https://deluge.readthedocs.io/en/latest/reference/api.html
and https://deluge.readthedocs.io/en/latest/reference/webapi.html
"""
from __future__ import annotations
import asyncio
import base64
import threading
from itertools import count
from typing import Any, 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.deluge")
# Deluge native state strings → adapter-uniform set.
_DELUGE_STATE_MAP = {
"Allocating": "queued",
"Checking": "queued",
"Downloading": "downloading",
"Seeding": "seeding",
"Paused": "paused",
"Queued": "queued",
"Error": "error",
"Moving": "queued",
}
def _map_state(deluge_state: str, progress: float) -> str:
mapped = _DELUGE_STATE_MAP.get(deluge_state or '', "error")
if mapped == "paused" and progress >= 1.0:
return "completed"
return mapped
class DelugeAdapter:
"""Deluge 2.x WebUI JSON-RPC adapter."""
DEFAULT_TIMEOUT = 15
# Fields we ask ``core.get_torrents_status`` to return — explicit
# to keep payload size predictable across Deluge versions that
# add fields over time.
_STATUS_FIELDS = [
'hash', 'name', 'state', 'progress', 'total_size',
'total_done', 'download_payload_rate', 'upload_payload_rate',
'num_seeds', 'num_peers', 'eta', 'save_path', 'tracker_status',
]
def __init__(self) -> None:
self._session: Optional[http_requests.Session] = None
self._session_lock = threading.Lock()
self._id_counter = count(1)
self._load_config()
def _load_config(self) -> None:
self._url = (config_manager.get('torrent_client.url', '') or '').rstrip('/')
# Deluge's WebUI auth uses a single password, not username+password.
# We accept whichever field the user filled in — keeps the UI uniform.
self._password = (
config_manager.get('torrent_client.password', '')
or config_manager.get('torrent_client.username', '')
or ''
)
self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync'
self._save_path = config_manager.get('torrent_client.save_path', '') or ''
with self._session_lock:
self._session = None
def reload_settings(self) -> None:
self._load_config()
def is_configured(self) -> bool:
# Deluge WebUI requires a password; without it auth.login fails
# outright. Refuse the configuration up front rather than letting
# every call 401.
return bool(self._url and self._password)
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:
sess = self._ensure_session_sync()
if sess is None:
return False
# web.connected() returns True iff the WebUI's daemon link is up.
# If daemons aren't auto-connected, fall back to a cheap call
# that just confirms we're authenticated.
result = self._rpc_sync('web.connected', [])
if result is True:
return True
# Fall back to a generic auth probe.
return self._rpc_sync('auth.check_session', []) is not None
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()
self._session = sess
# Log in. auth.login takes the password as a single positional arg.
result = self._rpc_sync('auth.login', [self._password])
if result is not True:
logger.error("Deluge auth.login returned %r", result)
with self._session_lock:
self._session = None
return None
with self._session_lock:
return self._session
def _rpc_sync(self, method: str, params: list) -> Any:
if not self._url:
return None
with self._session_lock:
sess = self._session
if sess is None:
# Bootstrap a session container; auth.login will populate it.
with self._session_lock:
if self._session is None:
self._session = http_requests.Session()
sess = self._session
payload = {
'id': next(self._id_counter),
'method': method,
'params': params,
}
try:
resp = sess.post(
f"{self._url}/json", json=payload,
headers={'Content-Type': 'application/json'},
timeout=self.DEFAULT_TIMEOUT,
)
if not resp.ok:
logger.warning("Deluge %s returned HTTP %s", method, resp.status_code)
return None
data = resp.json()
err = data.get('error')
if err:
# Code 1 = unknown method, 2 = bad params, etc.
# Code 'No Auth' surfaces as a string in some versions.
logger.warning("Deluge %s error: %r", method, err)
return None
return data.get('result')
except Exception as e:
logger.error("Deluge %s call failed: %s", method, 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]:
if self._ensure_session_sync() is None:
return None
options: dict = {}
if save_path or self._save_path:
options['download_location'] = save_path or self._save_path
# Deluge distinguishes magnet URIs from HTTP .torrent URLs at
# the API layer — different method names.
if url_or_magnet.startswith('magnet:'):
method = 'core.add_torrent_magnet'
else:
method = 'core.add_torrent_url'
torrent_hash = self._rpc_sync(method, [url_or_magnet, options])
if not torrent_hash:
return None
self._apply_label(str(torrent_hash), category or self._category)
return str(torrent_hash)
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]:
if self._ensure_session_sync() is None:
return None
options: dict = {}
if save_path or self._save_path:
options['download_location'] = save_path or self._save_path
encoded = base64.b64encode(file_bytes).decode('ascii')
torrent_hash = self._rpc_sync('core.add_torrent_file', ['soulsync.torrent', encoded, options])
if not torrent_hash:
return None
self._apply_label(str(torrent_hash), category or self._category)
return str(torrent_hash)
def _apply_label(self, torrent_hash: str, label: str) -> None:
"""Best-effort label assignment. The Label plugin is optional
in Deluge if it isn't installed the RPC call fails silently
and we don't propagate the error: the torrent is still added."""
if not label:
return
# Ensure the label exists before assigning.
self._rpc_sync('label.add', [label])
self._rpc_sync('label.set_torrent', [torrent_hash, label])
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]:
result = self._rpc_sync('core.get_torrent_status', [torrent_id, self._STATUS_FIELDS])
if not isinstance(result, dict) or not result:
return None
# core.get_torrent_status doesn't echo back the hash — patch it in.
result.setdefault('hash', torrent_id)
return self._parse_status(result)
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]:
result = self._rpc_sync('core.get_torrents_status', [{}, self._STATUS_FIELDS])
if not isinstance(result, dict):
return []
out = []
for hash_id, item in result.items():
if not isinstance(item, dict):
continue
item.setdefault('hash', hash_id)
out.append(self._parse_status(item))
return out
def _parse_status(self, item: dict) -> TorrentStatus:
progress = float(item.get('progress') or 0.0)
# Deluge expresses progress as 0-100; normalize to 0-1.
if progress > 1.0:
progress = progress / 100.0
return TorrentStatus(
id=str(item.get('hash') or ''),
name=item.get('name') or '',
state=_map_state(item.get('state') or '', progress),
progress=progress,
size=int(item.get('total_size') or 0),
downloaded=int(item.get('total_done') or 0),
download_speed=int(item.get('download_payload_rate') or 0),
upload_speed=int(item.get('upload_payload_rate') or 0),
seeders=int(item.get('num_seeds') or 0),
peers=int(item.get('num_peers') or 0),
eta=int(item['eta']) if isinstance(item.get('eta'), (int, float)) and item.get('eta', 0) > 0 else None,
save_path=item.get('save_path'),
error=item.get('tracker_status') if 'Error' in (item.get('state') or '') else None,
)
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:
result = self._rpc_sync('core.remove_torrent', [torrent_id, bool(delete_files)])
return result is True
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:
# Deluge 2.x core.pause_torrent takes a list of hashes.
return self._rpc_sync('core.pause_torrent', [[torrent_id]]) is not None
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:
return self._rpc_sync('core.resume_torrent', [[torrent_id]]) is not None

View file

@ -0,0 +1,289 @@
"""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)

View file

@ -0,0 +1,266 @@
"""Transmission RPC adapter.
Auth model: Transmission uses HTTP Basic auth + an
``X-Transmission-Session-Id`` header. The session ID rotates and is
returned on every 409 response the adapter caches the latest value
and replays the original request transparently.
Reference: https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md
"""
from __future__ import annotations
import asyncio
import base64
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.transmission")
# Transmission RPC status codes. Defined as numeric constants in the
# RPC spec — converted to the adapter-uniform string set here.
_TRANSMISSION_STATUS = {
0: "paused",
1: "queued", # queued to check files
2: "queued", # checking files
3: "queued", # queued to download
4: "downloading",
5: "queued", # queued to seed
6: "seeding",
}
def _map_state(status_code: int, percent_done: float) -> str:
base = _TRANSMISSION_STATUS.get(status_code, "error")
# Transmission reports 'paused' (0) for both never-started and
# fully-downloaded-but-not-seeding. Differentiate by progress.
if status_code == 0 and percent_done >= 1.0:
return "completed"
return base
class TransmissionAdapter:
"""Transmission RPC adapter (transmission-rpc protocol v17+)."""
DEFAULT_TIMEOUT = 15
def __init__(self) -> None:
self._session_id: Optional[str] = None
self._session_id_lock = threading.Lock()
self._load_config()
def _load_config(self) -> None:
url = (config_manager.get('torrent_client.url', '') or '').rstrip('/')
# Transmission's RPC endpoint is always /transmission/rpc — if the
# user pasted a bare host URL, append it. If they pasted the full
# /transmission/rpc URL, leave it.
if url and not url.endswith('/transmission/rpc'):
if '/transmission' not in url:
url = f"{url}/transmission/rpc"
self._url = 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 ''
with self._session_id_lock:
self._session_id = None
def reload_settings(self) -> None:
self._load_config()
def is_configured(self) -> bool:
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:
resp = self._rpc('session-get', {})
return resp is not None
def _rpc(self, method: str, arguments: dict) -> Optional[dict]:
"""One Transmission RPC round-trip. Handles the 409
session-id renegotiation transparently Transmission rejects
the first call with HTTP 409 and a fresh
``X-Transmission-Session-Id`` header, which subsequent calls
must echo back."""
if not self._url:
return None
auth = (self._username, self._password) if self._username else None
payload = {'method': method, 'arguments': arguments}
for attempt in range(2):
try:
with self._session_id_lock:
sid = self._session_id
headers = {'Content-Type': 'application/json'}
if sid:
headers['X-Transmission-Session-Id'] = sid
resp = http_requests.post(
self._url, json=payload, headers=headers, auth=auth,
timeout=self.DEFAULT_TIMEOUT,
)
if resp.status_code == 409:
# Pick up the new session id and retry.
new_sid = resp.headers.get('X-Transmission-Session-Id')
if not new_sid:
logger.error("Transmission 409 with no X-Transmission-Session-Id header")
return None
with self._session_id_lock:
self._session_id = new_sid
continue
if not resp.ok:
logger.warning("Transmission RPC %s returned HTTP %s", method, resp.status_code)
return None
data = resp.json()
if data.get('result') != 'success':
logger.warning("Transmission RPC %s result=%s", method, data.get('result'))
return None
return data.get('arguments', {})
except Exception as e:
logger.error("Transmission RPC %s failed: %s", method, e)
return None
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]:
args: dict = {'filename': url_or_magnet, 'labels': [category or self._category]}
if save_path or self._save_path:
args['download-dir'] = save_path or self._save_path
return self._add_torrent_finish(args)
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]:
args: dict = {
'metainfo': base64.b64encode(file_bytes).decode('ascii'),
'labels': [category or self._category],
}
if save_path or self._save_path:
args['download-dir'] = save_path or self._save_path
return self._add_torrent_finish(args)
def _add_torrent_finish(self, args: dict) -> Optional[str]:
data = self._rpc('torrent-add', args)
if not data:
return None
# torrent-add returns either ``torrent-added`` (new torrent) or
# ``torrent-duplicate`` (already exists). Both carry the hash.
torrent = data.get('torrent-added') or data.get('torrent-duplicate')
if not torrent:
return None
return str(torrent.get('hashString') or '') or None
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]:
data = self._rpc('torrent-get', {
'ids': [torrent_id],
'fields': self._STATUS_FIELDS,
})
if not data or not data.get('torrents'):
return None
return self._parse_status(data['torrents'][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]:
data = self._rpc('torrent-get', {'fields': self._STATUS_FIELDS})
if not data:
return []
return [self._parse_status(t) for t in data.get('torrents', [])]
_STATUS_FIELDS = [
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'rateDownload', 'rateUpload', 'peersSendingToUs',
'peersGettingFromUs', 'eta', 'downloadDir', 'errorString',
]
def _parse_status(self, item: dict) -> TorrentStatus:
progress = float(item.get('percentDone') or 0.0)
status_code = int(item.get('status') or 0)
eta_raw = item.get('eta')
# Transmission returns -1 / -2 for "unknown" — surface as None.
eta = eta_raw if isinstance(eta_raw, int) and eta_raw > 0 else None
return TorrentStatus(
id=str(item.get('hashString') or ''),
name=item.get('name') or '',
state=_map_state(status_code, progress),
progress=progress,
size=int(item.get('totalSize') or 0),
downloaded=int(item.get('downloadedEver') or 0),
download_speed=int(item.get('rateDownload') or 0),
upload_speed=int(item.get('rateUpload') or 0),
seeders=int(item.get('peersSendingToUs') or 0),
peers=int(item.get('peersGettingFromUs') or 0),
eta=eta,
save_path=item.get('downloadDir'),
error=item.get('errorString') or None,
)
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:
data = self._rpc('torrent-remove', {
'ids': [torrent_id],
'delete-local-data': bool(delete_files),
})
return data is not None
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:
return self._rpc('torrent-stop', {'ids': [torrent_id]}) is not None
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:
return self._rpc('torrent-start', {'ids': [torrent_id]}) is not None

View file

@ -2753,7 +2753,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'torrent_client', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)

View file

@ -4995,6 +4995,63 @@
</div>
</div>
<!-- ═══ TORRENT CLIENT ═══ -->
<div class="settings-group" data-stg="indexers">
<h3>🧲 Torrent Client</h3>
<div class="setting-help-text" style="margin-bottom: 10px;">
Where SoulSync sends torrents once Prowlarr finds them. Pick the client you already use — only one can be active at a time. Add the URL of the client's WebUI, log in if it asks for credentials, and hit Test Connection.
</div>
<div class="form-group">
<label>Client Type:</label>
<select id="torrent-client-type" class="form-select">
<option value="qbittorrent">qBittorrent</option>
<option value="transmission">Transmission</option>
<option value="deluge">Deluge 2.x</option>
</select>
<div class="setting-help-text" id="torrent-client-help">
qBittorrent: WebUI port (default 8080). Transmission: RPC port (default 9091). Deluge: WebUI port (default 8112).
</div>
</div>
<div class="form-group">
<label>WebUI URL:</label>
<input type="text" id="torrent-client-url" placeholder="http://localhost:8080">
</div>
<div class="form-group" id="torrent-client-username-group">
<label>Username:</label>
<input type="text" id="torrent-client-username" placeholder="leave blank if WebUI doesn't require auth">
<div class="setting-help-text">
qBittorrent and Transmission use username + password. Deluge uses password only — paste it in the password field below.
</div>
</div>
<div class="form-group">
<label>Password:</label>
<input type="password" id="torrent-client-password" placeholder="WebUI password">
</div>
<div class="form-group">
<label>Category / Label:</label>
<input type="text" id="torrent-client-category" placeholder="soulsync" value="soulsync">
<div class="setting-help-text">
SoulSync tags every torrent it adds with this label so it's easy to spot in your client. Deluge needs the Label plugin installed for this to stick.
</div>
</div>
<div class="form-group">
<label>Save Path (optional):</label>
<input type="text" id="torrent-client-save-path" placeholder="leave blank to use the client's default">
<div class="setting-help-text">
Override where the torrent client writes downloads. This path is on the <strong>torrent client's</strong> machine, not SoulSync's.
</div>
</div>
<div class="form-group">
<label>Torrent Client Status:</label>
<div class="form-actions" style="margin-top: 4px;">
<button class="test-button" id="torrent-client-test-btn" onclick="testTorrentClientConnection()">
Test Connection
</button>
<span id="torrent-client-connection-status" class="setting-help-text" style="margin-left: 8px;"></span>
</div>
</div>
</div>
<!-- ═══ PATHS & ORGANIZATION ═══ -->
<div class="settings-section-header collapsed" data-stg="library" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
<span class="settings-section-arrow">&#9660;</span>

View file

@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.0': [
{ unreleased: true },
{ title: 'Torrent client adapters (qBittorrent, Transmission, Deluge)', desc: 'second commit in the torrent + usenet rollout. SoulSync can now talk to any of the three big torrent clients through a single adapter contract — pick which one you use in Settings → Indexers & Downloaders, paste your WebUI URL and credentials, and Test Connection confirms the link. each adapter handles its own auth quirk (qBit cookies, Transmission session-id, Deluge JSON-RPC) and maps native state strings onto a uniform set so the rest of the app stays client-agnostic. no download wiring yet — that gets layered on once the usenet client adapters land in the next commit.' },
{ title: 'Prowlarr integration', desc: 'new Indexers & Downloaders tab in Settings. point SoulSync at your Prowlarr instance with a URL and API key, and you can browse the indexers Prowlarr exposes from inside the app. this is the search half of the upcoming torrent and usenet download sources — wires up the indexer list now so later commits can plug the download flow on top. Lidarr already pulls from its own indexers; Prowlarr unlocks the same search surface to the rest of the download pipeline.' },
],
'2.5.8': [
@ -3475,6 +3476,20 @@ const WHATS_NEW = {
// Section shape: { title, description, features: [bullet strings],
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "Torrent Client Adapters (qBit, Transmission, Deluge)",
description: "second phase of the torrent + usenet rollout. SoulSync now speaks the three big torrent client APIs through one uniform adapter — pick which client you use and SoulSync handles the auth and protocol quirks for you.",
features: [
"• supports qBittorrent (WebUI v2), Transmission (RPC), Deluge 2.x (JSON-RPC)",
"• new Torrent Client section on the Indexers & Downloaders tab",
"• single client type picker — switching between clients is a dropdown change, no code path divergence",
"• per-client credential fields with hints (qBit / Transmission use username + password, Deluge uses password only)",
"• optional category/label and save-path overrides so SoulSync's torrents are easy to spot in your client",
"• Test Connection probes the live WebUI and confirms auth works",
"• no downloads triggered yet — the wire-up to Prowlarr search results lands once usenet client adapters ship",
],
usage_note: "Settings → Indexers & Downloaders → Torrent Client",
},
{
title: "Prowlarr Integration (Phase 1 of Torrent + Usenet)",
description: "first commit toward torrent and usenet download sources. SoulSync can now talk to your Prowlarr instance and pull the list of configured indexers, setting up the search surface that the torrent and usenet clients will plug into next.",

View file

@ -952,6 +952,18 @@ async function loadSettingsData() {
if (_prowUrl) _prowUrl.value = settings.prowlarr?.url || '';
if (_prowKey) _prowKey.value = settings.prowlarr?.api_key || '';
if (_prowIds) _prowIds.value = settings.prowlarr?.indexer_ids || '';
const _tcType = document.getElementById('torrent-client-type');
const _tcUrl = document.getElementById('torrent-client-url');
const _tcUser = document.getElementById('torrent-client-username');
const _tcPass = document.getElementById('torrent-client-password');
const _tcCat = document.getElementById('torrent-client-category');
const _tcPath = document.getElementById('torrent-client-save-path');
if (_tcType) _tcType.value = settings.torrent_client?.type || 'qbittorrent';
if (_tcUrl) _tcUrl.value = settings.torrent_client?.url || '';
if (_tcUser) _tcUser.value = settings.torrent_client?.username || '';
if (_tcPass) _tcPass.value = settings.torrent_client?.password || '';
if (_tcCat) _tcCat.value = settings.torrent_client?.category || 'soulsync';
if (_tcPath) _tcPath.value = settings.torrent_client?.save_path || '';
// Sync ARL to connections tab field + bidirectional listeners
const _connArl = document.getElementById('deezer-connection-arl');
const _dlArl = document.getElementById('deezer-download-arl');
@ -2702,6 +2714,14 @@ async function saveSettings(quiet = false) {
api_key: document.getElementById('prowlarr-api-key')?.value || '',
indexer_ids: document.getElementById('prowlarr-indexer-ids')?.value || '',
},
torrent_client: {
type: document.getElementById('torrent-client-type')?.value || 'qbittorrent',
url: document.getElementById('torrent-client-url')?.value || '',
username: document.getElementById('torrent-client-username')?.value || '',
password: document.getElementById('torrent-client-password')?.value || '',
category: document.getElementById('torrent-client-category')?.value || 'soulsync',
save_path: document.getElementById('torrent-client-save-path')?.value || '',
},
soundcloud_download: {
// No knobs yet — anonymous-only. Keeping the key present so
// future tier-2 OAuth wiring (Go+ session token) doesn't have
@ -3568,6 +3588,32 @@ async function testProwlarrConnection() {
}
}
async function testTorrentClientConnection() {
const statusEl = document.getElementById('torrent-client-connection-status');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
await saveSettings();
const resp = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: 'torrent_client' })
});
const data = await resp.json();
if (data.success) {
statusEl.textContent = data.message || 'Connected';
statusEl.style.color = '#4caf50';
} else {
statusEl.textContent = data.error || 'Connection failed';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function loadProwlarrIndexers() {
const listEl = document.getElementById('prowlarr-indexer-list');
if (!listEl) return;