Skip to content

Commit 6d5fa01

Browse files
committed
Cascade Trakt-imported show completion/drop to seasons
- import_data() now runs the same completion/drop cascade TV.save() uses (TV._completed(), TV._mark_in_progress_seasons_as_dropped()) for every TV row a Trakt import touches, since bulk_create_media() and bulk_update_with_history() never call save() and previously skipped it entirely - add regression tests covering the completed-show and dropped-show cascades Fixes #985
1 parent c9bd3ff commit 6d5fa01

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

src/integrations/imports/trakt.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,43 @@ def import_data(self):
450450
if self.dropped_tvs:
451451
bulk_update_with_history(self.dropped_tvs, app.models.TV, fields=["status"])
452452

453+
# Neither bulk_create_media() nor bulk_update_with_history() call
454+
# TV.save(), so the season/episode cascade it normally fires for a
455+
# COMPLETED/DROPPED show never runs for Trakt-imported shows. A show
456+
# created fresh this run has its final status baked directly into
457+
# the bulk_create call (never touching completed_tvs/dropped_tvs
458+
# below), so collect every TV row this run touched, not just the
459+
# ones re-flushed above, and run the same cascade helpers TV.save()
460+
# would use.
461+
touched_tvs = {
462+
tv.pk: tv
463+
for tv in (
464+
*self.bulk_media[MediaTypes.TV.value],
465+
*self.completed_tvs,
466+
*self.dropped_tvs,
467+
)
468+
if tv.pk
469+
}
470+
for tv_obj in touched_tvs.values():
471+
if tv_obj.status == Status.COMPLETED.value:
472+
try:
473+
tv_obj._completed()
474+
except (
475+
services.ProviderAPIError,
476+
requests.exceptions.RequestException,
477+
KeyError,
478+
TypeError,
479+
ValueError,
480+
) as error:
481+
logger.warning(
482+
"Skipping completion fan-out due to missing metadata"
483+
" for %s: %s",
484+
tv_obj.item.media_id,
485+
error,
486+
)
487+
elif tv_obj.status == Status.DROPPED.value:
488+
tv_obj._mark_in_progress_seasons_as_dropped()
489+
453490
imported_counts = {
454491
media_type: len(media_list)
455492
for media_type, media_list in self.bulk_media.items()

src/integrations/tests/imports/test_trakt.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,169 @@ def mock_metadata(media_type, tmdb_id, title, season_number=None):
10341034
self.assertEqual(new_season.status, Status.COMPLETED.value)
10351035
self.assertEqual(new_tv.status, Status.COMPLETED.value)
10361036

1037+
@patch("app.models.providers.services.get_media_metadata")
1038+
@patch("integrations.imports.trakt.TraktImporter._get_metadata")
1039+
def test_import_data_cascades_completed_tv_status_to_other_seasons(
1040+
self, mock_get_metadata, mock_get_media_metadata
1041+
):
1042+
"""Regression test for #985: a show that Trakt import marks
1043+
Completed must also cascade that completion down to its other
1044+
seasons/episodes, exactly like manually marking a show Completed in
1045+
the UI does (TV.save() -> _completed()). The bulk_update_with_history
1046+
flush in import_data() never calls TV.save(), so without the fix
1047+
this cascade is silently skipped.
1048+
"""
1049+
TMDB_ID = 99997
1050+
SEASON_NUMBER = 2
1051+
TOTAL_EPISODES = 5
1052+
1053+
# Season 1 already exists and is only partially watched; it isn't
1054+
# touched by the watch-history entry below, so only the completion
1055+
# cascade (not the history walk) can bring it to Completed.
1056+
item_tv, _ = Item.objects.get_or_create(
1057+
media_id=TMDB_ID,
1058+
source=Sources.TMDB.value,
1059+
media_type=MediaTypes.TV.value,
1060+
defaults={"title": "Test Show", "image": ""},
1061+
)
1062+
tv_obj = TV.objects.create(
1063+
item=item_tv, user=self.user, status=Status.IN_PROGRESS.value
1064+
)
1065+
item_season1, _ = Item.objects.get_or_create(
1066+
media_id=TMDB_ID,
1067+
source=Sources.TMDB.value,
1068+
media_type=MediaTypes.SEASON.value,
1069+
season_number=1,
1070+
defaults={"title": "Test Show", "image": ""},
1071+
)
1072+
Season.objects.create(
1073+
item=item_season1,
1074+
user=self.user,
1075+
related_tv=tv_obj,
1076+
status=Status.IN_PROGRESS.value,
1077+
)
1078+
1079+
def mock_metadata(media_type, tmdb_id, title, season_number=None):
1080+
if media_type == MediaTypes.TV.value:
1081+
return {
1082+
"title": "Test Show",
1083+
"image": "",
1084+
"last_episode_season": SEASON_NUMBER,
1085+
"max_progress": TOTAL_EPISODES,
1086+
}
1087+
if media_type == MediaTypes.SEASON.value:
1088+
return {
1089+
"title": f"Season {season_number}",
1090+
"image": "",
1091+
"episodes": [
1092+
{"episode_number": i, "still_path": None}
1093+
for i in range(1, TOTAL_EPISODES + 1)
1094+
],
1095+
"max_progress": TOTAL_EPISODES,
1096+
}
1097+
return None
1098+
1099+
mock_get_metadata.side_effect = mock_metadata
1100+
1101+
# Metadata used by TV._completed(), the cascade helper the fix wires
1102+
# up. It reports season 1 as the only remaining incomplete season.
1103+
mock_get_media_metadata.return_value = {
1104+
"max_progress": TOTAL_EPISODES,
1105+
"related": {"seasons": [{"season_number": 1, "image": ""}]},
1106+
"season/1": {
1107+
"image": "",
1108+
"season_number": 1,
1109+
"episodes": [{"episode_number": i} for i in range(1, TOTAL_EPISODES + 1)],
1110+
},
1111+
}
1112+
1113+
entry = {
1114+
"type": "episode",
1115+
"episode": {
1116+
"season": SEASON_NUMBER,
1117+
"number": TOTAL_EPISODES,
1118+
"title": "Finale",
1119+
},
1120+
"show": {"title": "Test Show", "ids": {"tmdb": TMDB_ID}},
1121+
"watched_at": "2024-06-01T00:00:00.000Z",
1122+
}
1123+
1124+
trakt_importer = TraktImporter("testuser", self.user, "overwrite")
1125+
trakt_importer.process_watched_episode(entry)
1126+
trakt_importer.process_history = lambda: None
1127+
trakt_importer.process_watchlist = lambda: None
1128+
trakt_importer.process_ratings = lambda: None
1129+
trakt_importer.process_notes = lambda: None
1130+
trakt_importer.process_comments = lambda: None
1131+
trakt_importer.process_collection = lambda: None
1132+
trakt_importer.process_dropped = lambda: None
1133+
trakt_importer._validate_username = lambda: None
1134+
1135+
trakt_importer.import_data()
1136+
1137+
season1 = Season.objects.get(
1138+
user=self.user,
1139+
item__media_id=str(TMDB_ID),
1140+
item__season_number=1,
1141+
)
1142+
self.assertEqual(season1.status, Status.COMPLETED.value)
1143+
self.assertTrue(season1.episodes.exists())
1144+
1145+
def test_import_data_cascades_dropped_tv_status_to_in_progress_seasons(self):
1146+
"""Regression test for #985: a show hidden/dropped on Trakt must
1147+
have its in-progress seasons marked Dropped too, matching what
1148+
manually dropping a show does via TV.save() ->
1149+
_mark_in_progress_seasons_as_dropped(). Without the fix, the bulk
1150+
flush in import_data() only updates the TV row's own status.
1151+
"""
1152+
TMDB_ID = 99996
1153+
1154+
item_tv, _ = Item.objects.get_or_create(
1155+
media_id=TMDB_ID,
1156+
source=Sources.TMDB.value,
1157+
media_type=MediaTypes.TV.value,
1158+
defaults={"title": "Test Show", "image": ""},
1159+
)
1160+
tv_obj = TV.objects.create(
1161+
item=item_tv, user=self.user, status=Status.IN_PROGRESS.value
1162+
)
1163+
item_season, _ = Item.objects.get_or_create(
1164+
media_id=TMDB_ID,
1165+
source=Sources.TMDB.value,
1166+
media_type=MediaTypes.SEASON.value,
1167+
season_number=1,
1168+
defaults={"title": "Test Show", "image": ""},
1169+
)
1170+
Season.objects.create(
1171+
item=item_season,
1172+
user=self.user,
1173+
related_tv=tv_obj,
1174+
status=Status.IN_PROGRESS.value,
1175+
)
1176+
1177+
trakt_importer = TraktImporter("testuser", self.user, "overwrite")
1178+
trakt_importer.dropped_tmdb_ids.add(TMDB_ID)
1179+
tv_obj.status = Status.DROPPED.value
1180+
trakt_importer.dropped_tvs.append(tv_obj)
1181+
1182+
trakt_importer.process_dropped = lambda: None
1183+
trakt_importer.process_history = lambda: None
1184+
trakt_importer.process_watchlist = lambda: None
1185+
trakt_importer.process_ratings = lambda: None
1186+
trakt_importer.process_notes = lambda: None
1187+
trakt_importer.process_comments = lambda: None
1188+
trakt_importer.process_collection = lambda: None
1189+
trakt_importer._validate_username = lambda: None
1190+
1191+
trakt_importer.import_data()
1192+
1193+
season = Season.objects.get(
1194+
user=self.user,
1195+
item__media_id=str(TMDB_ID),
1196+
item__season_number=1,
1197+
)
1198+
self.assertEqual(season.status, Status.DROPPED.value)
1199+
10371200
@patch("integrations.imports.trakt.TraktImporter._get_metadata")
10381201
def test_last_episode_import_does_not_complete_existing_show_in_new_mode(
10391202
self, mock_get_metadata

0 commit comments

Comments
 (0)