From fd016cfb13266dc67f0ce5982e6d4965d8fbd273 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 6 Sep 2026 17:19:36 +0200 Subject: Share played state across alternate versions --- .../Library/MovieSimilarItemsProviderTests.cs | 155 +++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs (limited to 'tests/Jellyfin.Server.Implementations.Tests/Library') 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; + +/// +/// Covers how 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. +/// +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(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + _provider = new MovieSimilarItemsProvider( + CreateDbContextFactory(), + CreateBaseItemRepository(itemTypeLookup), + serverConfigurationManager.Object, + new Mock().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> 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 + }; +} -- cgit v1.2.3