Fix sparse request metadata hydration
This commit is contained in:
@@ -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]]:
|
def _extract_artwork_paths(item: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
|
||||||
media = item.get("media") or {}
|
media = item.get("media") or {}
|
||||||
poster_path = None
|
poster_path = None
|
||||||
@@ -694,6 +731,15 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
|
|||||||
cache_key = f"request:{request_id}"
|
cache_key = f"request:{request_id}"
|
||||||
cached = _cache_get(cache_key)
|
cached = _cache_get(cache_key)
|
||||||
if isinstance(cached, dict):
|
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
|
return cached
|
||||||
if _failure_cache_has(cache_key):
|
if _failure_cache_has(cache_key):
|
||||||
return None
|
return None
|
||||||
@@ -703,6 +749,13 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
|
|||||||
_failure_cache_set(cache_key)
|
_failure_cache_set(cache_key)
|
||||||
return None
|
return None
|
||||||
if isinstance(fetched, dict):
|
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)
|
_cache_set(cache_key, fetched)
|
||||||
return fetched
|
return fetched
|
||||||
return None
|
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")
|
raise HTTPException(status_code=404, detail="Request not found in Seerr")
|
||||||
|
|
||||||
parsed = _parse_request_payload(fresh_request)
|
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):
|
if parsed.get("request_id") != int(request_id):
|
||||||
raise HTTPException(status_code=502, detail="Seerr returned an unexpected request record")
|
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):
|
if not isinstance(created, dict):
|
||||||
raise HTTPException(status_code=502, detail="Invalid response from Seerr request create")
|
raise HTTPException(status_code=502, detail="Invalid response from Seerr request create")
|
||||||
|
|
||||||
|
created = _merge_request_media_details(created, details)
|
||||||
parsed = _parse_request_payload(created)
|
parsed = _parse_request_payload(created)
|
||||||
request_id = _quality_profile_id(parsed.get("request_id"))
|
request_id = _quality_profile_id(parsed.get("request_id"))
|
||||||
status_code = parsed.get("status")
|
status_code = parsed.get("status")
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
|
|||||||
return None
|
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]:
|
def _normalize_media_title(value: Any) -> Optional[str]:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return None
|
return None
|
||||||
@@ -821,7 +837,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
poster_path = media.get("posterPath") or media.get("poster_path")
|
poster_path = media.get("posterPath") or media.get("poster_path")
|
||||||
backdrop_path = media.get("backdropPath") or media.get("backdrop_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")
|
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
||||||
if tmdb_id:
|
if tmdb_id:
|
||||||
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(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:
|
if arr_state is None:
|
||||||
arr_state = "unknown"
|
arr_state = "unknown"
|
||||||
|
|
||||||
|
_apply_arr_identity(snapshot, arr_item)
|
||||||
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
|
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
|
||||||
|
|
||||||
prowlarr_state = "unknown"
|
prowlarr_state = "unknown"
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ from backend.app.services.operation_progress import (
|
|||||||
reset_operation,
|
reset_operation,
|
||||||
start_remote_call,
|
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:
|
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):
|
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:
|
async def test_seerr_search_percent_encodes_multi_word_titles(self) -> None:
|
||||||
client = requests_router.JellyseerrClient("http://seerr.test", "key")
|
client = requests_router.JellyseerrClient("http://seerr.test", "key")
|
||||||
client.get = AsyncMock(return_value={"results": []})
|
client.get = AsyncMock(return_value={"results": []})
|
||||||
@@ -623,6 +649,62 @@ class RequestRecheckTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(result["status"], "ok")
|
self.assertEqual(result["status"], "ok")
|
||||||
self.assertIs(result["snapshot"], snapshot)
|
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):
|
class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None:
|
async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user