aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests/Library
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Library')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs155
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs117
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs33
3 files changed, 305 insertions, 0 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs
new file mode 100644
index 0000000000..297f930205
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Emby.Server.Implementations.Library.SimilarItems;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Tests.Item;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Covers how <see cref="MovieSimilarItemsProvider"/> treats alternate versions: they share their
+/// primary's genres, tags, studios and people, so they score like it and must not be offered as
+/// something similar - neither as another copy of a recommendation nor as a match for the source.
+/// </summary>
+public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture
+{
+ private readonly MovieSimilarItemsProvider _provider;
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+ private readonly string _movieTypeName;
+
+ private readonly Guid _source = Guid.NewGuid();
+ private readonly Guid _sourceAlternate = Guid.NewGuid();
+ private readonly Guid _similar = Guid.NewGuid();
+ private readonly Guid _similarAlternate = Guid.NewGuid();
+ private readonly Guid _unrelated = Guid.NewGuid();
+
+ public MovieSimilarItemsProviderTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!;
+
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ var serverConfigurationManager = new Mock<IServerConfigurationManager>();
+ serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _provider = new MovieSimilarItemsProvider(
+ CreateDbContextFactory(),
+ CreateBaseItemRepository(itemTypeLookup),
+ serverConfigurationManager.Object,
+ new Mock<ILibraryManager>().Object);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_ReturnsThePrimaryAndNeitherVersionOfTheSource()
+ {
+ var items = await GetSimilarItemsAsync().ConfigureAwait(true);
+
+ Assert.Equal([_similar], items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_DoesNotOfferAnAlternateVersionOfAMatch()
+ {
+ var items = await GetSimilarItemsAsync().ConfigureAwait(true);
+
+ Assert.DoesNotContain(_similarAlternate, items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_DoesNotOfferTheSourcesOwnOtherVersion()
+ {
+ var items = await GetSimilarItemsAsync().ConfigureAwait(true);
+
+ Assert.DoesNotContain(_sourceAlternate, items);
+ }
+
+ private async Task<List<Guid>> GetSimilarItemsAsync()
+ {
+ var results = await _provider.GetSimilarItemsAsync(
+ new Movie { Id = _source, Name = "Source" },
+ new SimilarItemsQuery { User = _user, Limit = 10, DtoOptions = new DtoOptions() },
+ CancellationToken.None).ConfigureAwait(false);
+
+ return results.Select(i => i.Id).ToList();
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ // One shared genre, so every movie but the unrelated one scores against the source.
+ var shared = CreateItemValue("Action", "action");
+ var other = CreateItemValue("Comedy", "comedy");
+
+ var source = AddMovie(context, _source, "Source", primaryVersionId: null);
+ var sourceAlternate = AddMovie(context, _sourceAlternate, "Source 4K", primaryVersionId: _source);
+ var similar = AddMovie(context, _similar, "Similar", primaryVersionId: null);
+ var similarAlternate = AddMovie(context, _similarAlternate, "Similar 4K", primaryVersionId: _similar);
+ var unrelated = AddMovie(context, _unrelated, "Unrelated", primaryVersionId: null);
+
+ context.Users.Add(_user);
+ context.ItemValues.AddRange(shared, other);
+ context.ItemValuesMap.AddRange(
+ CreateMap(source, shared),
+ CreateMap(sourceAlternate, shared),
+ CreateMap(similar, shared),
+ CreateMap(similarAlternate, shared),
+ CreateMap(unrelated, other));
+
+ context.SaveChanges();
+ }
+
+ private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId)
+ {
+ var item = new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ SortName = name,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false,
+ // An alternate presents under its primary's key, which is what collapses the group in listings.
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"),
+ PrimaryVersionId = primaryVersionId
+ };
+
+ context.BaseItems.Add(item);
+ return item;
+ }
+
+ private static ItemValue CreateItemValue(string value, string cleanValue)
+ => new()
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = value,
+ CleanValue = cleanValue
+ };
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ => new()
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
new file mode 100644
index 0000000000..30f7bed208
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library.Validators;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Tests for how the people validator decides which credits need a person item and which person items
+/// nothing credits any more. Keying either half on the item's name rather than its id put the two halves
+/// in a loop that created, refreshed and deleted the same people on every run, so these pin the id.
+/// </summary>
+public class PeopleValidatorPartitionTests
+{
+ // Stands in for the real item-by-name id: derived from the credit name, case-insensitively, and
+ // from nothing else. The property that matters is that it does not depend on the item's own name.
+ private static Guid PersonId(string creditName)
+ {
+#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
+ var hash = System.Security.Cryptography.MD5.HashData(
+ System.Text.Encoding.Unicode.GetBytes(creditName.ToLowerInvariant()));
+#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms
+ return new Guid(hash);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ProviderRenamedThePerson_KeepsThemAndCreatesNothing()
+ {
+ // The credit still says "AURORA"; the item it made has been renamed to "Aurora" by the provider
+ // that refreshed it. Nothing about the library changed, so nothing should be created or deleted.
+ var credits = new[] { "AURORA" };
+ var existing = new HashSet<Guid> { PersonId("AURORA") };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Theory]
+ // Every shape of rename seen in the wild on a real library.
+ [InlineData("AURORA")]
+ [InlineData("Amir AboulEla")]
+ [InlineData("Miguel Ángel Fuentes")]
+ [InlineData("a‐ha")]
+ [InlineData("윤현민")]
+ public void PartitionCreditsByPersonId_CreditWithAnItem_IsNeverBothCreatedAndDeleted(string creditName)
+ {
+ var existing = new HashSet<Guid> { PersonId(creditName) };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId([creditName], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditWithNoItem_IsCreated()
+ {
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["Wanted Person"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Equal(["Wanted Person"], newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ItemNoCreditNames_IsDead()
+ {
+ var orphan = PersonId("Nobody Credits Me");
+ var existing = new HashSet<Guid> { PersonId("Credited"), orphan };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(["Credited"], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Equal([orphan], deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditsNormalizingOntoOneId_CreateOneItem()
+ {
+ // "AURORA" and "Aurora" are one person as far as the item-by-name id is concerned, so exactly
+ // one of them should create the item and neither should end up dead.
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["AURORA", "Aurora", "aurora"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Single(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_SecondRunAfterCreating_AsksForNothingFurther()
+ {
+ // The churn showed up as a run that never settled, so drive two rounds: whatever round one
+ // created must leave round two with nothing to do.
+ string[] credits = ["AURORA", "Amir AboulEla", "Miguel Ángel Fuentes"];
+ var existing = new HashSet<Guid>();
+
+ var (firstNames, firstDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+ Assert.Equal(3, firstNames.Count);
+ Assert.Empty(firstDead);
+
+ foreach (var created in firstNames)
+ {
+ existing.Add(PersonId(created));
+ }
+
+ var (secondNames, secondDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(secondNames);
+ Assert.Empty(secondDead);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
index feb2d8a625..67d924d152 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
@@ -62,6 +62,36 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.Equal(expectedId, actualId);
}
+ [Theory]
+ [InlineData("/media/Show/Season 01 [anidbid=11111]", "AniDB", "11111")]
+ [InlineData("/media/Show/Season 01 [anidbid-11111]", "AniDB", "11111")]
+ [InlineData("/media/Show/Season 02 [anilistid=22222]", "AniList", "22222")]
+ [InlineData("/media/Show/Season 02 (anilistid=22222)", "AniList", "22222")]
+ [InlineData("/media/Show/Season 03 [anisearchid=33333]", "AniSearch", "33333")]
+ public void Resolve_SeasonFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId)
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(providerKey, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
[Fact]
public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll()
{
@@ -140,6 +170,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _));
Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _));
Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _));
+ Assert.False(season.TryGetProviderId("AniDB", out _));
+ Assert.False(season.TryGetProviderId("AniList", out _));
+ Assert.False(season.TryGetProviderId("AniSearch", out _));
}
}
}