soulsync/core/torrent_clients/deluge.py
BoulderBadgeDad b5d22bede5 Fix #790: torrent client URL without http:// scheme fails to connect
A bare host like '192.168.1.5:8080' or 'qbittorrent.lan:8080' (no scheme)
is what users naturally type, but requests then raises 'No connection
adapters were found for ...' — it can't pick an http/https adapter, and a
bare host:port even gets misparsed as scheme=host. This surfaced as the
generic 'qbittorrent probe failed' with a 'login error: No connection
adapters were found' in the logs.

Add normalize_client_url() in torrent_clients/base: default a missing scheme
to http:// (+ trim trailing slash), and route all three adapters'
_load_config through it. Transmission normalizes the base before appending
/transmission/rpc.

Tests: normalizer unit cases + per-adapter regression (bare host -> http://).

Note: usenet adapters (sabnzbd/nzbget) share the same pattern and need the
same treatment in a follow-up.
2026-06-04 11:57:53 -07:00

307 lines
12 KiB
Python

"""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, normalize_client_url
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 = normalize_client_url(config_manager.get('torrent_client.url', ''))
# 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