@@ -62,10 +62,98 @@ def test_process_watched_movie(self, mock_get_metadata):
6262 movie_obj = trakt_importer .bulk_media [MediaTypes .MOVIE .value ][0 ]
6363 self .assertEqual (movie_obj .progress , 1 )
6464
65- # Process the same movie again to test repeat handling
65+ # Reprocessing the exact same entry is a duplicate play (issue #854)
66+ # and must not create a second row.
6667 trakt_importer .process_watched_movie (movie_entry )
68+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .MOVIE .value ]), 1 )
69+
70+ @patch ("integrations.imports.trakt.TraktImporter._get_metadata" )
71+ def test_process_watched_movie_dedupes_nearby_play (self , mock_get_metadata ):
72+ """A movie watch within the dedupe window of an existing play is skipped."""
73+ mock_get_metadata .return_value = {
74+ "title" : "Test Movie" ,
75+ "image" : "movie_image.jpg" ,
76+ }
77+
78+ trakt_importer = TraktImporter ("test" , self .user , "new" )
79+ trakt_importer .process_watched_movie (
80+ {
81+ "type" : "movie" ,
82+ "movie" : {"title" : "Test Movie" , "ids" : {"tmdb" : 67890 }},
83+ "watched_at" : "2023-01-02T00:00:00.000Z" ,
84+ },
85+ )
86+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .MOVIE .value ]), 1 )
87+
88+ # 10 minutes later, same movie: within the 15 minute dedupe window.
89+ trakt_importer .process_watched_movie (
90+ {
91+ "type" : "movie" ,
92+ "movie" : {"title" : "Test Movie" , "ids" : {"tmdb" : 67890 }},
93+ "watched_at" : "2023-01-02T00:10:00.000Z" ,
94+ },
95+ )
96+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .MOVIE .value ]), 1 )
97+
98+ # 1 day later, same movie: a legitimate rewatch outside the window.
99+ trakt_importer .process_watched_movie (
100+ {
101+ "type" : "movie" ,
102+ "movie" : {"title" : "Test Movie" , "ids" : {"tmdb" : 67890 }},
103+ "watched_at" : "2023-01-03T00:00:00.000Z" ,
104+ },
105+ )
67106 self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .MOVIE .value ]), 2 )
68107
108+ # A different movie at a nearby time is not a duplicate.
109+ mock_get_metadata .return_value = {
110+ "title" : "Other Movie" ,
111+ "image" : "movie_image.jpg" ,
112+ }
113+ trakt_importer .process_watched_movie (
114+ {
115+ "type" : "movie" ,
116+ "movie" : {"title" : "Other Movie" , "ids" : {"tmdb" : 11111 }},
117+ "watched_at" : "2023-01-03T00:05:00.000Z" ,
118+ },
119+ )
120+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .MOVIE .value ]), 3 )
121+
122+ @patch ("integrations.imports.trakt.TraktImporter._get_metadata" )
123+ def test_process_watched_movie_dedupes_against_existing_db_play (
124+ self ,
125+ mock_get_metadata ,
126+ ):
127+ """A Trakt-imported play is skipped if it's near an existing DB play (e.g. webhook)."""
128+ item = Item .objects .get_or_create (
129+ media_id = "67890" ,
130+ source = Sources .TMDB .value ,
131+ media_type = MediaTypes .MOVIE .value ,
132+ defaults = {"title" : "Test Movie" },
133+ )[0 ]
134+ Movie .objects .create (
135+ item = item ,
136+ user = self .user ,
137+ end_date = "2023-01-02T00:00:00Z" ,
138+ status = Status .COMPLETED .value ,
139+ progress = 1 ,
140+ )
141+
142+ mock_get_metadata .return_value = {
143+ "title" : "Test Movie" ,
144+ "image" : "movie_image.jpg" ,
145+ }
146+ trakt_importer = TraktImporter ("test" , self .user , "new" )
147+ trakt_importer .process_watched_movie (
148+ {
149+ "type" : "movie" ,
150+ "movie" : {"title" : "Test Movie" , "ids" : {"tmdb" : 67890 }},
151+ "watched_at" : "2023-01-02T00:12:00.000Z" ,
152+ },
153+ )
154+
155+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .MOVIE .value ]), 0 )
156+
69157 @patch ("integrations.imports.trakt.TraktImporter._get_metadata" )
70158 def test_process_watched_episode (self , mock_get_metadata ):
71159 """Test processing an episode entry."""
@@ -132,6 +220,152 @@ def mock_metadata_side_effect(media_type, _, __, ___=None):
132220 )
133221 self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .EPISODE .value ]), 2 )
134222
223+ @patch ("integrations.imports.trakt.TraktImporter._get_metadata" )
224+ def test_process_watched_episode_dedupes_nearby_play (self , mock_get_metadata ):
225+ """An episode watch within the dedupe window of an existing play is skipped."""
226+
227+ def mock_metadata_side_effect (media_type , _ , __ , ___ = None ):
228+ if media_type == MediaTypes .TV .value :
229+ return {
230+ "title" : "Test Show" ,
231+ "image" : "tv_image.jpg" ,
232+ "last_episode_season" : 1 ,
233+ "max_progress" : 1 ,
234+ }
235+ if media_type == MediaTypes .SEASON .value :
236+ return {
237+ "title" : "Season 1" ,
238+ "image" : "season_image.jpg" ,
239+ "episodes" : [
240+ {
241+ "episode_number" : 1 ,
242+ "still_path" : "/still.jpg" ,
243+ "title" : "Pilot Episode Title" ,
244+ },
245+ {
246+ "episode_number" : 2 ,
247+ "still_path" : "/still2.jpg" ,
248+ "title" : "Episode 2 Title" ,
249+ },
250+ ],
251+ "max_progress" : 2 ,
252+ }
253+ return None
254+
255+ mock_get_metadata .side_effect = mock_metadata_side_effect
256+
257+ episode_entry = {
258+ "type" : "episode" ,
259+ "episode" : {"season" : 1 , "number" : 1 , "title" : "Pilot" },
260+ "show" : {"title" : "Test Show" , "ids" : {"tmdb" : 12345 }},
261+ "watched_at" : "2023-01-01T00:00:00.000Z" ,
262+ }
263+
264+ trakt_importer = TraktImporter ("testuser" , self .user , "new" )
265+ trakt_importer .process_watched_episode (episode_entry )
266+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .EPISODE .value ]), 1 )
267+
268+ # 10 minutes later, same episode: within the 15 minute dedupe window.
269+ trakt_importer .process_watched_episode (
270+ {
271+ ** episode_entry ,
272+ "watched_at" : "2023-01-01T00:10:00.000Z" ,
273+ },
274+ )
275+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .EPISODE .value ]), 1 )
276+
277+ # A different episode of the same show at a nearby time is not a duplicate.
278+ trakt_importer .process_watched_episode (
279+ {
280+ "type" : "episode" ,
281+ "episode" : {"season" : 1 , "number" : 2 , "title" : "Episode 2" },
282+ "show" : {"title" : "Test Show" , "ids" : {"tmdb" : 12345 }},
283+ "watched_at" : "2023-01-01T00:12:00.000Z" ,
284+ },
285+ )
286+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .EPISODE .value ]), 2 )
287+
288+ @patch ("integrations.imports.trakt.TraktImporter._get_metadata" )
289+ def test_process_watched_episode_dedupes_against_existing_db_play (
290+ self ,
291+ mock_get_metadata ,
292+ ):
293+ """A Trakt-imported episode play is skipped if it's near an existing DB play."""
294+ tv_item = Item .objects .get_or_create (
295+ media_id = "12345" ,
296+ source = Sources .TMDB .value ,
297+ media_type = MediaTypes .TV .value ,
298+ defaults = {"title" : "Test Show" },
299+ )[0 ]
300+ tv_obj = TV .objects .create (
301+ item = tv_item ,
302+ user = self .user ,
303+ status = Status .IN_PROGRESS .value ,
304+ )
305+ season_item = Item .objects .get_or_create (
306+ media_id = "12345" ,
307+ source = Sources .TMDB .value ,
308+ media_type = MediaTypes .SEASON .value ,
309+ season_number = 1 ,
310+ defaults = {"title" : "Season 1" },
311+ )[0 ]
312+ season_obj = Season .objects .create (
313+ item = season_item ,
314+ related_tv = tv_obj ,
315+ user = self .user ,
316+ status = Status .IN_PROGRESS .value ,
317+ )
318+ episode_item = Item .objects .get_or_create (
319+ media_id = "12345" ,
320+ source = Sources .TMDB .value ,
321+ media_type = MediaTypes .EPISODE .value ,
322+ season_number = 1 ,
323+ episode_number = 1 ,
324+ defaults = {"title" : "Pilot" },
325+ )[0 ]
326+ Episode .objects .create (
327+ item = episode_item ,
328+ related_season = season_obj ,
329+ end_date = "2023-01-01T00:00:00Z" ,
330+ )
331+
332+ def mock_metadata_side_effect (media_type , _ , __ , ___ = None ):
333+ if media_type == MediaTypes .TV .value :
334+ return {
335+ "title" : "Test Show" ,
336+ "image" : "tv_image.jpg" ,
337+ "last_episode_season" : 1 ,
338+ "max_progress" : 1 ,
339+ }
340+ if media_type == MediaTypes .SEASON .value :
341+ return {
342+ "title" : "Season 1" ,
343+ "image" : "season_image.jpg" ,
344+ "episodes" : [
345+ {
346+ "episode_number" : 1 ,
347+ "still_path" : "/still.jpg" ,
348+ "title" : "Pilot Episode Title" ,
349+ },
350+ ],
351+ "max_progress" : 1 ,
352+ }
353+ return None
354+
355+ mock_get_metadata .side_effect = mock_metadata_side_effect
356+
357+ trakt_importer = TraktImporter ("testuser" , self .user , "new" )
358+ trakt_importer .process_watched_episode (
359+ {
360+ "type" : "episode" ,
361+ "episode" : {"season" : 1 , "number" : 1 , "title" : "Pilot" },
362+ "show" : {"title" : "Test Show" , "ids" : {"tmdb" : 12345 }},
363+ "watched_at" : "2023-01-01T00:12:00.000Z" ,
364+ },
365+ )
366+
367+ self .assertEqual (len (trakt_importer .bulk_media [MediaTypes .EPISODE .value ]), 0 )
368+
135369 @patch ("integrations.imports.trakt.TraktImporter._get_metadata" )
136370 def test_process_watched_episode_existing_show_imports_new_episode (
137371 self ,
0 commit comments