diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index ecf025f..f9eb3e4 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -400,6 +400,43 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]: } +def _merge_request_media_details( + request_payload: Dict[str, Any], details: Dict[str, Any] +) -> Dict[str, Any]: + """Fill display metadata omitted by Seerr's request mutation/detail payloads.""" + merged = dict(request_payload) + media = request_payload.get("media") + media = dict(media) if isinstance(media, dict) else {} + media_type = _normalize_media_type( + media.get("mediaType") or request_payload.get("mediaType") or request_payload.get("type") + ) + + title = details.get("title") or details.get("name") + if title and not (media.get("title") or media.get("name")): + if media_type == "tv": + media["name"] = title + else: + media["title"] = title + + date_value = details.get("releaseDate") or details.get("firstAirDate") + if not media.get("year") and isinstance(date_value, str) and date_value[:4].isdigit(): + media["year"] = int(date_value[:4]) + + for camel_key, snake_key in ( + ("posterPath", "poster_path"), + ("backdropPath", "backdrop_path"), + ): + if not (media.get(camel_key) or media.get(snake_key)): + value = details.get(camel_key) or details.get(snake_key) + if value: + media[camel_key] = value + + if media_type and not media.get("mediaType"): + media["mediaType"] = media_type + merged["media"] = media + return merged + + def _extract_artwork_paths(item: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]: media = item.get("media") or {} poster_path = None @@ -694,6 +731,15 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt cache_key = f"request:{request_id}" cached = _cache_get(cache_key) if isinstance(cached, dict): + parsed_cached = _parse_request_payload(cached) + if parsed_cached.get("title"): + return cached + details = await _get_media_details( + client, parsed_cached.get("media_type"), parsed_cached.get("tmdb_id") + ) + if isinstance(details, dict): + cached = _merge_request_media_details(cached, details) + _cache_set(cache_key, cached) return cached if _failure_cache_has(cache_key): return None @@ -703,6 +749,13 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt _failure_cache_set(cache_key) return None if isinstance(fetched, dict): + parsed_fetched = _parse_request_payload(fetched) + if not parsed_fetched.get("title"): + details = await _get_media_details( + client, parsed_fetched.get("media_type"), parsed_fetched.get("tmdb_id") + ) + if isinstance(details, dict): + fetched = _merge_request_media_details(fetched, details) _cache_set(cache_key, fetched) return fetched return None @@ -1757,6 +1810,13 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur raise HTTPException(status_code=404, detail="Request not found in Seerr") parsed = _parse_request_payload(fresh_request) + if not parsed.get("title"): + details = await _get_media_details( + seerr, parsed.get("media_type"), parsed.get("tmdb_id") + ) + if isinstance(details, dict): + fresh_request = _merge_request_media_details(fresh_request, details) + parsed = _parse_request_payload(fresh_request) if parsed.get("request_id") != int(request_id): raise HTTPException(status_code=502, detail="Seerr returned an unexpected request record") @@ -2305,6 +2365,7 @@ async def create_request( if not isinstance(created, dict): raise HTTPException(status_code=502, detail="Invalid response from Seerr request create") + created = _merge_request_media_details(created, details) parsed = _parse_request_payload(created) request_id = _quality_profile_id(parsed.get("request_id")) status_code = parsed.get("status") diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index 8b45ed2..ba15a9d 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -59,6 +59,22 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]: return None +def _apply_arr_identity(snapshot: Snapshot, arr_item: Any) -> None: + """Use the collector's authoritative identity when cached Seerr metadata is sparse.""" + if not isinstance(arr_item, dict): + return + if snapshot.title in {None, "", "Unknown"}: + title = arr_item.get("title") or arr_item.get("seriesTitle") + if isinstance(title, str) and title.strip(): + snapshot.title = title.strip() + if not snapshot.year: + year = arr_item.get("year") + try: + snapshot.year = int(year) if year else snapshot.year + except (TypeError, ValueError): + pass + + def _normalize_media_title(value: Any) -> Optional[str]: if not isinstance(value, str): return None @@ -821,7 +837,7 @@ async def build_snapshot(request_id: str) -> Snapshot: poster_path = media.get("posterPath") or media.get("poster_path") backdrop_path = media.get("backdropPath") or media.get("backdrop_path") - if snapshot.title in {None, "", "Unknown"} and allow_remote: + if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured(): tmdb_id = jelly_request.get("media", {}).get("tmdbId") if tmdb_id: details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id)) @@ -971,6 +987,7 @@ async def build_snapshot(request_id: str) -> Snapshot: if arr_state is None: arr_state = "unknown" + _apply_arr_identity(snapshot, arr_item) timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details)) prowlarr_state = "unknown" diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index fd75063..8bda7b7 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -28,7 +28,12 @@ from backend.app.services.operation_progress import ( reset_operation, start_remote_call, ) -from backend.app.services.snapshot import _build_presentation, _episode_availability, _torrent_progress +from backend.app.services.snapshot import ( + _apply_arr_identity, + _build_presentation, + _episode_availability, + _torrent_progress, +) def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request: @@ -388,6 +393,27 @@ class RequestPresentationTests(unittest.TestCase): class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase): + def test_sparse_seerr_request_is_enriched_with_media_lookup(self) -> None: + sparse = { + "id": 3925, + "type": "movie", + "status": 2, + "media": {"id": 2444, "mediaType": "movie", "tmdbId": 209112}, + } + details = { + "title": "Batman v Superman: Dawn of Justice", + "releaseDate": "2016-03-23", + "posterPath": "/poster.jpg", + } + + enriched = requests_router._merge_request_media_details(sparse, details) + parsed = requests_router._parse_request_payload(enriched) + + self.assertEqual(parsed["title"], "Batman v Superman: Dawn of Justice") + self.assertEqual(parsed["year"], 2016) + self.assertEqual(enriched["media"]["posterPath"], "/poster.jpg") + self.assertNotIn("title", sparse["media"]) + async def test_seerr_search_percent_encodes_multi_word_titles(self) -> None: client = requests_router.JellyseerrClient("http://seerr.test", "key") client.get = AsyncMock(return_value={"results": []}) @@ -623,6 +649,62 @@ class RequestRecheckTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result["status"], "ok") self.assertIs(result["snapshot"], snapshot) + async def test_recheck_hydrates_sparse_seerr_request_before_caching(self) -> None: + runtime = SimpleNamespace(jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="key") + sparse_request = { + "id": 3925, + "type": "movie", + "status": 2, + "requestedBy": {"username": "viewer"}, + "media": {"id": 2444, "mediaType": "movie", "tmdbId": 209112}, + } + seerr = SimpleNamespace( + configured=lambda: True, + get_request=AsyncMock(return_value=sparse_request), + get_movie=AsyncMock( + return_value={ + "title": "Batman v Superman: Dawn of Justice", + "releaseDate": "2016-03-23", + } + ), + ) + snapshot = Snapshot( + request_id="3925", + title="Batman v Superman: Dawn of Justice", + request_type=RequestType.movie, + state=NormalizedState.downloading, + presentation={"status": {"label": "Download in progress"}}, + ) + + with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object( + requests_router, "JellyseerrClient", return_value=seerr + ), patch.object(requests_router, "upsert_request_cache") as upsert, patch.object( + requests_router, "_cache_set" + ) as cache_set, patch.object(requests_router, "_refresh_recent_cache_from_db"), patch.object( + requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot) + ), patch.object(requests_router, "save_action"): + await requests_router.action_recheck( + "3925", user={"username": "viewer", "role": "user"} + ) + + cached_record = upsert.call_args.kwargs + self.assertEqual(cached_record["title"], "Batman v Superman: Dawn of Justice") + cached_payload = cache_set.call_args.args[1] + self.assertEqual(cached_payload["media"]["title"], "Batman v Superman: Dawn of Justice") + + +class SnapshotIdentityTests(unittest.TestCase): + def test_radarr_identity_replaces_unknown_cached_title(self) -> None: + snapshot = Snapshot(request_id="3925", title="Unknown", request_type=RequestType.movie) + + _apply_arr_identity( + snapshot, + {"title": "Batman v Superman: Dawn of Justice", "year": 2016}, + ) + + self.assertEqual(snapshot.title, "Batman v Superman: Dawn of Justice") + self.assertEqual(snapshot.year, 2016) + class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase): async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None: