Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,299 @@
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ProwlarrConfig:
|
||||
url: str
|
||||
api_key: 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_int(value: Any, default: int = 0) -> int:
|
||||
if value is None or value == '':
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
if isinstance(value, int | float):
|
||||
return int(value)
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_url() -> str:
|
||||
value = settings.apis.get('PROWLARR_URL')
|
||||
return str(value).rstrip('/') if value else ''
|
||||
|
||||
|
||||
class ProwlarrConnector(BaseConnector):
|
||||
name = 'Prowlarr'
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(settings.apis.get('PROWLARR_URL') and settings.apis.get('PROWLARR_API_KEY'))
|
||||
|
||||
def _config(self) -> _ProwlarrConfig | None:
|
||||
if not self.is_configured():
|
||||
return None
|
||||
return _ProwlarrConfig(
|
||||
url=str(settings.apis['PROWLARR_URL']).rstrip('/'),
|
||||
api_key=str(settings.apis['PROWLARR_API_KEY']),
|
||||
verify_tls=_as_bool(settings.apis.get('PROWLARR_VERIFY_TLS'), False),
|
||||
timeout_seconds=_as_float(settings.apis.get('PROWLARR_TIMEOUT_SECONDS'), 10.0),
|
||||
)
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
config = self._config()
|
||||
if not config:
|
||||
raise RuntimeError('missing_config')
|
||||
|
||||
headers = dict(kwargs.pop('headers', {}) or {})
|
||||
headers['X-Api-Key'] = config.api_key
|
||||
url = f'{config.url}{path}'
|
||||
|
||||
try:
|
||||
self.clear_error()
|
||||
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
|
||||
response = client.request(method=method, url=url, headers=headers, **kwargs)
|
||||
except (httpx.RequestError, ValueError) as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
|
||||
if response.status_code in {401, 403}:
|
||||
self.set_error('unauthorized')
|
||||
raise RuntimeError('unauthorized')
|
||||
if response.status_code == 404:
|
||||
self.set_error('unsupported_endpoint')
|
||||
raise RuntimeError('unsupported_endpoint')
|
||||
if response.status_code >= 500:
|
||||
message = f'Prowlarr server error: HTTP {response.status_code}'
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
if response.status_code >= 400:
|
||||
message = redact_message(f'Prowlarr API error: HTTP {response.status_code} {(response.text or "")[:256]}')
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
|
||||
def test_connection(self) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
return {'status': 'missing_config', 'reason': 'missing_config'}
|
||||
try:
|
||||
payload = self._request('GET', '/api/v1/system/status')
|
||||
except RuntimeError as exc:
|
||||
reason = str(exc)
|
||||
if reason == 'missing_config':
|
||||
return {'status': 'missing_config', 'reason': reason}
|
||||
return {'status': 'unreachable' if reason not in {'unauthorized'} else 'error', 'reason': redact_message(reason)}
|
||||
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',
|
||||
'version': 'unknown',
|
||||
'url': '',
|
||||
'required_env_vars': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
|
||||
}
|
||||
result = self.test_connection()
|
||||
status = str(result.get('status', 'error'))
|
||||
return {
|
||||
'success': status == 'connected',
|
||||
'status': status,
|
||||
'message': 'Prowlarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
|
||||
'version': str(result.get('version', 'unknown')),
|
||||
'url': _safe_url(),
|
||||
}
|
||||
|
||||
def indexer_status(self) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
return self._empty_indexers('missing_config')
|
||||
try:
|
||||
payload = self._request('GET', '/api/v1/indexer')
|
||||
except RuntimeError as exc:
|
||||
result = self._empty_indexers('unavailable' if str(exc) == 'unsupported_endpoint' else 'error')
|
||||
result['message'] = redact_message(str(exc))
|
||||
return result
|
||||
items_raw = payload if isinstance(payload, list) else []
|
||||
items = [self._map_indexer(item) for item in items_raw if isinstance(item, dict)]
|
||||
return {
|
||||
'success': True,
|
||||
'status': 'ok',
|
||||
'message': 'indexers_ok',
|
||||
'total': len(items),
|
||||
'enabled': sum(1 for item in items if item['enabled']),
|
||||
'disabled': sum(1 for item in items if not item['enabled']),
|
||||
'failing': sum(1 for item in items if item['status'] in {'warning', 'error'}),
|
||||
'protocols': {
|
||||
'torrent': sum(1 for item in items if item['protocol'] == 'torrent'),
|
||||
'usenet': sum(1 for item in items if item['protocol'] == 'usenet'),
|
||||
'unknown': sum(1 for item in items if item['protocol'] == 'unknown'),
|
||||
},
|
||||
'items': items,
|
||||
}
|
||||
|
||||
def application_status(self) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
|
||||
try:
|
||||
payload = self._request('GET', '/api/v1/applications')
|
||||
except RuntimeError as exc:
|
||||
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
|
||||
items_raw = payload if isinstance(payload, list) else []
|
||||
items = [self._map_application(item) for item in items_raw if isinstance(item, dict)]
|
||||
return {'success': True, 'status': 'ok', 'message': 'applications_ok', 'total': len(items), 'items': items}
|
||||
|
||||
def indexer_health(self) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'warnings': [], 'errors': []}
|
||||
try:
|
||||
payload = self._request('GET', '/api/v1/health')
|
||||
except RuntimeError as exc:
|
||||
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'warnings': [], 'errors': []}
|
||||
records = payload if isinstance(payload, list) else []
|
||||
warnings = [str(item.get('message') or item.get('source') or 'warning') for item in records if isinstance(item, dict) and str(item.get('type', '')).lower() != 'error']
|
||||
errors = [str(item.get('message') or item.get('source') or 'error') for item in records if isinstance(item, dict) and str(item.get('type', '')).lower() == 'error']
|
||||
return {
|
||||
'success': True,
|
||||
'status': 'ok' if not warnings and not errors else 'warning',
|
||||
'message': 'healthy' if not warnings and not errors else 'health_issues_detected',
|
||||
'warnings': warnings,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
def search_test(self, query: str) -> dict[str, Any]:
|
||||
clean_query = query.strip()
|
||||
if not clean_query:
|
||||
return {'success': False, 'status': 'error', 'message': 'query_required', 'total': 0, 'items': []}
|
||||
if not self.is_configured():
|
||||
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
|
||||
try:
|
||||
payload = self._request('GET', '/api/v1/search', params={'query': clean_query})
|
||||
except RuntimeError as exc:
|
||||
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
|
||||
records = payload if isinstance(payload, list) else []
|
||||
items = [self._map_search_result(item) for item in records if isinstance(item, dict)]
|
||||
return {'success': True, 'status': 'ok', 'message': 'search_ok', 'total': len(items), 'items': items}
|
||||
|
||||
def sync_status(self) -> dict[str, Any]:
|
||||
indexers = self.indexer_status()
|
||||
applications = self.application_status()
|
||||
health = self.indexer_health()
|
||||
success = bool(indexers.get('success')) and bool(applications.get('success')) and bool(health.get('success'))
|
||||
return {
|
||||
'success': success,
|
||||
'status': 'ok' if success else 'unavailable',
|
||||
'message': 'sync_status_ok' if success else 'sync_status_unavailable',
|
||||
'applicationsConfigured': int(applications.get('total', 0)),
|
||||
'indexersConfigured': int(indexers.get('total', 0)),
|
||||
'indexersEnabled': int(indexers.get('enabled', 0)),
|
||||
'indexersFailing': int(indexers.get('failing', 0)),
|
||||
'healthWarnings': len(health.get('warnings', [])),
|
||||
'healthErrors': len(health.get('errors', [])),
|
||||
'lastSync': None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _map_indexer(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
protocol = str(raw.get('protocol') or raw.get('protocolName') or 'unknown').lower()
|
||||
if protocol not in {'torrent', 'usenet'}:
|
||||
protocol = 'unknown'
|
||||
enabled = bool(raw.get('enable', raw.get('enabled', False)))
|
||||
status = str(raw.get('status') or ('healthy' if enabled else 'unknown')).lower()
|
||||
if status in {'ok', 'available'}:
|
||||
status = 'healthy'
|
||||
if status not in {'healthy', 'warning', 'error', 'unknown'}:
|
||||
status = 'unknown'
|
||||
categories_raw = raw.get('categories') if isinstance(raw.get('categories'), list) else []
|
||||
categories = [str(item.get('name') if isinstance(item, dict) else item) for item in categories_raw if item]
|
||||
return {
|
||||
'id': raw.get('id'),
|
||||
'name': str(raw.get('name') or raw.get('definitionName') or 'unknown'),
|
||||
'protocol': protocol,
|
||||
'enabled': enabled,
|
||||
'status': status,
|
||||
'categories': categories,
|
||||
'privacy': str(raw.get('privacy') or 'unknown').lower(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _map_application(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
enabled = bool(raw.get('enable', raw.get('enabled', False)))
|
||||
status = str(raw.get('status') or ('healthy' if enabled else 'unknown')).lower()
|
||||
if status not in {'healthy', 'warning', 'error', 'unknown'}:
|
||||
status = 'unknown'
|
||||
return {
|
||||
'id': raw.get('id'),
|
||||
'name': str(raw.get('name') or raw.get('implementation') or 'unknown'),
|
||||
'implementation': str(raw.get('implementation') or 'unknown'),
|
||||
'syncLevel': str(raw.get('syncLevel') or raw.get('sync_level') or 'unknown'),
|
||||
'enabled': enabled,
|
||||
'status': status,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _map_search_result(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
categories_raw = raw.get('categories') if isinstance(raw.get('categories'), list) else []
|
||||
categories = [str(item.get('name') if isinstance(item, dict) else item) for item in categories_raw if item]
|
||||
return {
|
||||
'title': str(raw.get('title') or 'unknown'),
|
||||
'indexer': str(raw.get('indexer') or raw.get('indexerName') or 'unknown'),
|
||||
'protocol': str(raw.get('protocol') or 'unknown').lower(),
|
||||
'size': raw.get('size'),
|
||||
'age': raw.get('age'),
|
||||
'seeders': _as_int(raw.get('seeders'), 0),
|
||||
'leechers': _as_int(raw.get('leechers'), 0),
|
||||
'category': categories[0] if categories else str(raw.get('category') or 'unknown'),
|
||||
'downloadAllowed': False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _empty_indexers(status: str) -> dict[str, Any]:
|
||||
return {
|
||||
'success': False,
|
||||
'status': status,
|
||||
'message': status,
|
||||
'total': 0,
|
||||
'enabled': 0,
|
||||
'disabled': 0,
|
||||
'failing': 0,
|
||||
'protocols': {'torrent': 0, 'usenet': 0, 'unknown': 0},
|
||||
'items': [],
|
||||
}
|
||||
Reference in new issue
Block a user