Merge pull request 'fix(500): dedup incoming metadata collections so a duplicate name inserts once' (#602) from fix/500-distinct-metadata into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 32s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 32s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
This commit was merged in pull request #602.
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,10 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
|
||||
private async Task<bool> ReconcileGenres(MusicVideoMetadata existing, MusicVideoMetadata incoming)
|
||||
{
|
||||
existing.Genres ??= [];
|
||||
List<Genre> incomingGenres = incoming.Genres ?? [];
|
||||
// ersatztv#500: dedup on Name — the add filter below is materialized (.ToList()) BEFORE the loop
|
||||
// mutates existing.Genres, so two identically-named incoming entries would both pass and both insert.
|
||||
// Deduping here (not just in the add loop) is safe: the remove filter only asks "is this name present".
|
||||
List<Genre> incomingGenres = (incoming.Genres ?? []).DistinctBy(g => g.Name).ToList();
|
||||
var updated = false;
|
||||
|
||||
foreach (Genre genre in existing.Genres.Filter(g => incomingGenres.All(g2 => g2.Name != g.Name)).ToList())
|
||||
@@ -317,7 +320,7 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
|
||||
private async Task<bool> ReconcileTags(MusicVideoMetadata existing, MusicVideoMetadata incoming)
|
||||
{
|
||||
existing.Tags ??= [];
|
||||
List<Tag> incomingTags = incoming.Tags ?? [];
|
||||
List<Tag> incomingTags = (incoming.Tags ?? []).DistinctBy(t => t.Name).ToList();
|
||||
var updated = false;
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => incomingTags.All(t2 => t2.Name != t.Name)).ToList())
|
||||
@@ -338,7 +341,7 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
|
||||
private async Task<bool> ReconcileStudios(MusicVideoMetadata existing, MusicVideoMetadata incoming)
|
||||
{
|
||||
existing.Studios ??= [];
|
||||
List<Studio> incomingStudios = incoming.Studios ?? [];
|
||||
List<Studio> incomingStudios = (incoming.Studios ?? []).DistinctBy(s => s.Name).ToList();
|
||||
var updated = false;
|
||||
|
||||
foreach (Studio studio in existing.Studios.Filter(s => incomingStudios.All(s2 => s2.Name != s.Name)).ToList())
|
||||
@@ -359,7 +362,7 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
|
||||
private async Task<bool> ReconcileArtists(MusicVideoMetadata existing, MusicVideoMetadata incoming)
|
||||
{
|
||||
existing.Artists ??= [];
|
||||
List<MusicVideoArtist> incomingArtists = incoming.Artists ?? [];
|
||||
List<MusicVideoArtist> incomingArtists = (incoming.Artists ?? []).DistinctBy(a => a.Name).ToList();
|
||||
var updated = false;
|
||||
|
||||
foreach (MusicVideoArtist artist in existing.Artists
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -200,7 +200,11 @@ public class PlexMovieLibraryScanner :
|
||||
}
|
||||
}
|
||||
|
||||
// ersatztv#500: each add filter below is materialized (.ToList()) BEFORE the loop mutates
|
||||
// existingMetadata, so two identically-named incoming entries would both pass it and both insert.
|
||||
// Deduplicate the incoming set on the same key the filter compares (Name; Guid for Guids).
|
||||
foreach (Genre genre in fullMetadata.Genres
|
||||
.DistinctBy(g => g.Name)
|
||||
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
@@ -223,6 +227,7 @@ public class PlexMovieLibraryScanner :
|
||||
}
|
||||
|
||||
foreach (Studio studio in fullMetadata.Studios
|
||||
.DistinctBy(s => s.Name)
|
||||
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
@@ -233,9 +238,23 @@ public class PlexMovieLibraryScanner :
|
||||
}
|
||||
}
|
||||
|
||||
// ersatztv#500: Actors are the one collection whose dedup cannot be a bare DistinctBy in the add
|
||||
// chain. Unlike the others, the REMOVE filter below is keyed on (Name, artwork-presence) — it is what
|
||||
// drops an artwork-less actor so the add loop can re-add it WITH artwork. So the deduped set has to be
|
||||
// hoisted and used by BOTH filters, and it has to prefer the artwork-bearing duplicate:
|
||||
// - dedup only the add chain and the remove filter still sees the artwork-less duplicate, which makes
|
||||
// its artwork-upgrade clause false -> the stale row is never removed and never re-added, so the
|
||||
// artwork is lost on EVERY subsequent scan, not just this one;
|
||||
// - keep the FIRST duplicate blindly and we may keep the one without artwork.
|
||||
// Actor also carries Role/Order, so first-wins would silently drop those too.
|
||||
List<Actor> incomingActors = fullMetadata.Actors
|
||||
.GroupBy(a => a.Name)
|
||||
.Map(g => g.FirstOrDefault(a => a.Artwork != null) ?? g.First())
|
||||
.ToList();
|
||||
|
||||
foreach (Actor actor in existingMetadata.Actors
|
||||
.Filter(a =>
|
||||
fullMetadata.Actors.All(a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
incomingActors.All(a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Actors.Remove(actor);
|
||||
@@ -245,7 +264,7 @@ public class PlexMovieLibraryScanner :
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Actor actor in fullMetadata.Actors
|
||||
foreach (Actor actor in incomingActors
|
||||
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
@@ -268,6 +287,7 @@ public class PlexMovieLibraryScanner :
|
||||
}
|
||||
|
||||
foreach (Director director in fullMetadata.Directors
|
||||
.DistinctBy(g => g.Name)
|
||||
.Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
@@ -290,6 +310,7 @@ public class PlexMovieLibraryScanner :
|
||||
}
|
||||
|
||||
foreach (Writer writer in fullMetadata.Writers
|
||||
.DistinctBy(g => g.Name)
|
||||
.Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
@@ -312,6 +333,7 @@ public class PlexMovieLibraryScanner :
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in fullMetadata.Guids
|
||||
.DistinctBy(g => g.Guid)
|
||||
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
@@ -335,6 +357,7 @@ public class PlexMovieLibraryScanner :
|
||||
}
|
||||
|
||||
foreach (Tag tag in fullMetadata.Tags
|
||||
.DistinctBy(g => g.Name)
|
||||
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
|
||||
@@ -468,6 +468,105 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
existingMetadata.Track.ShouldBe(4);
|
||||
}
|
||||
|
||||
// ersatztv#500: if Jellyfin reports two identically-named genres/tags/studios/artists for one item and
|
||||
// that name is not yet on the existing item, BOTH used to pass the add filter (which is materialized with
|
||||
// .ToList() before the loop mutates `existing`) and both got inserted — a duplicate row. Dedup the
|
||||
// incoming collection on the same key the filter compares.
|
||||
// Non-vacuous: dropping the DistinctBy calls in Reconcile* makes every Received(1) below see 2 calls.
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Not_Double_Insert_Duplicate_Named_Incoming_Collection_Entries()
|
||||
{
|
||||
JellyfinLibrary library = BuildLibrary(1, "/data/music");
|
||||
var connectionParameters = new JellyfinConnectionParameters("http://jellyfin", "api-key", 1);
|
||||
const string VideoPath = "/data/music/artist1/song1.mkv";
|
||||
|
||||
// the existing item carries NONE of the incoming names, so every incoming entry is an "add"
|
||||
var existing = new MusicVideo
|
||||
{
|
||||
Id = 7,
|
||||
ArtistId = 3,
|
||||
MediaVersions =
|
||||
[
|
||||
new MediaVersion
|
||||
{
|
||||
MediaFiles = [new MediaFile { Path = VideoPath }],
|
||||
Streams = []
|
||||
}
|
||||
],
|
||||
MusicVideoMetadata =
|
||||
[
|
||||
new MusicVideoMetadata
|
||||
{
|
||||
Id = 11,
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Artists = []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var artistRepository = Substitute.For<IArtistRepository>();
|
||||
artistRepository.GetArtistByMetadata(Arg.Any<int>(), Arg.Any<ArtistMetadata>())
|
||||
.Returns(Some(new Artist { Id = 3, ArtistMetadata = new List<ArtistMetadata>() }));
|
||||
artistRepository.DeleteEmptyArtists(Arg.Any<LibraryPath>()).Returns(new List<int>());
|
||||
|
||||
var musicVideoRepository = Substitute.For<IMusicVideoRepository>();
|
||||
musicVideoRepository
|
||||
.GetOrAdd(Arg.Any<Artist>(), Arg.Any<LibraryPath>(), Arg.Any<LibraryFolder>(), Arg.Any<string>())
|
||||
.Returns(Right<BaseError, MediaItemScanResult<MusicVideo>>(
|
||||
new MediaItemScanResult<MusicVideo>(existing) { IsAdded = false }));
|
||||
musicVideoRepository.FindMusicVideoPaths(Arg.Any<LibraryPath>())
|
||||
.Returns(new List<string> { VideoPath }.AsEnumerable());
|
||||
musicVideoRepository.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Any<Genre>()).Returns(true);
|
||||
musicVideoRepository.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Any<Tag>()).Returns(true);
|
||||
musicVideoRepository.AddStudio(Arg.Any<MusicVideoMetadata>(), Arg.Any<Studio>()).Returns(true);
|
||||
musicVideoRepository.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Any<MusicVideoArtist>()).Returns(true);
|
||||
|
||||
var metadataRepository = Substitute.For<IMetadataRepository>();
|
||||
metadataRepository.Update(Arg.Any<ErsatzTV.Core.Domain.Metadata>()).Returns(true);
|
||||
metadataRepository.UpdateStatistics(Arg.Any<MediaItem>(), Arg.Any<MediaVersion>(), Arg.Any<bool>())
|
||||
.Returns(false);
|
||||
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
libraryRepository.GetParentFolderId(Arg.Any<LibraryPath>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.None);
|
||||
libraryRepository.GetOrAddFolder(Arg.Any<LibraryPath>(), Arg.Any<Option<int>>(), Arg.Any<string>())
|
||||
.Returns(new LibraryFolder { Id = 5, Path = "/data/music/artist1" });
|
||||
|
||||
// Jellyfin reports each name TWICE for the same item
|
||||
JellyfinMusicVideoLibraryScanner scanner = BuildScannerWith(
|
||||
FakeApi(() => BuildIncoming(
|
||||
VideoPath, "Artist 1", "Song 1",
|
||||
genres: ["Synthwave", "Synthwave"],
|
||||
tags: ["DupTag", "DupTag"],
|
||||
studios: ["DupStudio", "DupStudio"],
|
||||
artists: ["Artist 1", "Artist 1"])),
|
||||
artistRepository,
|
||||
musicVideoRepository,
|
||||
libraryRepository,
|
||||
metadataRepository);
|
||||
|
||||
(await scanner.ScanLibrary(connectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
await musicVideoRepository.Received(1)
|
||||
.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Is<Genre>(g => g.Name == "Synthwave"));
|
||||
await musicVideoRepository.Received(1)
|
||||
.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Is<Tag>(t => t.Name == "DupTag"));
|
||||
await musicVideoRepository.Received(1)
|
||||
.AddStudio(Arg.Any<MusicVideoMetadata>(), Arg.Is<Studio>(s => s.Name == "DupStudio"));
|
||||
await musicVideoRepository.Received(1)
|
||||
.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Is<MusicVideoArtist>(a => a.Name == "Artist 1"));
|
||||
|
||||
// and the in-memory collections mirror the DB: one entry each, not two
|
||||
MusicVideoMetadata metadata = existing.MusicVideoMetadata.Head();
|
||||
metadata.Genres.Count.ShouldBe(1);
|
||||
metadata.Tags.Count.ShouldBe(1);
|
||||
metadata.Studios.Count.ShouldBe(1);
|
||||
metadata.Artists.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
private JellyfinMusicVideoLibraryScanner BuildScannerWith(
|
||||
IJellyfinApiClient apiClient,
|
||||
IArtistRepository artistRepository,
|
||||
|
||||
+2
-2
@@ -2479,8 +2479,8 @@ remote scanner tripped it, and the feature had never run in prod, CI, or locally
|
||||
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
|
||||
`key: scan.musicvideo-reconciliation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
|
||||
**Signals:** music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496
|
||||
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness
|
||||
**Signals:** music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse, remove-stale+add-new dedup · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496, #500
|
||||
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness. #500 — when mirroring the remove-stale + add-new idiom, dedup the incoming set on **the same key its add filter compares** (the filter is materialized before the loop mutates `existing`, so duplicates both pass): `Name`, `Guid` for guids, and for Plex `Actors` an artwork-preferring dedup shared with the remove filter (whose key is `(Name, artwork-presence)`). Remaining un-deduped copies of the idiom: #600.
|
||||
|
||||
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
|
||||
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points
|
||||
|---|---|---|
|
||||
| `ErsatzTV.Tests` | API controllers + MediatR handlers | In-memory SQLite fixture: a shared `SqliteConnection("Data Source=:memory:;Foreign Keys=False")` kept open + `EnsureCreatedAsync()` (**not** full migration replay) + `PRAGMA foreign_keys=OFF`, then seed; a tiny `IDbContextFactory` wraps `new TvContext(...)`. 828 tests currently. |
|
||||
| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. 543 tests + 1 skipped under `TZ=UTC` (the Block playout golden additionally skips under a non-UTC `TZ`; see Golden-file nets). |
|
||||
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. 1471 tests. Additionally contains `Core/FFmpeg/TranscodingTests` — `[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
|
||||
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests` — `[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
|
||||
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
|
||||
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
|
||||
| `web/` (vitest) | React SPA unit tests | 330 tests; run alongside typecheck + build (see below). |
|
||||
|
||||
Reference in New Issue
Block a user