Resolve Arr metadata before adding media
This commit is contained in:
@@ -9,6 +9,10 @@ class RadarrClient(ApiClient):
|
|||||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
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})
|
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]]:
|
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
return await self.get(f"/api/v3/movie/{movie_id}")
|
||||||
|
|
||||||
@@ -42,9 +46,15 @@ class RadarrClient(ApiClient):
|
|||||||
root_folder: str,
|
root_folder: str,
|
||||||
monitored: bool = True,
|
monitored: bool = True,
|
||||||
search_for_movie: bool = True,
|
search_for_movie: bool = True,
|
||||||
|
title: Optional[str] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> 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 = {
|
payload = {
|
||||||
"tmdbId": tmdb_id,
|
"tmdbId": tmdb_id,
|
||||||
|
"title": resolved_title,
|
||||||
"qualityProfileId": quality_profile_id,
|
"qualityProfileId": quality_profile_id,
|
||||||
"rootFolderPath": root_folder,
|
"rootFolderPath": root_folder,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ class SonarrClient(ApiClient):
|
|||||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
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})
|
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]]:
|
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(f"/api/v3/series/{series_id}")
|
return await self.get(f"/api/v3/series/{series_id}")
|
||||||
|
|
||||||
@@ -49,16 +63,19 @@ class SonarrClient(ApiClient):
|
|||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
search_missing: bool = True,
|
search_missing: bool = True,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> 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 = {
|
payload = {
|
||||||
"tvdbId": tvdb_id,
|
"tvdbId": tvdb_id,
|
||||||
|
"title": resolved_title,
|
||||||
"qualityProfileId": quality_profile_id,
|
"qualityProfileId": quality_profile_id,
|
||||||
"rootFolderPath": root_folder,
|
"rootFolderPath": root_folder,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
"seasonFolder": True,
|
"seasonFolder": True,
|
||||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||||
}
|
}
|
||||||
if title:
|
|
||||||
payload["title"] = title
|
|
||||||
return await self.post("/api/v3/series", payload=payload)
|
return await self.post("/api/v3/series", payload=payload)
|
||||||
|
|
||||||
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -337,17 +337,25 @@ def _extract_requested_by_id(request_data: Any) -> Optional[int]:
|
|||||||
def _format_upstream_error(service: str, exc: httpx.HTTPStatusError) -> str:
|
def _format_upstream_error(service: str, exc: httpx.HTTPStatusError) -> str:
|
||||||
response = exc.response
|
response = exc.response
|
||||||
status = response.status_code if response is not None else "unknown"
|
status = response.status_code if response is not None else "unknown"
|
||||||
body = ""
|
message = ""
|
||||||
if response is not None:
|
if response is not None:
|
||||||
try:
|
try:
|
||||||
payload = response.json()
|
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:
|
except ValueError:
|
||||||
body = response.text
|
message = response.text.strip()
|
||||||
body = body.strip() if body else ""
|
if message:
|
||||||
if body:
|
compact_message = " ".join(message.split())[:500]
|
||||||
return f"{service} error {status}: {body}"
|
return f"{service} could not complete the request ({status}): {compact_message}"
|
||||||
return f"{service} error {status}."
|
return f"{service} could not complete the request ({status})."
|
||||||
|
|
||||||
|
|
||||||
def _request_display_name(request_data: Any) -> Optional[str]:
|
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
|
save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=502, detail=detail) from exc
|
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(
|
await asyncio.to_thread(
|
||||||
save_action,
|
save_action,
|
||||||
request_id,
|
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}
|
return {"status": "ok", "message": message, "movieId": movie_id}
|
||||||
root_folder = await _resolve_root_folder_path(client, runtime.radarr_root_folder, "Radarr")
|
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:
|
try:
|
||||||
response = await client.add_movie(
|
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:
|
except httpx.HTTPStatusError as exc:
|
||||||
detail = _format_upstream_error("Radarr", 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
|
save_action, request_id, "readd_to_arr", "Re-add to Sonarr/Radarr", "failed", detail
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=502, detail=detail) from exc
|
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(
|
await asyncio.to_thread(
|
||||||
save_action,
|
save_action,
|
||||||
request_id,
|
request_id,
|
||||||
|
|||||||
@@ -344,6 +344,67 @@ class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(result["torrents"][0]["progressPercent"], 13.4)
|
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):
|
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _runtime() -> SimpleNamespace:
|
def _runtime() -> SimpleNamespace:
|
||||||
|
|||||||
Reference in New Issue
Block a user