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 _RadarrConfig: url: str api_key: str verify_tls: bool default_root_folder: str | None default_quality_profile: int | None default_minimum_availability: str search_on_add: bool timeout_seconds: float 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 _as_float(value: Any, default: float) -> float: try: return float(value) except (TypeError, ValueError): return default 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 _safe_url() -> str: value = settings.apis.get('RADARR_URL') if not value: return '' return str(value).rstrip('/') class RadarrConnector(BaseConnector): name = 'Radarr' def is_configured(self) -> bool: return bool(settings.apis.get('RADARR_URL') and settings.apis.get('RADARR_API_KEY')) def _config(self) -> _RadarrConfig | None: if not self.is_configured(): return None return _RadarrConfig( url=str(settings.apis['RADARR_URL']).rstrip('/'), api_key=str(settings.apis['RADARR_API_KEY']), verify_tls=_as_bool(settings.apis.get('RADARR_VERIFY_TLS'), False), default_root_folder=( str(settings.apis.get('RADARR_DEFAULT_ROOT_FOLDER')) if settings.apis.get('RADARR_DEFAULT_ROOT_FOLDER') else None ), default_quality_profile=( _as_int(settings.apis.get('RADARR_DEFAULT_QUALITY_PROFILE')) if settings.apis.get('RADARR_DEFAULT_QUALITY_PROFILE') else None ), default_minimum_availability=str(settings.apis.get('RADARR_DEFAULT_MINIMUM_AVAILABILITY') or 'released'), search_on_add=_as_bool(settings.apis.get('RADARR_SEARCH_ON_ADD'), True), timeout_seconds=_as_float(settings.apis.get('RADARR_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 >= 500: message = f'Radarr server error: HTTP {response.status_code}' self.set_error(message) raise RuntimeError(message) if response.status_code >= 400: message = redact_message(f'Radarr 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/v3/system/status') except RuntimeError as exc: reason = str(exc) if reason == 'missing_config': return {'status': 'missing_config', 'reason': reason} return {'status': 'unreachable', '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': ['RADARR_URL', 'RADARR_API_KEY'], } result = self.test_connection() status = str(result.get('status', 'error')) return { 'success': status == 'connected', 'status': status, 'message': 'Radarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), 'version': str(result.get('version', 'unknown')), 'url': _safe_url(), } def search_movie(self, query: str) -> dict[str, Any]: if not self.is_configured(): return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': []} try: payload = self._request('GET', '/api/v3/movie/lookup', params={'term': query}) except RuntimeError as exc: return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'items': []} if not isinstance(payload, list): return {'success': False, 'status': 'error', 'message': 'unexpected_payload', 'items': []} return { 'success': True, 'status': 'connected', 'items': [self._map_movie(item) for item in payload if isinstance(item, dict)], } def queue_status(self) -> dict[str, Any]: if not self.is_configured(): return self._empty_queue('missing_config') try: payload = self._request('GET', '/api/v3/queue') except RuntimeError as exc: result = self._empty_queue('unavailable') result['message'] = redact_message(str(exc)) return result records = payload.get('records', []) if isinstance(payload, dict) else [] if not isinstance(records, list): records = [] items = [self._map_queue_item(item) for item in records if isinstance(item, dict)] return { 'success': True, 'status': 'ok', 'total': int(payload.get('totalRecords', len(items))) if isinstance(payload, dict) else len(items), 'downloading': sum(1 for item in items if item['status'] == 'downloading'), 'queued': sum(1 for item in items if item['status'] == 'queued'), 'failed': sum(1 for item in items if item['trackedDownloadStatus'] == 'error' or item['status'] == 'failed'), 'stalled': sum(1 for item in items if item['status'] == 'stalled'), 'items': items, } def wanted_missing(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/v3/wanted/missing', params={'page': 1, 'pageSize': 20}) except RuntimeError as exc: return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []} records = payload.get('records', []) if isinstance(payload, dict) else [] if not isinstance(records, list): records = [] return { 'success': True, 'status': 'connected', 'total': int(payload.get('totalRecords', len(records))) if isinstance(payload, dict) else len(records), 'items': records[:20], } def add_movie(self, payload: dict[str, Any]) -> dict[str, Any]: config = self._config() if not config: return {'success': False, 'status': 'missing_config', 'message': 'missing_config'} title = str(payload.get('title') or '').strip() tmdb_id = _as_int(payload.get('tmdbId') or payload.get('tmdb_id')) if not title or not tmdb_id: return {'success': False, 'status': 'error', 'message': 'title_and_tmdbId_required'} root_folder = str(payload.get('rootFolderPath') or config.default_root_folder or '').strip() quality_profile_id = _as_int(payload.get('qualityProfileId'), config.default_quality_profile or 0) minimum_availability = str( payload.get('minimumAvailability') or config.default_minimum_availability or 'released' ).strip() if not root_folder or not quality_profile_id: return {'success': False, 'status': 'missing_config', 'message': 'missing_defaults'} search_on_add = _as_bool(payload.get('searchForMovie'), config.search_on_add) request_payload = { **payload, 'title': title, 'tmdbId': tmdb_id, 'rootFolderPath': root_folder, 'qualityProfileId': quality_profile_id, 'monitored': _as_bool(payload.get('monitored'), True), 'minimumAvailability': minimum_availability, 'searchForMovie': search_on_add, 'addOptions': { 'searchForMovie': search_on_add, }, } try: result = self._request('POST', '/api/v3/movie', json=request_payload) except RuntimeError as exc: return {'success': False, 'status': 'error', 'message': redact_message(str(exc))} return { 'success': True, 'status': 'connected', 'message': 'movie_added', 'data': result if isinstance(result, dict) else {}, } @staticmethod def _map_movie(raw: dict[str, Any]) -> dict[str, Any]: return { 'id': _as_int(raw.get('id')), 'tmdbId': _as_int(raw.get('tmdbId')), 'imdbId': str(raw.get('imdbId', '')), 'title': str(raw.get('title', '')), 'year': _as_int(raw.get('year')), 'overview': str(raw.get('overview', '')), 'status': str(raw.get('status', 'unknown')), 'remotePoster': str(raw.get('remotePoster', '')), 'existing': bool(raw.get('id') and raw.get('path')), 'monitored': _as_bool(raw.get('monitored'), False), 'minimumAvailability': str(raw.get('minimumAvailability', 'unknown')), } @staticmethod def _map_queue_item(raw: dict[str, Any]) -> dict[str, Any]: movie = raw.get('movie') if isinstance(raw.get('movie'), dict) else {} return { 'id': raw.get('id'), 'title': str(raw.get('title') or movie.get('title') or 'unknown'), 'status': str(raw.get('status', 'unknown')).lower(), 'trackedDownloadStatus': str(raw.get('trackedDownloadStatus', 'unknown')).lower(), 'size': raw.get('size'), 'sizeleft': raw.get('sizeleft'), } @staticmethod def _empty_queue(status: str) -> dict[str, Any]: return { 'success': False, 'status': status, 'message': status, 'total': 0, 'downloading': 0, 'queued': 0, 'failed': 0, 'stalled': 0, 'items': [], }