Files
Unraid-AppOps-Gateway-Public/backend/app/connectors/deluge.py
T

371 lines
15 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_TORRENT_FIELDS = [
'name',
'state',
'progress',
'download_payload_rate',
'upload_payload_rate',
'eta',
'ratio',
'total_size',
'tracker_host',
'label',
'save_path',
'error',
'is_finished',
]
@dataclass
class _DelugeConfig:
url: str
rpc_url: str
password: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_number(value: Any, default: float = 0) -> float:
if value is None or value == '':
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _safe_path(value: Any) -> str:
text = str(value or '').replace('\\', '/').rstrip('/')
if not text:
return ''
parts = [part for part in text.split('/') if part]
if not parts:
return ''
return f'.../{parts[-1]}'
def _safe_url() -> str:
value = settings.apis.get('DELUGE_URL')
return str(value).rstrip('/') if value else ''
class DelugeConnector(BaseConnector):
name = 'Deluge'
def __init__(self) -> None:
self._client: httpx.Client | None = None
self._rpc_id = 0
def is_configured(self) -> bool:
return bool(settings.apis.get('DELUGE_URL') and settings.apis.get('DELUGE_PASSWORD'))
def _config(self) -> _DelugeConfig | None:
if not self.is_configured():
return None
base_url = str(settings.apis['DELUGE_URL']).rstrip('/')
web_path = str(settings.apis.get('DELUGE_WEB_PATH') or '/json').strip() or '/json'
if not web_path.startswith('/'):
web_path = f'/{web_path}'
rpc_url = base_url if base_url.endswith('/json') else f'{base_url}{web_path}'
return _DelugeConfig(
url=base_url,
rpc_url=rpc_url,
password=str(settings.apis['DELUGE_PASSWORD']),
verify_tls=_as_bool(settings.apis.get('DELUGE_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('DELUGE_TIMEOUT_SECONDS'), 10.0),
)
def _http(self) -> httpx.Client:
config = self._config()
if not config:
raise RuntimeError('missing_config')
if self._client is None:
self._client = httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls)
return self._client
def _rpc(self, method: str, params: list[Any] | None = None, authed: bool = True) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
if authed:
self._login()
self._rpc_id += 1
body = {'method': method, 'params': params or [], 'id': self._rpc_id}
try:
response = self._http().post(config.rpc_url, json=body)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Deluge Web JSON-RPC error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if isinstance(payload, dict) and payload.get('error'):
error = payload['error']
message = redact_message(str(error.get('message') if isinstance(error, dict) else error))
if 'password' in message.lower() or 'auth' in message.lower():
raise RuntimeError(f'auth_failed {message}')
raise RuntimeError(message)
return payload.get('result') if isinstance(payload, dict) else payload
def _login(self) -> bool:
config = self._config()
if not config:
raise RuntimeError('missing_config')
result = self._rpc('auth.login', [config.password], authed=False)
if result is not True:
raise RuntimeError('auth_failed password=[REDACTED]')
return True
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
optional_warnings: list[dict[str, str]] = []
def optional_rpc(method: str, params: list[Any] | None = None) -> Any:
try:
return self._rpc(method, params)
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
raise
optional_warnings.append({'method': method, 'reason': reason})
return None
try:
self._login()
web_connected = optional_rpc('web.connected')
daemon_connected = optional_rpc('daemon.connected')
version = optional_rpc('daemon.get_version')
session_payload = optional_rpc('core.get_session_status', [['num_torrents']])
if web_connected is None and daemon_connected is None and version is None and session_payload is None:
reason = optional_warnings[0]['reason'] if optional_warnings else 'connection_failed'
return {'status': 'unreachable', 'reason': reason, 'diagnostics': {'optionalMethodWarnings': optional_warnings}}
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
fallback = 'core.get_session_status' if session_payload is not None and optional_warnings else None
return {
'status': 'connected',
'webConnected': bool(web_connected) if web_connected is not None else None,
'daemonConnected': bool(daemon_connected) if daemon_connected is not None else None,
'version': str(version or 'unknown'),
'diagnostics': {
'healthFallback': fallback,
'optionalMethodWarnings': optional_warnings,
},
}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'url': '',
'webConnected': None,
'daemonConnected': None,
'version': 'unknown',
'required_env_vars': ['DELUGE_URL', 'DELUGE_PASSWORD'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Deluge connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'webConnected': result.get('webConnected'),
'daemonConnected': result.get('daemonConnected'),
'version': str(result.get('version', 'unknown')),
'diagnostics': result.get('diagnostics', {'healthFallback': None, 'optionalMethodWarnings': []}),
}
def session_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_session('missing_config')
try:
connected = bool(self._rpc('web.connected'))
session_payload = self._rpc('core.get_session_status', [['download_rate', 'upload_rate', 'num_torrents']])
torrents = self.torrent_status()
except RuntimeError as exc:
return self._empty_session('auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', redact_message(str(exc)))
items = torrents.get('items', []) if isinstance(torrents, dict) else []
return {
'success': True,
'status': 'ok',
'connected': connected,
'downloadRate': _as_number((session_payload or {}).get('download_rate')),
'uploadRate': _as_number((session_payload or {}).get('upload_rate')),
'numTorrents': int(_as_number((session_payload or {}).get('num_torrents'), len(items))),
'paused': sum(1 for item in items if item.get('paused')),
'active': sum(1 for item in items if item.get('active')),
'stalled': sum(1 for item in items if item.get('stalled')),
'errors': sum(1 for item in items if str(item.get('state', '')).lower() == 'error'),
}
def torrent_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_torrents('missing_config')
try:
payload = self._rpc('web.update_ui', [_TORRENT_FIELDS, {}])
except RuntimeError as exc:
return self._empty_torrents('auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', redact_message(str(exc)))
records = (payload or {}).get('torrents') if isinstance(payload, dict) else {}
items = [self._map_torrent(torrent_id, raw) for torrent_id, raw in (records or {}).items() if isinstance(raw, dict)]
return {'success': True, 'status': 'ok', 'message': 'torrents_ok', 'total': len(items), 'items': items}
def active_downloads(self) -> dict[str, Any]:
return self._filtered('active', lambda item: bool(item.get('active')))
def stalled_downloads(self) -> dict[str, Any]:
return self._filtered('stalled', lambda item: bool(item.get('stalled')))
def paused_downloads(self) -> dict[str, Any]:
return self._filtered('paused', lambda item: bool(item.get('paused')))
def speed_status(self) -> dict[str, Any]:
session = self.session_status()
return {
'success': bool(session.get('success')),
'status': session.get('status', 'unreachable'),
'connected': bool(session.get('connected')),
'downloadRate': session.get('downloadRate', 0),
'uploadRate': session.get('uploadRate', 0),
'active': session.get('active', 0),
'paused': session.get('paused', 0),
'stalled': session.get('stalled', 0),
'errors': session.get('errors', 0),
}
def pause_torrent(self, torrent_id: str) -> dict[str, Any]:
return self._torrent_mutation('core.pause_torrent', torrent_id, 'torrent_paused')
def resume_torrent(self, torrent_id: str) -> dict[str, Any]:
return self._torrent_mutation('core.resume_torrent', torrent_id, 'torrent_resumed')
def pause_all(self) -> dict[str, Any]:
return self._bulk_mutation('core.pause_all_torrents', 'all_torrents_paused')
def resume_all(self) -> dict[str, Any]:
return self._bulk_mutation('core.resume_all_torrents', 'all_torrents_resumed')
def _torrent_mutation(self, method: str, torrent_id: str, message: str) -> dict[str, Any]:
clean_id = torrent_id.strip()
if not clean_id:
return {'success': False, 'status': 'error', 'message': 'torrent_id_required'}
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {'torrentId': clean_id}}
try:
self._rpc(method, [[clean_id]])
except RuntimeError as exc:
status = 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable'
return {'success': False, 'status': status, 'message': redact_message(str(exc)), 'data': {'torrentId': clean_id}}
return {'success': True, 'status': 'ok', 'message': message, 'data': {'torrentId': clean_id}}
def _bulk_mutation(self, method: str, message: str) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
try:
self._rpc(method)
except RuntimeError as exc:
status = 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable'
return {'success': False, 'status': status, 'message': redact_message(str(exc))}
return {'success': True, 'status': 'ok', 'message': message, 'data': {'accepted': True}}
def _filtered(self, name: str, predicate: Any) -> dict[str, Any]:
payload = self.torrent_status()
if not payload.get('success'):
return {'success': False, 'status': payload.get('status'), 'message': payload.get('message'), 'total': 0, 'items': []}
items = [item for item in payload.get('items', []) if predicate(item)]
return {'success': True, 'status': 'ok', 'message': f'{name}_downloads_ok', 'total': len(items), 'items': items}
@staticmethod
def _map_torrent(torrent_id: str, raw: dict[str, Any]) -> dict[str, Any]:
state = str(raw.get('state') or 'unknown')
state_lower = state.lower()
download_rate = _as_number(raw.get('download_payload_rate'))
error = str(raw.get('error') or '')
paused = state_lower == 'paused'
active = state_lower in {'downloading', 'allocating', 'checking', 'moving'} and download_rate > 0
stalled = state_lower in {'error', 'queued'} or (state_lower == 'downloading' and download_rate <= 0)
return {
'id': str(torrent_id),
'name': str(raw.get('name') or torrent_id),
'state': state,
'progress': _as_number(raw.get('progress')),
'downloadPayloadRate': download_rate,
'uploadPayloadRate': _as_number(raw.get('upload_payload_rate')),
'eta': _as_number(raw.get('eta')),
'ratio': _as_number(raw.get('ratio')),
'totalSize': _as_number(raw.get('total_size')),
'trackerHost': str(raw.get('tracker_host') or ''),
'category': str(raw.get('label') or raw.get('category') or ''),
'savePath': _safe_path(raw.get('save_path')),
'error': error,
'isFinished': bool(raw.get('is_finished')),
'paused': paused,
'stalled': stalled,
'active': active,
}
@staticmethod
def _empty_session(status: str, message: str | None = None) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': message or status,
'connected': False,
'downloadRate': 0,
'uploadRate': 0,
'numTorrents': 0,
'paused': 0,
'active': 0,
'stalled': 0,
'errors': 0,
}
@staticmethod
def _empty_torrents(status: str, message: str | None = None) -> dict[str, Any]:
return {'success': False, 'status': status, 'message': message or status, 'total': 0, 'items': []}