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