371 lines
15 KiB
Python
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': []}
|