From ae6cee5d0b7292f6672942dc59b55db36ab288bf Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Mon, 31 Aug 2026 22:28:56 +1200 Subject: [PATCH] Explain remote service responses in plain English --- backend/app/clients/base.py | 218 +++++++++++++++++++++++++- backend/app/clients/jellyfin.py | 26 ++- backend/app/clients/qbittorrent.py | 64 +++++++- backend/tests/test_backend_quality.py | 77 +++++++++ 4 files changed, 369 insertions(+), 16 deletions(-) diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 81ad517..1a234e4 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -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 diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py index 088313c..0e5a478 100644 --- a/backend/app/clients/jellyfin.py +++ b/backend/app/clients/jellyfin.py @@ -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 diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index c11e126..52d5c69 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -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 diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index ec06348..9b67453 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -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(