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:
parent
579eff8807
commit
de2faf290b
11 changed files with 1178 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
56
core/torrent_clients/__init__.py
Normal file
56
core/torrent_clients/__init__.py
Normal 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)
|
||||
110
core/torrent_clients/base.py
Normal file
110
core/torrent_clients/base.py
Normal 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.0–1.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: ...
|
||||
307
core/torrent_clients/deluge.py
Normal file
307
core/torrent_clients/deluge.py
Normal 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
|
||||
289
core/torrent_clients/qbittorrent.py
Normal file
289
core/torrent_clients/qbittorrent.py
Normal 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)
|
||||
266
core/torrent_clients/transmission.py
Normal file
266
core/torrent_clients/transmission.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">▼</span>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue