169 lines
7.0 KiB
Python
169 lines
7.0 KiB
Python
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)
|