Skip to content

Commit cefe989

Browse files
committed
fix(plex): content sync duplicate key errors
1 parent 043530f commit cefe989

3 files changed

Lines changed: 426 additions & 25 deletions

File tree

src/Ombi.Schedule.Tests/PlexContentSyncTests.cs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)