Skip to content

Commit d81a426

Browse files
committed
Fix cross-provider season deduplication
Resolve missing Season TVDB IDs from cached parent TV rows and add Calendar regressions. Fixes #938
1 parent fe75f07 commit d81a426

4 files changed

Lines changed: 117 additions & 7 deletions

File tree

src/app/services/item_merge.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,10 @@ def dedupe_cross_provider_items(items: list[Item], preferred_source: str) -> lis
252252
reconciles them (#620), and a user may legitimately track a show under
253253
both identities on purpose - in which case both trees stay, and only the
254254
non-preferred one should be hidden from render-time listings (#639).
255-
This hides the item the user doesn't prefer using only the already-cached
256-
`provider_external_ids["tvdb_id"]` mapping - never a title match, and
257-
never a network call, so it can't misfire on unrelated shows and can't
258-
slow down rendering.
255+
This hides the item the user doesn't prefer using only verified, cached
256+
`provider_external_ids["tvdb_id"]` mappings - never a title match, and
257+
never a network call. Season rows that lack their own mapping inherit the
258+
cached mapping from the matching TMDB TV row through one bulk lookup.
259259
"""
260260
tvdb_by_key = {
261261
(
@@ -271,6 +271,28 @@ def dedupe_cross_provider_items(items: list[Item], preferred_source: str) -> lis
271271
if not tvdb_by_key:
272272
return items
273273

274+
season_parent_tvdb_ids = {}
275+
tmdb_season_media_ids = {
276+
item.media_id
277+
for item in items
278+
if item.source == Sources.TMDB.value
279+
and item.media_type == MediaTypes.SEASON.value
280+
and not (item.provider_external_ids or {}).get("tvdb_id")
281+
}
282+
if tmdb_season_media_ids:
283+
for parent in (
284+
Item.objects.filter(
285+
media_id__in=tmdb_season_media_ids,
286+
source=Sources.TMDB.value,
287+
media_type=MediaTypes.TV.value,
288+
)
289+
.order_by("media_id", "id")
290+
.values("media_id", "provider_external_ids")
291+
):
292+
tvdb_id = (parent["provider_external_ids"] or {}).get("tvdb_id")
293+
if tvdb_id and parent["media_id"] not in season_parent_tvdb_ids:
294+
season_parent_tvdb_ids[parent["media_id"]] = str(tvdb_id)
295+
274296
hidden_ids = set()
275297
for item in items:
276298
if item.source != Sources.TMDB.value or item.media_type not in (
@@ -279,6 +301,8 @@ def dedupe_cross_provider_items(items: list[Item], preferred_source: str) -> lis
279301
):
280302
continue
281303
tvdb_id = (item.provider_external_ids or {}).get("tvdb_id")
304+
if not tvdb_id and item.media_type == MediaTypes.SEASON.value:
305+
tvdb_id = season_parent_tvdb_ids.get(item.media_id)
282306
if not tvdb_id:
283307
continue
284308
counterpart = tvdb_by_key.get(

src/app/tests/test_item_merge.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,92 @@ def test_playback_progress_collision_keeps_keepers_row(self):
240240
self.assertEqual(keeper_progress.position_seconds, 900)
241241

242242

243+
class DedupeCrossProviderItemsTests(TestCase):
244+
"""Render-time dedupe uses verified provider identity without network calls."""
245+
246+
def _season_pair(self, *, parent_tvdb_id="81189"):
247+
tmdb_show = Item.objects.create(
248+
media_id="1396",
249+
source=Sources.TMDB.value,
250+
media_type=MediaTypes.TV.value,
251+
title="Breaking Bad",
252+
image="",
253+
provider_external_ids={"tvdb_id": parent_tvdb_id},
254+
)
255+
tvdb_show = Item.objects.create(
256+
media_id=parent_tvdb_id,
257+
source=Sources.TVDB.value,
258+
media_type=MediaTypes.TV.value,
259+
title="Breaking Bad",
260+
image="",
261+
)
262+
tmdb_season = Item.objects.create(
263+
media_id=tmdb_show.media_id,
264+
source=Sources.TMDB.value,
265+
media_type=MediaTypes.SEASON.value,
266+
season_number=1,
267+
title="Breaking Bad",
268+
image="",
269+
)
270+
tvdb_season = Item.objects.create(
271+
media_id=tvdb_show.media_id,
272+
source=Sources.TVDB.value,
273+
media_type=MediaTypes.SEASON.value,
274+
season_number=1,
275+
title="Breaking Bad",
276+
image="",
277+
)
278+
return tmdb_season, tvdb_season
279+
280+
def test_season_falls_back_to_parent_tvdb_id_and_honors_preference(self):
281+
tmdb_season, tvdb_season = self._season_pair()
282+
283+
with self.assertNumQueries(1):
284+
tmdb_result = item_merge.dedupe_cross_provider_items(
285+
[tmdb_season, tvdb_season],
286+
Sources.TMDB.value,
287+
)
288+
tvdb_result = item_merge.dedupe_cross_provider_items(
289+
[tmdb_season, tvdb_season],
290+
Sources.TVDB.value,
291+
)
292+
293+
self.assertEqual(tmdb_result, [tmdb_season])
294+
self.assertEqual(tvdb_result, [tvdb_season])
295+
296+
@patch("app.services.item_merge.tmdb.resolve_tvdb_id_for_tmdb_show")
297+
def test_missing_parent_id_does_not_match_same_title(self, mock_resolve):
298+
tmdb_season, tvdb_season = self._season_pair(parent_tvdb_id="81189")
299+
Item.objects.filter(pk=tmdb_season.pk).update(provider_external_ids={})
300+
Item.objects.filter(media_id="1396", source=Sources.TMDB.value).update(
301+
provider_external_ids={},
302+
)
303+
304+
result = item_merge.dedupe_cross_provider_items(
305+
[tmdb_season, tvdb_season],
306+
Sources.TMDB.value,
307+
)
308+
309+
self.assertCountEqual(result, [tmdb_season, tvdb_season])
310+
mock_resolve.assert_not_called()
311+
312+
def test_explicit_season_id_takes_precedence_over_parent(self):
313+
tmdb_season, tvdb_season = self._season_pair()
314+
Item.objects.filter(
315+
media_id="1396",
316+
source=Sources.TMDB.value,
317+
media_type=MediaTypes.TV.value,
318+
).update(provider_external_ids={"tvdb_id": "99999"})
319+
tmdb_season.provider_external_ids = {"tvdb_id": tvdb_season.media_id}
320+
321+
result = item_merge.dedupe_cross_provider_items(
322+
[tmdb_season, tvdb_season],
323+
Sources.TVDB.value,
324+
)
325+
326+
self.assertEqual(result, [tvdb_season])
327+
328+
243329
class FindCrossProviderDuplicateTests(TestCase):
244330
"""Verified-identity lookup only - never title matching."""
245331

src/events/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ def _cross_provider_hidden_season_item_ids(self, user, enabled_types):
105105
TVDB identity (the #620 dual-identity model); each identity gets its
106106
own independent Season `Item`s and `Event`s. Without this, the
107107
calendar shows the same real episode twice - once per identity
108-
(#639). Uses the same verified `provider_external_ids["tvdb_id"]`
109-
based dedup already used for Home rows.
108+
(#639). Uses the same verified cached cross-provider mapping already
109+
used for Home rows; Season rows fall back to their matching TMDB TV
110+
item's mapping when the child row has no `tvdb_id` of its own.
110111
"""
111112
if not (
112113
MediaTypes.TV.value in enabled_types

src/events/tests/test_models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,6 @@ def _tv_pair(self, tvdb_id="81189"):
345345
source=Sources.TMDB.value,
346346
image="",
347347
season_number=1,
348-
provider_external_ids={"tvdb_id": tvdb_id},
349348
)
350349
tvdb_season = Item.objects.create(
351350
title="Breaking Bad",

0 commit comments

Comments
 (0)