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': []}