211 lines
10 KiB
Python
211 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
|
|
from ..core.config import settings
|
|
from ..core.redaction import redact_message
|
|
from .base import BaseConnector
|
|
|
|
|
|
@dataclass
|
|
class _WidefrogConfig:
|
|
url: str
|
|
api_key: str | None
|
|
verify_tls: bool
|
|
timeout_seconds: float
|
|
|
|
|
|
def _as_float(value: Any, default: float) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _safe_url() -> str:
|
|
value = settings.apis.get('WIDEFROG_URL')
|
|
if not value:
|
|
return ''
|
|
parsed = urlparse(str(value).rstrip('/'))
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return str(value).rstrip('/')
|
|
return f'{parsed.scheme}://{parsed.netloc}'
|
|
|
|
|
|
def _port() -> int:
|
|
parsed = urlparse(_safe_url())
|
|
return parsed.port or 1207
|
|
|
|
|
|
class WidefrogConnector(BaseConnector):
|
|
name = 'Widefrog'
|
|
|
|
def is_configured(self) -> bool:
|
|
return bool(settings.apis.get('WIDEFROG_URL'))
|
|
|
|
def _config(self) -> _WidefrogConfig | None:
|
|
if not self.is_configured():
|
|
return None
|
|
return _WidefrogConfig(
|
|
url=str(settings.apis['WIDEFROG_URL']).rstrip('/'),
|
|
api_key=str(settings.apis['WIDEFROG_API_KEY']) if settings.apis.get('WIDEFROG_API_KEY') else None,
|
|
verify_tls=bool(settings.apis.get('WIDEFROG_VERIFY_TLS', False)),
|
|
timeout_seconds=_as_float(settings.apis.get('WIDEFROG_TIMEOUT_SECONDS'), 10.0),
|
|
)
|
|
|
|
def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
config = self._config()
|
|
if not config:
|
|
raise RuntimeError('missing_config')
|
|
|
|
headers = dict(kwargs.pop('headers', {}) or {})
|
|
if config.api_key:
|
|
headers['X-Api-Key'] = config.api_key
|
|
try:
|
|
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
|
|
response = client.request(method=method, url=f'{config.url}{path}', headers=headers, **kwargs)
|
|
except (httpx.TimeoutException, httpx.ConnectError, httpx.RequestError, ValueError) as exc:
|
|
message = redact_message(str(exc))
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
if response.status_code == 404:
|
|
raise RuntimeError('unsupported_endpoint')
|
|
if response.status_code in {401, 403}:
|
|
raise RuntimeError('unauthorized')
|
|
if response.status_code >= 500:
|
|
raise RuntimeError(f'Widefrog server error: HTTP {response.status_code}')
|
|
if response.status_code >= 400:
|
|
raise RuntimeError(redact_message(f'Widefrog API error: HTTP {response.status_code} {(response.text or "")[:256]}'))
|
|
if response.status_code == 204:
|
|
return {}
|
|
|
|
content_type = response.headers.get('content-type', '')
|
|
if 'json' not in content_type and response.text:
|
|
return {'message': response.text[:256], 'status': 'ok'}
|
|
|
|
try:
|
|
return response.json() if response.text else {}
|
|
except ValueError as exc:
|
|
raise RuntimeError(redact_message(str(exc)))
|
|
|
|
def _first_supported(self, method: str, paths: list[str], **kwargs: Any) -> tuple[Any | None, str | None, str | None]:
|
|
last_error: str | None = None
|
|
for path in paths:
|
|
try:
|
|
return self._request_json(method, path, **kwargs), path, None
|
|
except RuntimeError as exc:
|
|
reason = str(exc)
|
|
if reason == 'unsupported_endpoint':
|
|
last_error = reason
|
|
continue
|
|
return None, path, redact_message(reason)
|
|
return None, None, last_error or 'unsupported_endpoint'
|
|
|
|
def test_connection(self) -> dict[str, Any]:
|
|
if not self.is_configured():
|
|
return {'status': 'missing_config', 'reason': 'missing_config'}
|
|
payload, _path, error = self._first_supported('GET', ['/api/health', '/health', '/'])
|
|
if error:
|
|
return {'status': 'unreachable' if error != 'unsupported_endpoint' else 'error', 'reason': redact_message(error)}
|
|
version = payload.get('version') if isinstance(payload, dict) else None
|
|
return {'status': 'connected', 'version': str(version or 'unknown')}
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
if not self.is_configured():
|
|
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': '', 'port': 1207, 'version': 'unknown', 'checks': [], 'required_env_vars': ['WIDEFROG_URL']}
|
|
payload, _path, error = self._first_supported('GET', ['/api/health', '/health', '/'])
|
|
if error:
|
|
return {'success': False, 'status': 'unreachable' if error != 'unsupported_endpoint' else 'error', 'message': redact_message(error), 'url': _safe_url(), 'port': _port(), 'version': 'unknown', 'checks': []}
|
|
data = payload if isinstance(payload, dict) else {}
|
|
return {'success': True, 'status': 'connected', 'message': 'Widefrog connector ready.', 'url': _safe_url(), 'port': _port(), 'version': str(data.get('version', 'unknown')), 'checks': data.get('checks', []) if isinstance(data.get('checks'), list) else []}
|
|
|
|
def queue_status(self) -> dict[str, Any]:
|
|
if not self.is_configured():
|
|
return self._empty_queue('missing_config')
|
|
payload, _path, error = self._first_supported('GET', ['/api/queue', '/api/jobs'])
|
|
if error:
|
|
result = self._empty_queue('unavailable')
|
|
result['message'] = redact_message(error)
|
|
return result
|
|
items = _extract_items(payload)
|
|
return _queue_summary(items)
|
|
|
|
def failed_jobs(self) -> dict[str, Any]:
|
|
if not self.is_configured():
|
|
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'failed': 0, 'items': []}
|
|
payload, _path, error = self._first_supported('GET', ['/api/failed', '/api/jobs/failed', '/api/queue/failed', '/api/jobs'])
|
|
if error:
|
|
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'failed': 0, 'items': []}
|
|
items = [item for item in _extract_items(payload) if str(item.get('status', '')).lower() == 'failed']
|
|
if not items:
|
|
items = _extract_items(payload)
|
|
return {'success': True, 'status': 'ok', 'failed': len(items), 'items': items}
|
|
|
|
def activity(self) -> dict[str, Any]:
|
|
if not self.is_configured():
|
|
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'recent': [], 'last_event': None}
|
|
payload, _path, error = self._first_supported('GET', ['/api/activity', '/api/events', '/api/history'])
|
|
if error:
|
|
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'recent': [], 'last_event': None}
|
|
recent = _extract_items(payload, key='recent')
|
|
return {'success': True, 'status': 'ok', 'recent': recent, 'last_event': recent[0] if recent else None}
|
|
|
|
def download_status(self) -> dict[str, Any]:
|
|
if not self.is_configured():
|
|
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'active': 0, 'speed': None, 'processing': 0, 'items': []}
|
|
payload, _path, error = self._first_supported('GET', ['/api/download-status', '/api/downloads', '/api/queue/downloads'])
|
|
if error:
|
|
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'active': 0, 'speed': None, 'processing': 0, 'items': []}
|
|
data = payload if isinstance(payload, dict) else {}
|
|
items = _extract_items(payload)
|
|
return {'success': True, 'status': 'ok', 'active': int(data.get('active', len(items)) or 0), 'speed': data.get('speed'), 'processing': int(data.get('processing', 0) or 0), 'items': items}
|
|
|
|
def retry_failed(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if not self.is_configured():
|
|
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
|
|
result, _path, error = self._first_supported('POST', ['/api/retry-failed', '/api/jobs/retry-failed', '/api/failed/retry'], json=payload or {})
|
|
if error:
|
|
return {'success': False, 'status': 'unsupported' if error == 'unsupported_endpoint' else 'error', 'message': redact_message(error)}
|
|
return {'success': True, 'status': 'ok', 'message': 'retry_started', 'data': result if isinstance(result, dict) else {}}
|
|
|
|
def scan_library(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if not self.is_configured():
|
|
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
|
|
result, _path, error = self._first_supported('POST', ['/api/scan-library', '/api/library/scan', '/api/scan'], json=payload or {})
|
|
if error:
|
|
return {'success': False, 'status': 'unsupported' if error == 'unsupported_endpoint' else 'error', 'message': redact_message(error)}
|
|
return {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': result if isinstance(result, dict) else {}}
|
|
|
|
@staticmethod
|
|
def _empty_queue(status: str) -> dict[str, Any]:
|
|
return {'success': False, 'status': status, 'message': status, 'total': 0, 'active': 0, 'queued': 0, 'completed': 0, 'failed': 0, 'items': []}
|
|
|
|
|
|
def _extract_items(payload: Any, key: str = 'items') -> list[dict[str, Any]]:
|
|
if isinstance(payload, list):
|
|
return [item for item in payload if isinstance(item, dict)]
|
|
if isinstance(payload, dict):
|
|
value = payload.get(key) or payload.get('jobs') or payload.get('queue') or []
|
|
if isinstance(value, list):
|
|
return [item for item in value if isinstance(item, dict)]
|
|
return []
|
|
|
|
|
|
def _queue_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
normalized = [{**item, 'status': str(item.get('status', 'unknown')).lower()} for item in items]
|
|
return {
|
|
'success': True,
|
|
'status': 'ok',
|
|
'total': len(normalized),
|
|
'active': sum(1 for item in normalized if item['status'] in {'active', 'running', 'processing'}),
|
|
'queued': sum(1 for item in normalized if item['status'] in {'queued', 'pending'}),
|
|
'completed': sum(1 for item in normalized if item['status'] in {'completed', 'done', 'success'}),
|
|
'failed': sum(1 for item in normalized if item['status'] in {'failed', 'error'}),
|
|
'items': normalized,
|
|
}
|