Explain remote service responses in plain English
This commit is contained in:
+214
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user