@@ -214,5 +214,238 @@ public async Task DoesNotUpdatesExistingMovieWhen_WeFindSameQuality()
214214 Assert . That ( contentToAdd , Is . Empty ) ;
215215 _mocker . Verify < IPlexContentRepository > ( x => x . Update ( It . IsAny < PlexServerContent > ( ) ) , Times . Never ) ;
216216 }
217+
218+ [ Test ]
219+ public void PlexServerContentKeyComparer_PreventsDuplicates_BasedOnKey ( )
220+ {
221+ // Use reflection to access the private nested class
222+ var comparerType = typeof ( PlexContentSync ) . GetNestedType ( "PlexServerContentKeyComparer" ,
223+ System . Reflection . BindingFlags . NonPublic ) ;
224+ var comparer = ( IEqualityComparer < PlexServerContent > ) Activator . CreateInstance ( comparerType ) ;
225+ var contentToAdd = new HashSet < PlexServerContent > ( comparer ) ;
226+
227+ var item1 = new PlexServerContent { Key = "12345" , Title = "Test Show" } ;
228+ var item2 = new PlexServerContent { Key = "12345" , Title = "Test Show Different Instance" } ;
229+ var item3 = new PlexServerContent { Key = "67890" , Title = "Different Show" } ;
230+
231+ Assert . That ( contentToAdd . Add ( item1 ) , Is . True , "First item should be added" ) ;
232+ Assert . That ( contentToAdd . Add ( item2 ) , Is . False , "Second item with same Key should be rejected" ) ;
233+ Assert . That ( contentToAdd . Add ( item3 ) , Is . True , "Item with different Key should be added" ) ;
234+ Assert . That ( contentToAdd . Count , Is . EqualTo ( 2 ) , "HashSet should contain only 2 items" ) ;
235+ }
236+
237+ [ Test ]
238+ public async Task ProcessTvShow_DoesNotProcessDuplicate_WhenKeyAlreadyInContentProcessed ( )
239+ {
240+ var show = new Metadata
241+ {
242+ ratingKey = "302135" ,
243+ title = "Duplicate Show" ,
244+ year = 2021
245+ } ;
246+
247+ // Use reflection to access the private nested class
248+ var comparerType = typeof ( PlexContentSync ) . GetNestedType ( "PlexServerContentKeyComparer" ,
249+ System . Reflection . BindingFlags . NonPublic ) ;
250+ var comparer = ( IEqualityComparer < PlexServerContent > ) Activator . CreateInstance ( comparerType ) ;
251+ var contentToAdd = new HashSet < PlexServerContent > ( comparer ) ;
252+ var contentProcessed = new Dictionary < int , string >
253+ {
254+ { 1 , "302135" } // Already processed
255+ } ;
256+
257+ _mocker . Setup < IPlexApi , Task < PlexMetadata > > ( x =>
258+ x . GetSeasons ( It . IsAny < string > ( ) , It . IsAny < string > ( ) , It . IsAny < string > ( ) ) )
259+ . Returns ( Task . FromResult ( new PlexMetadata
260+ {
261+ MediaContainer = new Mediacontainer
262+ {
263+ Metadata = Array . Empty < Metadata > ( )
264+ }
265+ } ) ) ;
266+
267+ // Use reflection to call the private ProcessTvShow method
268+ var method = typeof ( PlexContentSync ) . GetMethod ( "ProcessTvShow" ,
269+ System . Reflection . BindingFlags . NonPublic | System . Reflection . BindingFlags . Instance ) ;
270+
271+ await ( Task ) method . Invoke ( _subject , new object [ ]
272+ {
273+ new PlexServers { Ip = "http://test.com/" , Port = 80 } ,
274+ show ,
275+ contentToAdd ,
276+ contentProcessed
277+ } ) ;
278+
279+ // Should return early without calling GetSeasons
280+ _mocker . Verify < IPlexApi > ( x => x . GetSeasons ( It . IsAny < string > ( ) , It . IsAny < string > ( ) , It . IsAny < string > ( ) ) ,
281+ Times . Never , "GetSeasons should not be called for duplicate show" ) ;
282+ Assert . That ( contentToAdd , Is . Empty , "No content should be added for duplicate" ) ;
283+ }
284+
285+ [ Test ]
286+ public async Task ProcessTvShow_DoesNotProcessDuplicate_WhenKeyAlreadyInContentToAdd ( )
287+ {
288+ var show = new Metadata
289+ {
290+ ratingKey = "302135" ,
291+ title = "Duplicate Show" ,
292+ year = 2021
293+ } ;
294+
295+ // Use reflection to access the private nested class
296+ var comparerType = typeof ( PlexContentSync ) . GetNestedType ( "PlexServerContentKeyComparer" ,
297+ System . Reflection . BindingFlags . NonPublic ) ;
298+ var comparer = ( IEqualityComparer < PlexServerContent > ) Activator . CreateInstance ( comparerType ) ;
299+ var contentToAdd = new HashSet < PlexServerContent > ( comparer ) ;
300+
301+ // Pre-add an item with the same key
302+ contentToAdd . Add ( new PlexServerContent { Key = "302135" , Title = "Already Added" } ) ;
303+
304+ var contentProcessed = new Dictionary < int , string > ( ) ;
305+
306+ _mocker . Setup < IPlexApi , Task < PlexMetadata > > ( x =>
307+ x . GetSeasons ( It . IsAny < string > ( ) , It . IsAny < string > ( ) , It . IsAny < string > ( ) ) )
308+ . Returns ( Task . FromResult ( new PlexMetadata
309+ {
310+ MediaContainer = new Mediacontainer
311+ {
312+ Metadata = Array . Empty < Metadata > ( )
313+ }
314+ } ) ) ;
315+
316+ // Use reflection to call the private ProcessTvShow method
317+ var method = typeof ( PlexContentSync ) . GetMethod ( "ProcessTvShow" ,
318+ System . Reflection . BindingFlags . NonPublic | System . Reflection . BindingFlags . Instance ) ;
319+
320+ await ( Task ) method . Invoke ( _subject , new object [ ]
321+ {
322+ new PlexServers { Ip = "http://test.com/" , Port = 80 } ,
323+ show ,
324+ contentToAdd ,
325+ contentProcessed
326+ } ) ;
327+
328+ // Should return early without calling GetSeasons
329+ _mocker . Verify < IPlexApi > ( x => x . GetSeasons ( It . IsAny < string > ( ) , It . IsAny < string > ( ) , It . IsAny < string > ( ) ) ,
330+ Times . Never , "GetSeasons should not be called for duplicate show" ) ;
331+ Assert . That ( contentToAdd . Count , Is . EqualTo ( 1 ) , "Should still have only the original item" ) ;
332+ }
333+
334+ [ Test ]
335+ public async Task ProcessTvShow_MultipleTimes_WithinSameBatch_OnlyFirstCallProcesses ( )
336+ {
337+ // This test reproduces the original issue #5331:
338+ // The same TV show being processed multiple times within a single batch (before SaveChanges)
339+ // Previously this would add duplicate entries causing "Duplicate entry for key" DB errors
340+ // Now with our fix: early detection prevents reprocessing
341+
342+ var show1 = new Metadata { ratingKey = "302135" , title = "Game of Thrones" , year = 2011 } ;
343+ var show2 = new Metadata { ratingKey = "302135" , title = "Game of Thrones" , year = 2011 } ; // Same show, different object
344+ var show3 = new Metadata { ratingKey = "302135" , title = "Game of Thrones" , year = 2011 } ; // Same show, third object
345+
346+ // Use reflection to access the private nested class
347+ var comparerType = typeof ( PlexContentSync ) . GetNestedType ( "PlexServerContentKeyComparer" ,
348+ System . Reflection . BindingFlags . NonPublic ) ;
349+ var comparer = ( IEqualityComparer < PlexServerContent > ) Activator . CreateInstance ( comparerType ) ;
350+ var contentToAdd = new HashSet < PlexServerContent > ( comparer ) ;
351+ var contentProcessed = new Dictionary < int , string > ( ) ;
352+
353+ // Mock GetSeasons to return empty - we're testing early detection, not the full processing
354+ _mocker . Setup < IPlexApi , Task < PlexMetadata > > ( x =>
355+ x . GetSeasons ( It . IsAny < string > ( ) , It . IsAny < string > ( ) , "302135" ) )
356+ . Returns ( Task . FromResult ( new PlexMetadata
357+ {
358+ MediaContainer = new Mediacontainer { Metadata = Array . Empty < Metadata > ( ) }
359+ } ) ) ;
360+
361+ _mocker . Setup < IPlexContentRepository , Task < PlexServerContent > > ( x =>
362+ x . GetFirstContentByCustom ( It . IsAny < Expression < Func < PlexServerContent , bool > > > ( ) ) )
363+ . Returns ( Task . FromResult < PlexServerContent > ( null ) ) ;
364+
365+ _mocker . Setup < IPlexContentRepository , Task < PlexServerContent > > ( x =>
366+ x . GetByKey ( "302135" ) )
367+ . Returns ( Task . FromResult < PlexServerContent > ( null ) ) ;
368+
369+ // Use reflection to call the private ProcessTvShow method
370+ var method = typeof ( PlexContentSync ) . GetMethod ( "ProcessTvShow" ,
371+ System . Reflection . BindingFlags . NonPublic | System . Reflection . BindingFlags . Instance ) ;
372+
373+ var server = new PlexServers { Ip = "http://test.com/" , Port = 80 } ;
374+
375+ // First call - should process normally
376+ await ( Task ) method . Invoke ( _subject , new object [ ] { server , show1 , contentToAdd , contentProcessed } ) ;
377+
378+ // Manually add to contentProcessed to simulate what would happen after SaveChanges
379+ // (In real code, this happens after the batch is saved to DB)
380+ contentProcessed . Add ( 1 , "302135" ) ;
381+
382+ // Second and third calls - should be rejected by early duplicate detection
383+ await ( Task ) method . Invoke ( _subject , new object [ ] { server , show2 , contentToAdd , contentProcessed } ) ;
384+ await ( Task ) method . Invoke ( _subject , new object [ ] { server , show3 , contentToAdd , contentProcessed } ) ;
385+
386+ // VERIFY THE FIX:
387+ // GetSeasons should only be called ONCE (for the first show), not three times
388+ _mocker . Verify < IPlexApi > ( x => x . GetSeasons ( It . IsAny < string > ( ) , It . IsAny < string > ( ) , "302135" ) ,
389+ Times . Once ,
390+ "GetSeasons should only be called once - subsequent calls should be rejected by early duplicate detection" ) ;
391+
392+ // This verifies that the early duplicate check (contentProcessed.ContainsValue) works correctly
393+ // In the original bug, all three calls would process and try to add to the database,
394+ // causing: "Duplicate entry '302135' for key 'AK_PlexServerContent_Key'"
395+ }
396+
397+ [ Test ]
398+ public void CustomComparerPreventsMultipleAdds_ToHashSet ( )
399+ {
400+ // This test verifies that even if the early checks somehow failed,
401+ // the custom comparer on the HashSet would still prevent duplicates
402+ // This is the second layer of defense
403+
404+ var comparerType = typeof ( PlexContentSync ) . GetNestedType ( "PlexServerContentKeyComparer" ,
405+ System . Reflection . BindingFlags . NonPublic ) ;
406+ var comparer = ( IEqualityComparer < PlexServerContent > ) Activator . CreateInstance ( comparerType ) ;
407+ var contentToAdd = new HashSet < PlexServerContent > ( comparer ) ;
408+
409+ // Simulate three attempts to add the same show (same Key, different objects)
410+ // This is what would happen in the original bug within a single batch
411+ var show1 = new PlexServerContent
412+ {
413+ Key = "302135" ,
414+ Title = "Game of Thrones" ,
415+ Type = MediaType . Series ,
416+ Seasons = new List < PlexSeasonsContent > ( )
417+ } ;
418+
419+ var show2 = new PlexServerContent
420+ {
421+ Key = "302135" , // SAME KEY
422+ Title = "Game of Thrones" ,
423+ Type = MediaType . Series ,
424+ Seasons = new List < PlexSeasonsContent > ( )
425+ } ;
426+
427+ var show3 = new PlexServerContent
428+ {
429+ Key = "302135" , // SAME KEY AGAIN
430+ Title = "Game of Thrones" ,
431+ Type = MediaType . Series ,
432+ Seasons = new List < PlexSeasonsContent > ( )
433+ } ;
434+
435+ // Try to add all three
436+ var added1 = contentToAdd . Add ( show1 ) ;
437+ var added2 = contentToAdd . Add ( show2 ) ; // Should be rejected
438+ var added3 = contentToAdd . Add ( show3 ) ; // Should be rejected
439+
440+ // VERIFY THE FIX:
441+ Assert . That ( added1 , Is . True , "First show should be added successfully" ) ;
442+ Assert . That ( added2 , Is . False , "Second show with same Key should be rejected by comparer" ) ;
443+ Assert . That ( added3 , Is . False , "Third show with same Key should be rejected by comparer" ) ;
444+ Assert . That ( contentToAdd . Count , Is . EqualTo ( 1 ) , "Only ONE show should be in the HashSet" ) ;
445+
446+ // This demonstrates that even if somehow the same show got processed multiple times,
447+ // the custom comparer prevents duplicate entries in contentToAdd,
448+ // which prevents the "Duplicate entry" database error when SaveChanges() is called
449+ }
217450 }
218451}
0 commit comments