diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py index dcd15d0..b64d196 100644 --- a/backend/app/clients/radarr.py +++ b/backend/app/clients/radarr.py @@ -9,6 +9,10 @@ class RadarrClient(ApiClient): async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]: return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id}) + async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]: + result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id}) + return result if isinstance(result, dict) else None + async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]: return await self.get(f"/api/v3/movie/{movie_id}") @@ -42,9 +46,15 @@ class RadarrClient(ApiClient): root_folder: str, monitored: bool = True, search_for_movie: bool = True, + title: Optional[str] = None, ) -> Optional[Dict[str, Any]]: + lookup = await self.lookup_movie_by_tmdb_id(tmdb_id) + resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip() + if not resolved_title: + raise ValueError("Radarr could not resolve a title for this TMDB ID") payload = { "tmdbId": tmdb_id, + "title": resolved_title, "qualityProfileId": quality_profile_id, "rootFolderPath": root_folder, "monitored": monitored, diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index f541081..5686ba2 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -9,6 +9,20 @@ class SonarrClient(ApiClient): async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]: return await self.get("/api/v3/series", params={"tvdbId": tvdb_id}) + async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]: + result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"}) + if not isinstance(result, list): + return None + for item in result: + if not isinstance(item, dict): + continue + try: + if int(item.get("tvdbId")) == tvdb_id: + return item + except (TypeError, ValueError): + continue + return next((item for item in result if isinstance(item, dict)), None) + async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]: return await self.get(f"/api/v3/series/{series_id}") @@ -49,16 +63,19 @@ class SonarrClient(ApiClient): title: Optional[str] = None, search_missing: bool = True, ) -> Optional[Dict[str, Any]]: + lookup = await self.lookup_series_by_tvdb_id(tvdb_id) + resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip() + if not resolved_title: + raise ValueError("Sonarr could not resolve a title for this TVDB ID") payload = { "tvdbId": tvdb_id, + "title": resolved_title, "qualityProfileId": quality_profile_id, "rootFolderPath": root_folder, "monitored": monitored, "seasonFolder": True, "addOptions": {"searchForMissingEpisodes": search_missing}, } - if title: - payload["title"] = title return await self.post("/api/v3/series", payload=payload) async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 8d8776d..8fb17c7 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -337,17 +337,25 @@ def _extract_requested_by_id(request_data: Any) -> Optional[int]: def _format_upstream_error(service: str, exc: httpx.HTTPStatusError) -> str: response = exc.response status = response.status_code if response is not None else "unknown" - body = "" + message = "" if response is not None: try: payload = response.json() - body = json.dumps(payload, ensure_ascii=True) + if isinstance(payload, dict): + message = str(payload.get("message") or payload.get("error") or "").strip() + elif isinstance(payload, list): + validation_messages = [ + str(item.get("errorMessage") or item.get("message") or "").strip() + for item in payload + if isinstance(item, dict) + ] + message = "; ".join(item for item in validation_messages if item) except ValueError: - body = response.text - body = body.strip() if body else "" - if body: - return f"{service} error {status}: {body}" - return f"{service} error {status}." + message = response.text.strip() + if message: + compact_message = " ".join(message.split())[:500] + return f"{service} could not complete the request ({status}): {compact_message}" + return f"{service} could not complete the request ({status})." def _request_display_name(request_data: Any) -> Optional[str]: @@ -2318,6 +2326,12 @@ async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_curre save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail ) raise HTTPException(status_code=502, detail=detail) from exc + except ValueError as exc: + detail = f"Sonarr could not add this series: {exc}" + await asyncio.to_thread( + save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail + ) + raise HTTPException(status_code=502, detail=detail) from exc await asyncio.to_thread( save_action, request_id, @@ -2353,9 +2367,17 @@ async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_curre ) return {"status": "ok", "message": message, "movieId": movie_id} root_folder = await _resolve_root_folder_path(client, runtime.radarr_root_folder, "Radarr") + title = snapshot.title + if title in {None, "", "Unknown"}: + title = ( + media.get("title") + or media.get("name") + or jelly.get("title") + or jelly.get("name") + ) try: response = await client.add_movie( - int(tmdb_id), runtime.radarr_quality_profile_id, root_folder + int(tmdb_id), runtime.radarr_quality_profile_id, root_folder, title=title ) except httpx.HTTPStatusError as exc: detail = _format_upstream_error("Radarr", exc) @@ -2363,6 +2385,12 @@ async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_curre save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail ) raise HTTPException(status_code=502, detail=detail) from exc + except ValueError as exc: + detail = f"Radarr could not add this movie: {exc}" + await asyncio.to_thread( + save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail + ) + raise HTTPException(status_code=502, detail=detail) from exc await asyncio.to_thread( save_action, request_id, diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 5e2156d..f368f47 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -344,6 +344,67 @@ class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result["torrents"][0]["progressPercent"], 13.4) +class ArrAddPayloadTests(unittest.IsolatedAsyncioTestCase): + async def test_radarr_add_resolves_title_before_posting_movie(self) -> None: + client = requests_router.RadarrClient("http://radarr.test", "radarr-key") + with patch.object( + client, + "get", + new=AsyncMock(return_value={"title": "A Grand Day Out", "tmdbId": 530}), + ) as lookup, patch.object( + client, + "post", + new=AsyncMock(return_value={"id": 12, "title": "A Grand Day Out"}), + ) as create: + result = await client.add_movie(530, 1, "/movies") + + lookup.assert_awaited_once_with("/api/v3/movie/lookup/tmdb", params={"tmdbId": 530}) + payload = create.await_args.kwargs["payload"] + self.assertEqual(payload["title"], "A Grand Day Out") + self.assertEqual(payload["tmdbId"], 530) + self.assertEqual(result["id"], 12) + + async def test_sonarr_add_resolves_matching_series_title_before_posting(self) -> None: + client = requests_router.SonarrClient("http://sonarr.test", "sonarr-key") + lookup_response = [ + {"title": "Wrong Show", "tvdbId": 111}, + {"title": "Example Show", "tvdbId": 222}, + ] + with patch.object( + client, + "get", + new=AsyncMock(return_value=lookup_response), + ) as lookup, patch.object( + client, + "post", + new=AsyncMock(return_value={"id": 42, "title": "Example Show"}), + ) as create: + result = await client.add_series(222, 2, "/television") + + lookup.assert_awaited_once_with("/api/v3/series/lookup", params={"term": "tvdb:222"}) + payload = create.await_args.kwargs["payload"] + self.assertEqual(payload["title"], "Example Show") + self.assertEqual(payload["tvdbId"], 222) + self.assertEqual(result["id"], 42) + + def test_arr_error_message_does_not_expose_upstream_stack_trace(self) -> None: + response = httpx.Response( + 500, + request=httpx.Request("POST", "http://radarr.test/api/v3/movie"), + json={ + "message": "Object reference not set to an instance of an object.", + "description": "System.NullReferenceException\n at Radarr.Internal.SecretMethod()", + }, + ) + error = httpx.HTTPStatusError("Radarr failed", request=response.request, response=response) + + message = requests_router._format_upstream_error("Radarr", error) + + self.assertIn("Object reference", message) + self.assertNotIn("NullReferenceException", message) + self.assertNotIn("SecretMethod", message) + + class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): @staticmethod def _runtime() -> SimpleNamespace: