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;
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 static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
private readonly MovieSimilarItemsProvider _provider;
private readonly Mock _libraryManager = new();
private readonly User _user = new("test", "auth-provider", "reset-provider");
private readonly string _movieTypeName;
private readonly string _folderTypeName;
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();
// A second scenario, in two libraries and on a genre of its own, for the group whose primary the
// user may not be able to reach at all.
private readonly Guid _crossSource = Guid.NewGuid();
private readonly Guid _crossLibraryPrimary = Guid.NewGuid();
private readonly Guid _crossLibraryVersion = Guid.NewGuid();
private readonly Guid _sameLibraryPrimary = Guid.NewGuid();
private readonly Guid _sameLibraryVersion = Guid.NewGuid();
public MovieSimilarItemsProviderTests()
{
var itemTypeLookup = new ItemTypeLookup();
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!;
_folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!;
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,
_libraryManager.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);
}
[Fact]
public async Task GetSimilarItems_UserWithoutThePrimarysLibrary_OffersTheVersion()
{
// The user may only open the library the 1080p version is in, so its primary is out of reach
// and the version is all that is left to stand in for the group.
RestrictUserTo(_movieLibraryId);
var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
Assert.Contains(_crossLibraryVersion, items);
Assert.DoesNotContain(_crossLibraryPrimary, items);
}
[Fact]
public async Task GetSimilarItems_UserWithBothLibraries_OffersThePrimaryOfTheGroupOnce()
{
RestrictUserTo(_movieLibraryId, _movie4KLibraryId);
var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
Assert.Contains(_crossLibraryPrimary, items);
Assert.DoesNotContain(_crossLibraryVersion, items);
}
[Fact]
public async Task GetSimilarItems_GroupMergedInsideOneLibrary_StillOffersOnlyThePrimary()
{
RestrictUserTo(_movieLibraryId, _movie4KLibraryId);
var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
Assert.Contains(_sameLibraryPrimary, items);
Assert.DoesNotContain(_sameLibraryVersion, items);
}
private void RestrictUserTo(params Guid[] libraryIds)
{
_libraryManager
.Setup(l => l.ConfigureUserAccess(It.IsAny(), It.IsAny()))
.Callback((query, _) => query.TopParentIds = libraryIds);
}
private async Task> GetSimilarItemsAsync(Guid? sourceId = null)
{
var results = await _provider.GetSimilarItemsAsync(
new Movie { Id = sourceId ?? _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);
// The second scenario scores on a genre of its own, so it stays out of the results above.
var crossLibrary = CreateItemValue("Science Fiction", "science fiction");
AddLibrary(context, _movieLibraryId, "Movies");
AddLibrary(context, _movie4KLibraryId, "Movies-4K");
var crossSource = AddMovie(context, _crossSource, "Cross Source", primaryVersionId: null, libraryId: _movieLibraryId);
// The 4K version heads the group and lives in a library of its own.
var crossLibraryPrimary = AddMovie(context, _crossLibraryPrimary, "Coco 4K", primaryVersionId: null, libraryId: _movie4KLibraryId);
var crossLibraryVersion = AddMovie(context, _crossLibraryVersion, "Coco", primaryVersionId: _crossLibraryPrimary, libraryId: _movieLibraryId);
// A group merged inside one library, as a control.
var sameLibraryPrimary = AddMovie(context, _sameLibraryPrimary, "Up 4K", primaryVersionId: null, libraryId: _movieLibraryId);
var sameLibraryVersion = AddMovie(context, _sameLibraryVersion, "Up", primaryVersionId: _sameLibraryPrimary, libraryId: _movieLibraryId);
context.Users.Add(_user);
context.ItemValues.AddRange(shared, other, crossLibrary);
context.ItemValuesMap.AddRange(
CreateMap(source, shared),
CreateMap(sourceAlternate, shared),
CreateMap(similar, shared),
CreateMap(similarAlternate, shared),
CreateMap(unrelated, other),
CreateMap(crossSource, crossLibrary),
CreateMap(crossLibraryPrimary, crossLibrary),
CreateMap(crossLibraryVersion, crossLibrary),
CreateMap(sameLibraryPrimary, crossLibrary),
CreateMap(sameLibraryVersion, crossLibrary));
context.SaveChanges();
}
private void AddLibrary(JellyfinDbContext context, Guid id, string name)
{
context.BaseItems.Add(new BaseItemEntity
{
Id = id,
Type = _folderTypeName,
Name = name,
Path = "/" + name,
IsFolder = true
});
}
private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId, Guid? libraryId = null)
{
var item = new BaseItemEntity
{
Id = id,
Type = _movieTypeName,
Name = name,
SortName = name,
ParentId = libraryId,
TopParentId = libraryId,
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
};
}