Show live feedback for remote request actions
This commit is contained in:
@@ -4,6 +4,39 @@ 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:
|
||||
@@ -43,6 +76,9 @@ class ApiClient:
|
||||
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,
|
||||
@@ -69,9 +105,14 @@ class ApiClient:
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
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
|
||||
@@ -85,6 +126,12 @@ class ApiClient:
|
||||
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)
|
||||
@@ -94,6 +141,11 @@ class ApiClient:
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import time
|
||||
from .base import ApiClient
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
class JellyfinClient(ApiClient):
|
||||
@@ -167,6 +169,8 @@ class JellyfinClient(ApiClient):
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"SearchTerm": term,
|
||||
@@ -175,10 +179,29 @@ class JellyfinClient(ApiClient):
|
||||
"Limit": limit,
|
||||
}
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"Jellyfin returned library availability in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"Jellyfin returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
@@ -193,9 +216,29 @@ class JellyfinClient(ApiClient):
|
||||
async def refresh_library(self, recursive: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
||||
url = f"{self.base_url}/Library/Refresh"
|
||||
headers = self._emby_headers()
|
||||
params = {"Recursive": "true" if recursive else "false"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"Jellyfin accepted the library refresh in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"Jellyfin returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import logging
|
||||
import time
|
||||
from .base import ApiClient
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
class QBittorrentClient(ApiClient):
|
||||
@@ -31,28 +33,90 @@ class QBittorrentClient(ApiClient):
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"qBittorrent returned the current download state in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.text.strip()
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.text.strip()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"qBittorrent responded in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"qBittorrent accepted the action in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def is_webui_reachable(self) -> bool:
|
||||
if not self.base_url:
|
||||
|
||||
Reference in New Issue
Block a user