Files
Unraid-AppOps-Gateway-Public/backend/app/connectors/radarr.py
T

315 lines
12 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
@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': [],
}