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