Files
ersatztv/ErsatzTV.Scanner.Tests/Core/Plex/PlexMovieLibraryScannerTests.cs
T
timothy 2f2bcca681
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(500): dedup incoming metadata collections so a duplicate name inserts once
The remove-stale + add-new reconcile idiom materializes its add set with
.ToList() BEFORE the loop mutates the existing collection, so the add filter
(`incoming.All(x2 => x2.Name != x.Name)`) is evaluated against a snapshot. Two
identically-named incoming entries whose name is not yet on the existing item
therefore BOTH passed the filter and BOTH inserted — a duplicate row.

Deduplicate the incoming set on the same key the filter compares (Name; Guid
for Guids), in both copies of the idiom:

- PlexMovieLibraryScanner.UpdateMetadata (the original) — genres, studios,
  actors, directors, writers, guids, tags.
- JellyfinMusicVideoLibraryScanner.Reconcile{Genres,Tags,Studios,Artists}
  (added in #497, mirrors the Plex pattern verbatim).

Plex ACTORS are the exception and get an artwork-preferring dedup hoisted out
and shared with the remove filter, because that filter is keyed on
(Name, artwork-presence) — it is the mechanism that drops an artwork-less actor
so the add loop can re-add it WITH artwork. A bare DistinctBy(a => a.Name)
there keeps the FIRST duplicate, so Plex listing the artwork-less copy first
discarded the artwork; worse, the remove filter would still see the
artwork-less duplicate, making its upgrade clause false, so the stale row was
never removed and the artwork never arrived on ANY later scan either. Actor
also carries Role/Order, which first-wins would silently drop too. Caught by
the cold review of the first version of this commit.

For the Jellyfin scanner the dedup sits at the incoming-list declaration, which
also covers the remove filter — safe because all four of those filters only ask
"is this name present at all", an answer duplicates cannot change.

Tests: duplicate-collapse for both paths, the two Actors cases above, and a
POSITIVE CONTROL proving distinct entries are still all added and stale ones
still removed (without it, a mis-keyed dedup that collapsed genuinely different
entries would pass every other assertion). Each proven non-vacuous.
The Plex tests drive the protected UpdateMetadata through a minimal test-only
subclass, as MediaServerMovieLibraryScannerTests already does.

Low likelihood in practice (a media server emitting two identically-named
genres for one item is unusual); this is defensive, with no observed occurrence.

The same idiom is copied into 8 further scanners/repositories that this change
deliberately does not touch (the issue scoped it to two paths) — filed as #600
so the class of bug is tracked rather than silently left in the majority of its
instances. The dedup rule is recorded under scan.musicvideo-reconciliation.

fixes #500
2026-07-25 13:04:38 +02:00

300 lines
15 KiB
C#

using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Plex;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Scanner.Tests.Core.Plex;
// ersatztv#500: the remove-stale + add-new idiom materializes the add set (.ToList()) BEFORE the loop
// mutates existingMetadata, so two identically-named incoming entries both passed the add filter and both
// got inserted — a duplicate row. This is the ORIGINAL of the pattern (#497 mirrored it into the Jellyfin
// music-video scanner, covered in ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests).
// UpdateMetadata is protected, so a minimal test-only subclass exposes it; that is the whole surface under
// test here, which keeps this off the full ScanLibrary path.
public class PlexMovieLibraryScannerTests
{
[TestFixture]
public class UpdateMetadata
{
// Non-vacuous: dropping the DistinctBy calls in PlexMovieLibraryScanner.UpdateMetadata turns every
// Received(1) below into 2 calls, and each Count.ShouldBe(1) into 2.
[Test]
public async Task Should_Not_Double_Insert_Duplicate_Named_Incoming_Entries()
{
var movieRepository = Substitute.For<IMovieRepository>();
var metadataRepository = Substitute.For<IMetadataRepository>();
movieRepository.AddGenre(Arg.Any<MovieMetadata>(), Arg.Any<Genre>()).Returns(true);
movieRepository.AddTag(Arg.Any<MovieMetadata>(), Arg.Any<Tag>()).Returns(true);
movieRepository.AddStudio(Arg.Any<MovieMetadata>(), Arg.Any<Studio>()).Returns(true);
movieRepository.AddActor(Arg.Any<MovieMetadata>(), Arg.Any<Actor>()).Returns(true);
movieRepository.AddDirector(Arg.Any<MovieMetadata>(), Arg.Any<Director>()).Returns(true);
movieRepository.AddWriter(Arg.Any<MovieMetadata>(), Arg.Any<Writer>()).Returns(true);
metadataRepository.AddGuid(Arg.Any<ErsatzTV.Core.Domain.Metadata>(), Arg.Any<MetadataGuid>())
.Returns(true);
metadataRepository
.UpdateSubtitles(
Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
Arg.Any<List<Subtitle>>(),
Arg.Any<CancellationToken>())
.Returns(false);
// the existing item carries none of the incoming names, so every incoming entry is an "add"
var existingMetadata = new MovieMetadata
{
MetadataKind = MetadataKind.External,
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Directors = [],
Writers = [],
Guids = [],
Subtitles = [],
Artwork = []
};
var existing = new PlexMovie { Id = 1, MovieMetadata = [existingMetadata] };
// Plex reports each name TWICE for the same movie (and the same guid twice)
var incoming = new MovieMetadata
{
MetadataKind = MetadataKind.External,
ContentRating = existingMetadata.ContentRating,
Plot = existingMetadata.Plot,
SortTitle = existingMetadata.SortTitle,
Genres = [new Genre { Name = "Comedy" }, new Genre { Name = "Comedy" }],
Tags = [new Tag { Name = "DupTag" }, new Tag { Name = "DupTag" }],
Studios = [new Studio { Name = "DupStudio" }, new Studio { Name = "DupStudio" }],
Actors = [new Actor { Name = "DupActor" }, new Actor { Name = "DupActor" }],
Directors = [new Director { Name = "DupDirector" }, new Director { Name = "DupDirector" }],
Writers = [new Writer { Name = "DupWriter" }, new Writer { Name = "DupWriter" }],
Guids = [new MetadataGuid { Guid = "imdb://tt1" }, new MetadataGuid { Guid = "imdb://tt1" }],
Subtitles = [],
Artwork = []
};
var scanner = new TestPlexMovieLibraryScanner(movieRepository, metadataRepository);
Either<BaseError, MediaItemScanResult<PlexMovie>> result = await scanner.Update(
new MediaItemScanResult<PlexMovie>(existing),
incoming);
result.IsRight.ShouldBeTrue();
await movieRepository.Received(1).AddGenre(Arg.Any<MovieMetadata>(), Arg.Is<Genre>(g => g.Name == "Comedy"));
await movieRepository.Received(1).AddTag(Arg.Any<MovieMetadata>(), Arg.Is<Tag>(t => t.Name == "DupTag"));
await movieRepository.Received(1)
.AddStudio(Arg.Any<MovieMetadata>(), Arg.Is<Studio>(s => s.Name == "DupStudio"));
await movieRepository.Received(1)
.AddActor(Arg.Any<MovieMetadata>(), Arg.Is<Actor>(a => a.Name == "DupActor"));
await movieRepository.Received(1)
.AddDirector(Arg.Any<MovieMetadata>(), Arg.Is<Director>(d => d.Name == "DupDirector"));
await movieRepository.Received(1)
.AddWriter(Arg.Any<MovieMetadata>(), Arg.Is<Writer>(w => w.Name == "DupWriter"));
await metadataRepository.Received(1).AddGuid(
Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
Arg.Is<MetadataGuid>(g => g.Guid == "imdb://tt1"));
// and the in-memory collections mirror the DB: one entry each, not two
existingMetadata.Genres.Count.ShouldBe(1);
existingMetadata.Tags.Count.ShouldBe(1);
existingMetadata.Studios.Count.ShouldBe(1);
existingMetadata.Actors.Count.ShouldBe(1);
existingMetadata.Directors.Count.ShouldBe(1);
existingMetadata.Writers.Count.ShouldBe(1);
existingMetadata.Guids.Count.ShouldBe(1);
}
// POSITIVE CONTROL for the dedup above: distinct incoming entries must still ALL be added, and a
// stale existing entry must still be removed. Without this, a mis-keyed dedup (or a stray .Take(1))
// that collapsed genuinely different entries would pass every assertion in the duplicate test.
[Test]
public async Task Should_Still_Add_All_Distinct_Entries_And_Remove_Stale_Ones()
{
var movieRepository = Substitute.For<IMovieRepository>();
var metadataRepository = Substitute.For<IMetadataRepository>();
movieRepository.AddGenre(Arg.Any<MovieMetadata>(), Arg.Any<Genre>()).Returns(true);
metadataRepository.RemoveGenre(Arg.Any<Genre>()).Returns(true);
metadataRepository
.UpdateSubtitles(
Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
Arg.Any<List<Subtitle>>(),
Arg.Any<CancellationToken>())
.Returns(false);
var existingMetadata = NewMetadata();
existingMetadata.Genres = [new Genre { Name = "Keep" }, new Genre { Name = "Stale" }];
var existing = new PlexMovie { Id = 1, MovieMetadata = [existingMetadata] };
MovieMetadata incoming = NewMetadata();
incoming.Genres = [new Genre { Name = "Keep" }, new Genre { Name = "New1" }, new Genre { Name = "New2" }];
var scanner = new TestPlexMovieLibraryScanner(movieRepository, metadataRepository);
Either<BaseError, MediaItemScanResult<PlexMovie>> result = await scanner.Update(
new MediaItemScanResult<PlexMovie>(existing),
incoming);
result.IsRight.ShouldBeTrue();
// both distinct new genres added; the kept one is not re-added; the stale one is removed
await movieRepository.Received(1).AddGenre(Arg.Any<MovieMetadata>(), Arg.Is<Genre>(g => g.Name == "New1"));
await movieRepository.Received(1).AddGenre(Arg.Any<MovieMetadata>(), Arg.Is<Genre>(g => g.Name == "New2"));
await movieRepository.DidNotReceive().AddGenre(Arg.Any<MovieMetadata>(), Arg.Is<Genre>(g => g.Name == "Keep"));
await metadataRepository.Received(1).RemoveGenre(Arg.Is<Genre>(g => g.Name == "Stale"));
await metadataRepository.DidNotReceive().RemoveGenre(Arg.Is<Genre>(g => g.Name == "Keep"));
existingMetadata.Genres.Select(g => g.Name).OrderBy(n => n)
.ShouldBe(new[] { "Keep", "New1", "New2" });
}
// ersatztv#500 review finding: Actors are dedup'd specially. Their REMOVE filter is keyed on
// (Name, artwork-presence) — that is the mechanism by which an artwork-less actor is dropped and
// re-added WITH artwork. A bare DistinctBy(a => a.Name) keeps the FIRST duplicate, so if Plex lists
// the artwork-less copy first the artwork is silently discarded; and because the remove filter would
// still see the artwork-less duplicate, its upgrade clause stays false, so the stale row is never
// removed and the artwork never arrives on ANY later scan either. Hence the artwork-preferring dedup
// shared by both filters.
// Non-vacuous: reverting to `.DistinctBy(a => a.Name)` in the add chain fails both assertions.
[Test]
public async Task Should_Prefer_The_Artwork_Bearing_Duplicate_Actor()
{
var movieRepository = Substitute.For<IMovieRepository>();
var metadataRepository = Substitute.For<IMetadataRepository>();
movieRepository.AddActor(Arg.Any<MovieMetadata>(), Arg.Any<Actor>()).Returns(true);
metadataRepository.RemoveActor(Arg.Any<Actor>()).Returns(true);
metadataRepository
.UpdateSubtitles(
Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
Arg.Any<List<Subtitle>>(),
Arg.Any<CancellationToken>())
.Returns(false);
var existingMetadata = NewMetadata();
var existing = new PlexMovie { Id = 1, MovieMetadata = [existingMetadata] };
// Plex lists the same person twice, artwork-less copy FIRST
MovieMetadata incoming = NewMetadata();
incoming.Actors =
[
new Actor { Name = "Dup", Role = "no-artwork", Artwork = null },
new Actor
{
Name = "Dup",
Role = "with-artwork",
Artwork = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = "/art/dup.jpg" }
}
];
var scanner = new TestPlexMovieLibraryScanner(movieRepository, metadataRepository);
(await scanner.Update(new MediaItemScanResult<PlexMovie>(existing), incoming)).IsRight.ShouldBeTrue();
// exactly one actor, and it is the one that carries the artwork (and its Role came along)
await movieRepository.Received(1).AddActor(Arg.Any<MovieMetadata>(), Arg.Any<Actor>());
existingMetadata.Actors.Count.ShouldBe(1);
existingMetadata.Actors[0].Artwork.ShouldNotBeNull();
existingMetadata.Actors[0].Role.ShouldBe("with-artwork");
}
// The follow-on half of the finding: an artwork-less actor ALREADY stored must still be upgraded when
// the incoming set contains the same name twice. This is the case that used to be permanently stuck.
[Test]
public async Task Should_Upgrade_An_Existing_Artworkless_Actor_Despite_Duplicate_Incoming_Names()
{
var movieRepository = Substitute.For<IMovieRepository>();
var metadataRepository = Substitute.For<IMetadataRepository>();
movieRepository.AddActor(Arg.Any<MovieMetadata>(), Arg.Any<Actor>()).Returns(true);
metadataRepository.RemoveActor(Arg.Any<Actor>()).Returns(true);
metadataRepository
.UpdateSubtitles(
Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
Arg.Any<List<Subtitle>>(),
Arg.Any<CancellationToken>())
.Returns(false);
var existingMetadata = NewMetadata();
existingMetadata.Actors = [new Actor { Name = "Dup", Role = "stale", Artwork = null }];
var existing = new PlexMovie { Id = 1, MovieMetadata = [existingMetadata] };
MovieMetadata incoming = NewMetadata();
incoming.Actors =
[
new Actor { Name = "Dup", Role = "no-artwork", Artwork = null },
new Actor
{
Name = "Dup",
Role = "with-artwork",
Artwork = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = "/art/dup.jpg" }
}
];
var scanner = new TestPlexMovieLibraryScanner(movieRepository, metadataRepository);
(await scanner.Update(new MediaItemScanResult<PlexMovie>(existing), incoming)).IsRight.ShouldBeTrue();
// the stale artwork-less row is removed and replaced by the artwork-bearing one
await metadataRepository.Received(1).RemoveActor(Arg.Is<Actor>(a => a.Role == "stale"));
existingMetadata.Actors.Count.ShouldBe(1);
existingMetadata.Actors[0].Artwork.ShouldNotBeNull();
}
private static MovieMetadata NewMetadata() =>
new()
{
MetadataKind = MetadataKind.External,
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Directors = [],
Writers = [],
Guids = [],
Subtitles = [],
Artwork = []
};
}
// Exposes the protected UpdateMetadata. Every dependency the metadata path does not touch is a
// substitute; nothing here drives ScanLibrary.
private sealed class TestPlexMovieLibraryScanner : PlexMovieLibraryScanner
{
public TestPlexMovieLibraryScanner(
IMovieRepository movieRepository,
IMetadataRepository metadataRepository)
: base(
Substitute.For<IScannerProxy>(),
Substitute.For<IPlexServerApiClient>(),
movieRepository,
metadataRepository,
Substitute.For<IMediaSourceRepository>(),
Substitute.For<IPlexMovieRepository>(),
Substitute.For<IPlexMetadataRepository>(),
Substitute.For<IPlexPathReplacementService>(),
Substitute.For<IFileSystem>(),
Substitute.For<ILocalChaptersProvider>(),
NullLogger<PlexMovieLibraryScanner>.Instance)
{
}
public Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> Update(
MediaItemScanResult<PlexMovie> result,
MovieMetadata fullMetadata) =>
UpdateMetadata(result, fullMetadata, CancellationToken.None);
}
}