from typing import Any, Dict, Optional import logging import time import httpx from ..logging_config import sanitize_headers, sanitize_value from ..services.operation_progress import finish_remote_call, start_remote_call _SERVICE_NAMES = { "JellyseerrClient": "Seerr", "SonarrClient": "Sonarr", "RadarrClient": "Radarr", "ProwlarrClient": "Prowlarr", "JellyfinClient": "Jellyfin", "QBittorrentClient": "qBittorrent", } def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]: normalized_path = path.lower() normalized_method = method.upper() if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET": return "Reading the request from Seerr…", "Seerr returned the current request record" if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET": return "Checking Radarr for the movie…", "Radarr returned the movie record" if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET": return "Checking Sonarr for the series…", "Sonarr returned the series record" if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path: return f"Checking {service}'s download queue…", f"{service} returned its queue state" if service == "Sonarr" and "/episode" in normalized_path: return "Checking episode availability in Sonarr…", "Sonarr returned episode availability" if service in {"Radarr", "Sonarr"} and "/release" in normalized_path: return f"Checking releases through {service}…", f"{service} returned release information" if service in {"Radarr", "Sonarr"} and "/command" in normalized_path: return f"Sending a command to {service}…", f"{service} accepted the command" if service == "Prowlarr" and "/health" in normalized_path: return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health" return f"Contacting {service}…", f"{service} responded" class ApiClient: def __init__(self, base_url: Optional[str], api_key: Optional[str] = None): self.base_url = base_url.rstrip("/") if base_url else None self.api_key = api_key self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") def configured(self) -> bool: return bool(self.base_url) def headers(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key} if self.api_key else {} def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]: if response is None: return None try: payload = sanitize_value(response.json()) except ValueError: payload = sanitize_value(response.text) if isinstance(payload, str) and len(payload) > 500: return f"{payload[:500]}..." return payload async def _request( self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, payload: Optional[Dict[str, Any]] = None, timeout_seconds: float = 10.0, ) -> Optional[Any]: if not self.base_url: self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path) return None url = f"{self.base_url}{path}" started_at = time.perf_counter() service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client")) active_message, complete_message = _operation_messages(service_name, method, path) operation_event_id = start_remote_call(service_name, active_message) self.logger.debug( "outbound request started method=%s url=%s params=%s payload=%s headers=%s", method, url, sanitize_value(params), sanitize_value(payload), sanitize_headers(self.headers()), ) try: async with httpx.AsyncClient(timeout=timeout_seconds) as client: response = await client.request( method, url, headers=self.headers(), params=params, json=payload, ) response.raise_for_status() duration_ms = round((time.perf_counter() - started_at) * 1000, 2) self.logger.debug( "outbound request completed method=%s url=%s status=%s duration_ms=%s", method, url, response.status_code, duration_ms, ) result = response.json() if response.content else None finish_remote_call( operation_event_id, success=True, status_code=response.status_code, message=f"{complete_message} in {duration_ms / 1000:.1f}s.", ) return result except httpx.HTTPStatusError as exc: duration_ms = round((time.perf_counter() - started_at) * 1000, 2) response = exc.response status = response.status_code if response is not None else "unknown" log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning log_fn( "outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s", method, url, status, duration_ms, self._response_summary(response), ) finish_remote_call( operation_event_id, success=False, status_code=status if isinstance(status, int) else None, message=f"{service_name} returned HTTP {status} after {duration_ms / 1000:.1f}s.", ) raise except Exception: duration_ms = round((time.perf_counter() - started_at) * 1000, 2) self.logger.exception( "outbound request failed method=%s url=%s duration_ms=%s", method, url, duration_ms, ) finish_remote_call( operation_event_id, success=False, message=f"Magent could not get a response from {service_name} after {duration_ms / 1000:.1f}s.", ) raise async def get( self, path: str, params: Optional[Dict[str, Any]] = None, timeout_seconds: float = 10.0, ) -> Optional[Any]: return await self._request( "GET", path, params=params, timeout_seconds=timeout_seconds ) async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]: return await self._request("POST", path, payload=payload) async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]: return await self._request("PUT", path, payload=payload) async def delete(self, path: str) -> Optional[Any]: return await self._request("DELETE", path)