Explain remote service responses in plain English
Magent CI/CD / verify (push) Successful in 10m20s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s

This commit is contained in:
2026-08-31 22:28:56 +12:00
parent 906a777b95
commit ae6cee5d0b
4 changed files with 369 additions and 16 deletions
+214 -4
View File
@@ -17,6 +17,206 @@ _SERVICE_NAMES = {
}
def _result_items(result: Any, *keys: str) -> list[Any]:
if isinstance(result, list):
return result
if not isinstance(result, dict):
return []
for key in keys:
value = result.get(key)
if isinstance(value, list):
return value
return []
def _result_title(result: Any, payload: Optional[Dict[str, Any]] = None) -> Optional[str]:
candidates = result if isinstance(result, list) else [result]
for candidate in candidates:
if not isinstance(candidate, dict):
continue
title = str(candidate.get("title") or candidate.get("name") or "").strip()
if title:
return title
if isinstance(payload, dict):
title = str(payload.get("title") or payload.get("name") or "").strip()
if title:
return title
return None
def _count_message(count: int, singular: str, plural: Optional[str] = None) -> str:
noun = singular if count == 1 else (plural or f"{singular}s")
return f"{count} {noun}"
def _queue_result_message(service: str, result: Any) -> str:
records = _result_items(result, "records", "items")
total = result.get("totalRecords") if isinstance(result, dict) else None
count = int(total) if isinstance(total, int) else len(records)
if count == 0:
return f"{service} has no matching downloads in its queue."
first = next((item for item in records if isinstance(item, dict)), None)
progress_text = ""
if first:
size = first.get("size")
size_left = first.get("sizeleft")
if isinstance(size, (int, float)) and size > 0 and isinstance(size_left, (int, float)):
progress = max(0, min(100, round((1 - (size_left / size)) * 100)))
progress_text = f" The first is {progress}% complete."
return f"{service} found {_count_message(count, 'matching download')} in its queue.{progress_text}"
def _command_name(payload: Optional[Dict[str, Any]]) -> str:
raw_name = str((payload or {}).get("name") or "").strip()
names = {
"MoviesSearch": "movie search",
"SeriesSearch": "series search",
"EpisodeSearch": "episode search",
"DownloadRelease": "release download",
"RefreshMovie": "movie refresh",
"RescanMovie": "movie rescan",
"RefreshSeries": "series refresh",
"RescanSeries": "series rescan",
}
return names.get(raw_name, "command")
def _operation_result_message(
service: str,
method: str,
path: str,
result: Any,
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> str:
normalized_path = path.lower().split("?", 1)[0].rstrip("/")
normalized_method = method.upper()
title = _result_title(result, payload)
title_text = f' "{title}"' if title else ""
if service == "Seerr":
if normalized_path.endswith("/request") and normalized_method == "POST":
request_id = result.get("id") if isinstance(result, dict) else None
suffix = f" #{request_id}" if isinstance(request_id, int) else ""
return f"Seerr created the request{suffix} and passed it into the collection workflow."
if "/request/" in normalized_path and normalized_method == "GET":
status_names = {1: "waiting for approval", 2: "approved", 3: "declined"}
status = result.get("status") if isinstance(result, dict) else None
status_text = status_names.get(status)
return (
f"Seerr found the request; it is currently {status_text}."
if status_text
else "Seerr found the request and returned its current status."
)
if service in {"Radarr", "Sonarr"}:
media_name = "movie" if service == "Radarr" else "series"
media_path = "/movie" if service == "Radarr" else "/series"
if "/queue" in normalized_path and normalized_method == "GET":
return _queue_result_message(service, result)
if "/command" in normalized_path and normalized_method == "POST":
return f"{service} accepted the {_command_name(payload)} and queued it for processing."
if "/release" in normalized_path:
if normalized_method == "GET":
count = len(_result_items(result, "records", "items"))
return (
f"{service} found {_count_message(count, 'download option')}."
if count
else f"{service} could not find a suitable download option."
)
return f"{service} accepted the selected release and sent it to the download client."
if "/qualityprofile" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'available quality profile')}."
if "/rootfolder" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'configured library location')}."
if "/indexer" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} reports {_count_message(count, 'configured search source')}."
if service == "Sonarr" and "/episodefile" in normalized_path:
if normalized_method == "DELETE":
return "Sonarr removed the existing episode file so it can be replaced."
count = len(_result_items(result))
return f"Sonarr found {_count_message(count, 'downloaded episode file')}."
if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET":
episodes = _result_items(result)
available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
return f"Sonarr reports {available} of {len(episodes)} episodes downloaded."
if service == "Radarr" and "/moviefile/" in normalized_path and normalized_method == "DELETE":
return "Radarr removed the existing movie file so it can be replaced."
is_media_endpoint = normalized_path.endswith(media_path) or f"{media_path}/" in normalized_path
if is_media_endpoint:
if normalized_method == "GET":
found = bool(result) if not isinstance(result, list) else len(result) > 0
return (
f"{service} found{title_text} in its library list."
if found
else f"This {media_name} is not currently in {service}."
)
if normalized_method == "POST":
search_key = "searchForMovie" if service == "Radarr" else "searchForMissingEpisodes"
search_requested = bool(((payload or {}).get("addOptions") or {}).get(search_key))
search_text = " and started looking for a download" if search_requested else ""
subject = title_text or f" the {media_name}"
return f"{service} added{subject}{search_text}."
if normalized_method == "PUT":
return f"{service} saved the updated settings for{title_text or f' the {media_name}'}."
if "/system/status" in normalized_path:
version = str(result.get("version") or "").strip() if isinstance(result, dict) else ""
return f"Connected to {service}{f' version {version}' if version else ''}."
if service == "Prowlarr":
if "/health" in normalized_path:
issues = _result_items(result)
if not issues:
return "Prowlarr reports that all configured indexers are healthy."
first = next((item for item in issues if isinstance(item, dict)), {})
detail = str(first.get("message") or first.get("source") or "").strip()
suffix = f" First issue: {detail}" if detail else ""
return f"Prowlarr reports {_count_message(len(issues), 'indexer issue')}.{suffix}"
if "/search" in normalized_path:
results = _result_items(result, "results", "records")
return (
f"Prowlarr found {_count_message(len(results), 'possible release')}."
if results
else "Prowlarr did not find any possible releases."
)
if normalized_method == "GET":
return f"{service} completed the check successfully."
if normalized_method == "POST":
return f"{service} accepted the request and started processing it."
if normalized_method == "PUT":
return f"{service} saved the requested changes."
if normalized_method == "DELETE":
return f"{service} confirmed the item was removed."
return f"{service} completed the request successfully."
def _operation_error_message(service: str, status_code: Optional[int]) -> str:
explanations = {
400: "rejected the request because some details were invalid",
401: "rejected Magent's login details",
403: "refused permission for this action",
404: "could not find the requested item",
409: "reported a conflict, usually because the item already exists",
422: "could not use the details Magent supplied",
429: "is busy and asked Magent to try again later",
500: "encountered an internal error while processing the request",
502: "could not reach one of its own dependent services",
503: "is temporarily unavailable",
504: "did not finish before the request timed out",
}
explanation = explanations.get(status_code)
if explanation:
return f"{service} {explanation}."
if status_code:
return f"{service} could not complete the request (response code {status_code})."
return f"Magent could not get a usable response from {service}."
def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
normalized_path = path.lower()
normalized_method = method.upper()
@@ -95,7 +295,7 @@ class ApiClient:
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)
active_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",
@@ -129,7 +329,14 @@ class ApiClient:
operation_event_id,
success=True,
status_code=response.status_code,
message=f"{complete_message} in {duration_ms / 1000:.1f}s.",
message=_operation_result_message(
service_name,
method,
path,
result,
params=params,
payload=payload,
),
)
return result
except httpx.HTTPStatusError as exc:
@@ -149,7 +356,10 @@ class ApiClient:
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.",
message=_operation_error_message(
service_name,
status if isinstance(status, int) else None,
),
)
raise
except Exception:
@@ -163,7 +373,7 @@ class ApiClient:
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.",
message=_operation_error_message(service_name, None),
)
raise
+21 -5
View File
@@ -1,10 +1,26 @@
from typing import Any, Dict, Optional
import httpx
import time
from .base import ApiClient
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
def _availability_message(result: Any) -> str:
if not isinstance(result, dict):
return "Jellyfin did not return any matching library items."
total = result.get("TotalRecordCount")
items = result.get("Items")
available = (
(isinstance(total, int) and total > 0)
or (isinstance(items, list) and len(items) > 0)
)
return (
"The title is available to watch in Jellyfin."
if available
else "The title is not currently available in Jellyfin."
)
class JellyfinClient(ApiClient):
def __init__(self, base_url: Optional[str], api_key: Optional[str]):
super().__init__(base_url, api_key)
@@ -189,7 +205,7 @@ class JellyfinClient(ApiClient):
operation_event_id,
success=True,
status_code=response.status_code,
message=f"Jellyfin returned library availability in {duration_ms / 1000:.1f}s.",
message=_availability_message(result),
)
return result
except Exception as exc:
@@ -199,7 +215,7 @@ class JellyfinClient(ApiClient):
operation_event_id,
success=False,
status_code=status_code,
message=f"Jellyfin returned an error after {duration_ms / 1000:.1f}s.",
message=_operation_error_message("Jellyfin", status_code),
)
raise
@@ -241,7 +257,7 @@ class JellyfinClient(ApiClient):
operation_event_id,
success=True,
status_code=response.status_code,
message=f"Jellyfin accepted the library refresh in {duration_ms / 1000:.1f}s.",
message="Jellyfin accepted the library refresh and is scanning for new media.",
)
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
@@ -250,6 +266,6 @@ class JellyfinClient(ApiClient):
operation_event_id,
success=False,
status_code=status_code,
message=f"Jellyfin returned an error after {duration_ms / 1000:.1f}s.",
message=_operation_error_message("Jellyfin", status_code),
)
raise
+57 -7
View File
@@ -2,10 +2,60 @@ from typing import Any, Dict, Optional
import httpx
import logging
import time
from .base import ApiClient
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
def _torrent_state_text(state: Any) -> str:
normalized = str(state or "").strip().lower()
if "pause" in normalized:
return "paused"
if "stall" in normalized:
return "stalled"
if normalized.startswith("queued"):
return "waiting in the queue"
if "downloading" in normalized or normalized in {"forcedl", "metadl", "checkingdl"}:
return "downloading"
if "upload" in normalized or normalized in {"stalledup", "forcedup"}:
return "finished and seeding"
if normalized in {"completed", "missingfiles"}:
return "finished" if normalized == "completed" else "missing files"
if "error" in normalized:
return "in an error state"
return "present"
def _torrent_result_message(result: Any) -> str:
torrents = result if isinstance(result, list) else []
if not torrents:
return "qBittorrent found no matching downloads."
first = next((item for item in torrents if isinstance(item, dict)), {})
if len(torrents) == 1:
name = str(first.get("name") or "the matching download").strip()
progress = first.get("progress")
progress_text = (
f" and {max(0, min(100, round(progress * 100)))}% complete"
if isinstance(progress, (int, float))
else ""
)
return f'qBittorrent found "{name}"; it is {_torrent_state_text(first.get("state"))}{progress_text}.'
active = sum(
1
for item in torrents
if isinstance(item, dict) and _torrent_state_text(item.get("state")) == "downloading"
)
return f"qBittorrent found {len(torrents)} matching downloads; {active} are actively downloading."
def _torrent_action_message(path: str) -> str:
normalized_path = path.lower()
if normalized_path.endswith("/resume") or normalized_path.endswith("/start"):
return "qBittorrent accepted the request to resume the download."
if normalized_path.endswith("/add"):
return "qBittorrent accepted the release and added it to the download queue."
return "qBittorrent accepted the requested download action."
class QBittorrentClient(ApiClient):
def __init__(self, base_url: Optional[str], username: Optional[str], password: Optional[str]):
super().__init__(base_url, None)
@@ -46,7 +96,7 @@ class QBittorrentClient(ApiClient):
operation_event_id,
success=True,
status_code=response.status_code,
message=f"qBittorrent returned the current download state in {duration_ms / 1000:.1f}s.",
message=_torrent_result_message(result),
)
return result
except Exception as exc:
@@ -56,7 +106,7 @@ class QBittorrentClient(ApiClient):
operation_event_id,
success=False,
status_code=status_code,
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
message=_operation_error_message("qBittorrent", status_code),
)
raise
@@ -76,7 +126,7 @@ class QBittorrentClient(ApiClient):
operation_event_id,
success=True,
status_code=response.status_code,
message=f"qBittorrent responded in {duration_ms / 1000:.1f}s.",
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
)
return result
except Exception as exc:
@@ -86,7 +136,7 @@ class QBittorrentClient(ApiClient):
operation_event_id,
success=False,
status_code=status_code,
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
message=_operation_error_message("qBittorrent", status_code),
)
raise
@@ -105,7 +155,7 @@ class QBittorrentClient(ApiClient):
operation_event_id,
success=True,
status_code=response.status_code,
message=f"qBittorrent accepted the action in {duration_ms / 1000:.1f}s.",
message=_torrent_action_message(path),
)
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
@@ -114,7 +164,7 @@ class QBittorrentClient(ApiClient):
operation_event_id,
success=False,
status_code=status_code,
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
message=_operation_error_message("qBittorrent", status_code),
)
raise
+77
View File
@@ -9,6 +9,9 @@ from fastapi import HTTPException
from starlette.requests import Request
from backend.app import db
from backend.app.clients.base import _operation_error_message, _operation_result_message
from backend.app.clients.jellyfin import _availability_message
from backend.app.clients.qbittorrent import _torrent_result_message
from backend.app.auth import require_admin
from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
@@ -187,6 +190,80 @@ class OperationProgressTests(unittest.TestCase):
self.assertEqual(completed["events"][-1]["service"], "Magent")
class OperationMessageTests(unittest.TestCase):
def test_radarr_lookup_explains_whether_movie_was_found(self) -> None:
found = _operation_result_message(
"Radarr",
"GET",
"/api/v3/movie",
[{"title": "Arrival"}],
)
missing = _operation_result_message(
"Radarr",
"GET",
"/api/v3/movie",
[],
)
self.assertEqual(found, 'Radarr found "Arrival" in its library list.')
self.assertEqual(missing, "This movie is not currently in Radarr.")
def test_radarr_add_explains_that_download_search_started(self) -> None:
message = _operation_result_message(
"Radarr",
"POST",
"/api/v3/movie",
{"title": "Arrival", "id": 42},
payload={
"title": "Arrival",
"addOptions": {"searchForMovie": True},
},
)
self.assertEqual(message, 'Radarr added "Arrival" and started looking for a download.')
def test_queue_and_indexer_health_results_are_summarized(self) -> None:
queue_message = _operation_result_message(
"Sonarr",
"GET",
"/api/v3/queue",
{"totalRecords": 0, "records": []},
)
health_message = _operation_result_message(
"Prowlarr",
"GET",
"/api/v1/health",
[],
)
self.assertEqual(queue_message, "Sonarr has no matching downloads in its queue.")
self.assertEqual(health_message, "Prowlarr reports that all configured indexers are healthy.")
def test_download_and_jellyfin_results_include_actual_state(self) -> None:
torrent_message = _torrent_result_message(
[{"name": "Arrival.2016", "state": "downloading", "progress": 0.42}]
)
self.assertEqual(
torrent_message,
'qBittorrent found "Arrival.2016"; it is downloading and 42% complete.',
)
self.assertEqual(
_availability_message({"TotalRecordCount": 0, "Items": []}),
"The title is not currently available in Jellyfin.",
)
def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None:
self.assertEqual(
_operation_error_message("Radarr", 500),
"Radarr encountered an internal error while processing the request.",
)
self.assertEqual(
_operation_error_message("Sonarr", 401),
"Sonarr rejected Magent's login details.",
)
class SiteInfoTests(unittest.TestCase):
def test_site_public_exposes_requests_navigation_toggle(self) -> None:
runtime = SimpleNamespace(