Skip to content

Commit 4a6337a

Browse files
dannyvfilmsclaude
andcommitted
Expose IMDb ratings in detail responses and derive season ratings
- CompleteMediaSerializer/CompleteEpisodeSerializer now surface item imdb_rating/imdb_rating_count alongside the TMDB-based score, sourced from the Item resolved in each view. - Untracked seasons/episodes in related.seasons/related.episodes now do a batched DB lookup for an already-synced Item instead of always building a blank in-memory one. - Add sync_season_ratings(): derives a season's IMDb rating as a vote-weighted average of its episodes' ratings, wired into the Celery task and the backfill_imdb_ratings command. - _build_imdb_rating_context now supports SEASON so season detail pages show the IMDb rating chip. - Add imdb_rating/imdb_rating_count to the API contract and regenerate openapi.yaml. Fixes #984 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c53da28 commit 4a6337a

14 files changed

Lines changed: 442 additions & 20 deletions

src/api/contract_serializers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ class CompleteMediaResponseSerializer(serializers.Serializer):
212212
genres = serializers.ListField(child=serializers.CharField(), allow_null=True)
213213
score = serializers.FloatField(allow_null=True)
214214
score_count = serializers.IntegerField(allow_null=True)
215+
# FORK: IMDb rating alongside TMDB-based score
216+
imdb_rating = serializers.FloatField(allow_null=True)
217+
imdb_rating_count = serializers.IntegerField(allow_null=True)
215218
cast = serializers.ListField(child=serializers.DictField())
216219
crew = serializers.ListField(child=serializers.DictField())
217220
details = serializers.DictField()

src/api/serializers.py

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ def to_representation(self, instance):
173173
many=True,
174174
)
175175

176+
item = instance.get("item")
177+
176178
return {
177179
"id": user_medias[0].item_id if user_medias else None,
178180
"media_id": (
@@ -192,6 +194,9 @@ def to_representation(self, instance):
192194
"genres": media_metadata.get("genres", []),
193195
"score": float(episode.get("vote_average")),
194196
"score_count": episode.get("vote_count"),
197+
# FORK: IMDb rating alongside TMDB-based score
198+
"imdb_rating": getattr(item, "imdb_rating", None),
199+
"imdb_rating_count": getattr(item, "imdb_rating_count", None),
195200
"details": {
196201
"air_date": episode.get("air_date"),
197202
"episode_number": episode.get("episode_number"),
@@ -224,6 +229,18 @@ def _process_seasons(self, media_metadata, seasons_by_number=None):
224229
):
225230
media_metadata["related"]["seasons"] = []
226231

232+
# FORK: look up already-synced season Items (e.g. IMDb ratings) for
233+
# untracked seasons instead of always building an ephemeral in-memory
234+
# Item, one batched query for the whole show rather than per-season.
235+
existing_items_by_season = {
236+
existing_item.season_number: existing_item
237+
for existing_item in Item.objects.filter(
238+
media_id=str(media_metadata.get("media_id") or ""),
239+
source=media_metadata.get("source"),
240+
media_type=MediaTypes.SEASON.value,
241+
)
242+
}
243+
227244
processed_seasons = []
228245
for season in media_metadata["related"]["seasons"]:
229246
season_number = season.get("season_number")
@@ -232,6 +249,8 @@ def _process_seasons(self, media_metadata, seasons_by_number=None):
232249
)
233250

234251
item = getattr(tracked_season, "item", None)
252+
if item is None:
253+
item = existing_items_by_season.get(season_number)
235254
if item is None:
236255
item = Item(
237256
media_id=str(
@@ -279,10 +298,23 @@ def _process_episodes(self, media_metadata, episodes_by_number=None):
279298
media_metadata["related"]["episodes"] = []
280299

281300
episodes = media_metadata.pop("episodes", [])
301+
# FORK: look up already-synced episode Items (e.g. IMDb ratings) for
302+
# untracked episodes instead of always building an ephemeral in-memory
303+
# Item, one batched query for the whole season rather than per-episode.
304+
existing_items_by_episode = {
305+
existing_item.episode_number: existing_item
306+
for existing_item in Item.objects.filter(
307+
media_id=str(media_metadata.get("media_id") or ""),
308+
source=media_metadata.get("source"),
309+
media_type=MediaTypes.EPISODE.value,
310+
season_number=media_metadata.get("season_number"),
311+
)
312+
}
282313
serializer = EpisodeSerializer(
283314
context={
284315
"source": media_metadata.get("source"),
285316
"tracked_episodes": episodes_by_number or {},
317+
"existing_items_by_episode": existing_items_by_episode,
286318
},
287319
)
288320
processed_episodes = [
@@ -370,6 +402,11 @@ def to_representation(self, instance):
370402
"score_count": int(media_metadata.get("score_count"))
371403
if media_metadata.get("score_count") is not None
372404
else None,
405+
# FORK: IMDb rating alongside TMDB-based score
406+
"imdb_rating": getattr(instance.get("item"), "imdb_rating", None),
407+
"imdb_rating_count": getattr(
408+
instance.get("item"), "imdb_rating_count", None,
409+
),
373410
"cast": media_metadata.get("cast") or [],
374411
"crew": media_metadata.get("crew") or [],
375412
"details": details,
@@ -433,23 +470,28 @@ def to_representation(self, instance):
433470
if hasattr(episode, "item"):
434471
item = getattr(episode, "item", None)
435472
else:
436-
image = (
437-
"https://image.tmdb.org/t/p/original" + instance.get("still_path")
438-
if instance.get("still_path")
439-
else None
440-
)
441-
item = Item(
442-
media_id=media_id,
443-
source=context.get("source"),
444-
media_type=MediaTypes.EPISODE.value,
445-
title=instance.get("name") or "",
446-
image=image,
447-
season_number=season_number,
448-
episode_number=episode_number,
449-
release_datetime=app_helpers.extract_release_datetime(
450-
{"release_date": instance.get("air_date")},
451-
),
452-
)
473+
# FORK: reuse an already-synced episode Item (e.g. IMDb ratings)
474+
# instead of always building an ephemeral in-memory Item.
475+
existing_items_by_episode = context.get("existing_items_by_episode", {})
476+
item = existing_items_by_episode.get(episode_number)
477+
if item is None:
478+
image = (
479+
"https://image.tmdb.org/t/p/original" + instance.get("still_path")
480+
if instance.get("still_path")
481+
else None
482+
)
483+
item = Item(
484+
media_id=media_id,
485+
source=context.get("source"),
486+
media_type=MediaTypes.EPISODE.value,
487+
title=instance.get("name") or "",
488+
image=image,
489+
season_number=season_number,
490+
episode_number=episode_number,
491+
release_datetime=app_helpers.extract_release_datetime(
492+
{"release_date": instance.get("air_date")},
493+
),
494+
)
453495

454496
if hasattr(episode, "lists"):
455497
lists = episode.lists

src/api/tests/helpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ def check_complete_media_structure(test_case, item):
8585
test_case.assertIn("genres", item)
8686
test_case.assertIn("score", item)
8787
test_case.assertIn("score_count", item)
88+
test_case.assertIn("imdb_rating", item)
89+
test_case.assertIn("imdb_rating_count", item)
8890
test_case.assertIn("cast", item)
8991
test_case.assertIsInstance(item["cast"], list)
9092
test_case.assertIn("crew", item)

src/api/tests/test_media_core.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,8 @@ def test_media_detail_get_returns_expected_shape(self, mock_metadata):
711711
"genres",
712712
"score",
713713
"score_count",
714+
"imdb_rating",
715+
"imdb_rating_count",
714716
"cast",
715717
"crew",
716718
"details",
@@ -759,6 +761,51 @@ def test_tv_detail_reports_tracked_season(self, mock_metadata):
759761
self.assertTrue(season["tracked"])
760762
self.assertEqual(season["id"], season_media.item_id)
761763

764+
@patch("api.views.services.get_media_metadata")
765+
def test_tv_detail_untracked_season_reports_synced_imdb_rating(
766+
self,
767+
mock_metadata,
768+
):
769+
"""An untracked season with a previously-synced Item still shows its rating."""
770+
tv_item = self.items_by_type[MediaTypes.TV.value][0]
771+
Item.objects.create(
772+
media_id=tv_item.media_id,
773+
source=tv_item.source,
774+
media_type=MediaTypes.SEASON.value,
775+
title="Untracked Season 5",
776+
season_number=5,
777+
imdb_rating=6.7,
778+
imdb_rating_count=42,
779+
)
780+
mock_metadata.return_value = {
781+
"media_id": tv_item.media_id,
782+
"source": tv_item.source,
783+
"media_type": MediaTypes.TV.value,
784+
"related": {
785+
"seasons": [
786+
{
787+
"media_id": tv_item.media_id,
788+
"source": tv_item.source,
789+
"media_type": MediaTypes.SEASON.value,
790+
"season_number": 5,
791+
},
792+
],
793+
},
794+
}
795+
796+
response = self.call_api(
797+
"get",
798+
"api_media_detail",
799+
args=(MediaTypes.TV.value, tv_item.source, tv_item.media_id),
800+
headers=self.auth_headers,
801+
)
802+
803+
self.assertEqual(response.status_code, 200)
804+
season = response.json()["related"]["seasons"][0]
805+
self.assertFalse(season["tracked"])
806+
self.assertEqual(season["item"]["imdb_rating"], 6.7)
807+
self.assertEqual(season["item"]["imdb_rating_count"], 42)
808+
762809
def test_media_detail_get_invalid_type_returns_bad_request(self):
763810
"""Media detail endpoint should reject unsupported media types."""
764811
response = self.call_api(
@@ -789,6 +836,41 @@ def test_media_detail_get_invalid_media_id_returns_ok_with_null_item_id(self):
789836
self.assertIsNone(response.data["item_id"])
790837
self.assertIsNone(response.data["parent_id"])
791838

839+
def test_media_detail_get_exposes_imdb_rating_for_untracked_item(self):
840+
"""Untracked movies with a previously-synced Item still show IMDb rating."""
841+
Item.objects.create(
842+
media_id="777",
843+
source="tmdb",
844+
media_type=MediaTypes.MOVIE.value,
845+
title="Synced Movie",
846+
imdb_rating=8.5,
847+
imdb_rating_count=1200,
848+
)
849+
850+
response = self.call_api(
851+
"get",
852+
"api_media_detail",
853+
args=(MediaTypes.MOVIE.value, "tmdb", 777),
854+
headers=self.auth_headers,
855+
)
856+
857+
self.assertEqual(response.status_code, 200)
858+
self.assertEqual(response.data["imdb_rating"], 8.5)
859+
self.assertEqual(response.data["imdb_rating_count"], 1200)
860+
861+
def test_media_detail_get_imdb_rating_none_without_synced_item(self):
862+
"""Media with no Item row at all reports a null IMDb rating."""
863+
response = self.call_api(
864+
"get",
865+
"api_media_detail",
866+
args=(MediaTypes.MOVIE.value, "tmdb", 999999),
867+
headers=self.auth_headers,
868+
)
869+
870+
self.assertEqual(response.status_code, 200)
871+
self.assertIsNone(response.data["imdb_rating"])
872+
self.assertIsNone(response.data["imdb_rating_count"])
873+
792874
@patch("api.views.services.get_media_metadata")
793875
def test_media_detail_get_podcast_resolves_tracked_and_untracked_episodes(
794876
self,

src/api/tests/test_media_episode.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,42 @@ def test_episode_detail_get_returns_expected_shape(self, mock_metadata):
163163
self.assertNotIn("season_number", payload)
164164
self.assertNotIn("episode_number", payload)
165165

166+
@patch("api.views.services.get_media_metadata")
167+
def test_episode_detail_get_exposes_imdb_rating(self, mock_metadata):
168+
"""Episode detail GET should expose the episode Item's IMDb rating."""
169+
tv_item = self.items_by_type[MediaTypes.TV.value][0]
170+
season_item = self.items_by_type[MediaTypes.SEASON.value][0]
171+
episode_item = self.items_by_type[MediaTypes.EPISODE.value][0]
172+
episode_item.imdb_rating = 9.2
173+
episode_item.imdb_rating_count = 4321
174+
episode_item.save()
175+
176+
mock_metadata.return_value = self.build_episode_metadata(
177+
tv_item=tv_item,
178+
season_number=season_item.season_number,
179+
episode_number=episode_item.episode_number,
180+
title=episode_item.title,
181+
image=episode_item.image,
182+
)
183+
184+
response = self.call_api(
185+
"get",
186+
"api_media_episode_detail",
187+
args=(
188+
MediaTypes.TV.value,
189+
tv_item.source,
190+
tv_item.media_id,
191+
season_item.season_number,
192+
episode_item.episode_number,
193+
),
194+
headers=self.auth_headers,
195+
)
196+
197+
self.assertEqual(response.status_code, 200)
198+
payload = response.json()
199+
self.assertEqual(payload["imdb_rating"], 9.2)
200+
self.assertEqual(payload["imdb_rating_count"], 4321)
201+
166202
@patch("api.views.services.get_media_metadata")
167203
def test_episode_detail_patch_with_invalid_field_returns_400(self, mock_metadata):
168204
"""Episode PATCH with unknown field should return 400."""

src/api/tests/test_media_season.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,102 @@ def test_season_detail_get_invalid_media_id_returns_ok_with_null_item_id(self):
539539
self.assertIsNone(response.data["item_id"])
540540
self.assertIsNone(response.data["parent_id"])
541541

542+
@patch("api.views.services.get_media_metadata")
543+
def test_season_detail_get_exposes_imdb_rating(self, mock_metadata):
544+
"""Season detail GET should expose the season Item's IMDb rating."""
545+
tv_item = self.items_by_type[MediaTypes.TV.value][0]
546+
season_item = self.items_by_type[MediaTypes.SEASON.value][0]
547+
season_item.imdb_rating = 7.3
548+
season_item.imdb_rating_count = 890
549+
season_item.save()
550+
551+
mock_metadata.return_value = {
552+
"media_id": tv_item.media_id,
553+
"source": tv_item.source,
554+
"media_type": "season",
555+
"season_number": season_item.season_number,
556+
"related": {"episodes": []},
557+
}
558+
559+
response = self.call_api(
560+
"get",
561+
"api_media_season_detail",
562+
args=(
563+
MediaTypes.TV.value,
564+
tv_item.source,
565+
tv_item.media_id,
566+
season_item.season_number,
567+
),
568+
headers=self.auth_headers,
569+
)
570+
571+
self.assertEqual(response.status_code, 200)
572+
payload = response.json()
573+
self.assertEqual(payload["imdb_rating"], 7.3)
574+
self.assertEqual(payload["imdb_rating_count"], 890)
575+
576+
@patch("api.views.services.get_media_metadata")
577+
def test_season_detail_untracked_episode_reports_synced_imdb_rating(
578+
self,
579+
mock_metadata,
580+
):
581+
"""An untracked episode with a previously-synced Item still shows its rating."""
582+
tv_item = self.items_by_type[MediaTypes.TV.value][0]
583+
season_item = self.items_by_type[MediaTypes.SEASON.value][0]
584+
Item.objects.create(
585+
media_id=tv_item.media_id,
586+
source=tv_item.source,
587+
media_type=MediaTypes.EPISODE.value,
588+
title="Untracked Episode 99",
589+
season_number=season_item.season_number,
590+
episode_number=99,
591+
imdb_rating=5.4,
592+
imdb_rating_count=17,
593+
)
594+
595+
mock_metadata.return_value = {
596+
"source": tv_item.source,
597+
"media_id": tv_item.media_id,
598+
"media_type": "season",
599+
"season_number": season_item.season_number,
600+
"episodes": [
601+
{
602+
"episode_number": 99,
603+
"season_number": season_item.season_number,
604+
"show_id": tv_item.media_id,
605+
"name": "Untracked Episode 99",
606+
"overview": "",
607+
"vote_average": 0.0,
608+
"vote_count": 0,
609+
"air_date": None,
610+
"runtime": None,
611+
"episode_type": None,
612+
"crew": [],
613+
"guest_stars": [],
614+
"still_path": None,
615+
},
616+
],
617+
"related": {"episodes": []},
618+
}
619+
620+
response = self.call_api(
621+
"get",
622+
"api_media_season_detail",
623+
args=(
624+
MediaTypes.TV.value,
625+
tv_item.source,
626+
tv_item.media_id,
627+
season_item.season_number,
628+
),
629+
headers=self.auth_headers,
630+
)
631+
632+
self.assertEqual(response.status_code, 200)
633+
episode = response.json()["related"]["episodes"][0]
634+
self.assertFalse(episode["tracked"])
635+
self.assertEqual(episode["item"]["imdb_rating"], 5.4)
636+
self.assertEqual(episode["item"]["imdb_rating_count"], 17)
637+
542638
@patch("api.views.services.get_media_metadata")
543639
def test_season_detail_patch_updates_season_fields(self, mock_metadata):
544640
"""Season detail PATCH should update season fields."""

0 commit comments

Comments
 (0)