aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs
blob: 421671b5200eff4c2ea3d18cd49730df4905644e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
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;

/// <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 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<ILibraryManager> _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<IServerConfigurationManager>();
        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<InternalItemsQuery>(), It.IsAny<User>()))
            .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds);
    }

    private async Task<List<Guid>> 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
        };
}