aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs3
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs30
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs173
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs155
4 files changed, 350 insertions, 11 deletions
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
index 57d1f7c770..b1547e72fe 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -260,7 +260,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
}
var candidateRows = await context.ItemValuesMap.AsNoTracking()
- .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
+ .Where(m => !m.Item.PrimaryVersionId.HasValue && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
.ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -276,6 +276,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
if (personSourceRows.Count > 0)
{
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
+ .Where(m => !m.Item.PrimaryVersionId.HasValue)
.Where(m => context.PeopleBaseItemMap
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
.Select(s => s.PeopleId)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index d635b38df5..a745c3309f 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -38,22 +38,32 @@ public sealed partial class BaseItemRepository
// Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree.
private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user)
{
- var userId = user.Id;
+ // Folders (Series, Seasons, BoxSets, albums, ...) carry no played state of their own and count
+ // as played once no descendant is left unplayed.
+ var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
+ .Where(BuildLeafIsPlayedFilter(context, user.Id).Not());
+
+ return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
+ .Or(IsFolderFilter.Not().And(BuildLeafIsPlayedFilter(context, user.Id)));
+ }
- // Leaf items carry their own played state.
+ private static Expression<Func<BaseItemEntity, bool>> BuildLeafIsPlayedFilter(JellyfinDbContext context, Guid userId)
+ {
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
- // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
- // descendant is left unplayed, matching what the DTO reports for them. This has to key off
- // the item itself rather than off the requested item types: tag and collection listings mix
- // folders and leaf items in a single query.
- var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
- .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ // The primaries of every version group holding a played row, whichever version carries it.
+ var playedGroupIds = context.BaseItems
+ .Where(v => v.PrimaryVersionId != null
+ && context.UserData.Any(ud => ud.UserId == userId
+ && ud.Played
+ && (ud.ItemId == v.Id || ud.ItemId == v.PrimaryVersionId)))
+ .Select(v => v.PrimaryVersionId!.Value);
- return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
- .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
+ return e => playedItemIds.Contains(e.Id)
+ || playedGroupIds.Contains(e.Id)
+ || (e.PrimaryVersionId != null && playedGroupIds.Contains(e.PrimaryVersionId.Value));
}
// "und" is the language filters' stand-in for a track that declares no language at all.
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
new file mode 100644
index 0000000000..0958512b1a
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the isPlayed filter over items with alternate versions: playback is recorded against the
+/// version that was actually played, so the played state belongs to the version group rather than to
+/// the row that happens to carry it.
+/// </summary>
+public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
+{
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+ private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+
+ private readonly BaseItemRepository _repository;
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ private readonly Guid _playedViaAlternate = Guid.NewGuid();
+ private readonly Guid _playedOnPrimary = Guid.NewGuid();
+ private readonly Guid _unplayedWithAlternate = Guid.NewGuid();
+ private readonly Guid _unplayedWithoutAlternate = Guid.NewGuid();
+
+ private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid();
+ private readonly Guid _unplayedSeries = Guid.NewGuid();
+
+ public BaseItemRepositoryPlayedVersionTests()
+ {
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void IsPlayed_CountsAMoviePlayedThroughItsAlternateVersion()
+ {
+ Assert.Equal(
+ new HashSet<Guid> { _playedOnPrimary, _playedViaAlternate },
+ Ids(BaseItemKind.Movie, isPlayed: true));
+ }
+
+ [Fact]
+ public void IsUnplayed_DropsAMoviePlayedThroughItsAlternateVersion()
+ {
+ Assert.Equal(
+ new HashSet<Guid> { _unplayedWithAlternate, _unplayedWithoutAlternate },
+ Ids(BaseItemKind.Movie, isPlayed: false));
+ }
+
+ [Fact]
+ public void IsPlayed_KeepsAPlayedPrimaryWhoseAlternateHasNoRowOfItsOwn()
+ {
+ Assert.Contains(_playedOnPrimary, Ids(BaseItemKind.Movie, isPlayed: true));
+ }
+
+ [Fact]
+ public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
+ {
+ Assert.Equal(new HashSet<Guid> { _seriesPlayedViaAlternate }, Ids(BaseItemKind.Series, isPlayed: true));
+ Assert.Equal(new HashSet<Guid> { _unplayedSeries }, Ids(BaseItemKind.Series, isPlayed: false));
+ }
+
+ private HashSet<Guid> Ids(BaseItemKind kind, bool isPlayed)
+ => _repository
+ .GetItemList(new InternalItemsQuery(_user)
+ {
+ IncludeItemTypes = [kind],
+ IsPlayed = isPlayed
+ })
+ .Select(i => i.Id)
+ .ToHashSet();
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.Users.Add(_user);
+
+ // Only the alternate carries the played row, which is what playing that version records.
+ AddMovieWithAlternate(context, _playedViaAlternate, "A", playedPrimary: false, playedAlternate: true);
+ AddMovieWithAlternate(context, _playedOnPrimary, "B", playedPrimary: true, playedAlternate: false);
+ AddMovieWithAlternate(context, _unplayedWithAlternate, "C", playedPrimary: false, playedAlternate: false);
+ AddItem(context, _unplayedWithoutAlternate, MovieType, "D");
+
+ AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true);
+ AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false);
+
+ context.SaveChanges();
+ }
+
+ private void AddMovieWithAlternate(JellyfinDbContext context, Guid primaryId, string name, bool playedPrimary, bool playedAlternate)
+ {
+ AddItem(context, primaryId, MovieType, name);
+ AddAlternateVersion(context, primaryId, MovieType, $"{name} 4K", playedAlternate);
+
+ if (playedPrimary)
+ {
+ AddPlayedUserData(context, primaryId);
+ }
+ }
+
+ private void AddSeriesWithAlternateEpisode(JellyfinDbContext context, Guid seriesId, string name, bool playedAlternate)
+ {
+ var episodeId = Guid.NewGuid();
+
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = seriesId,
+ Type = SeriesType,
+ Name = name,
+ SortName = name,
+ PresentationUniqueKey = seriesId.ToString("N"),
+ IsFolder = true
+ });
+
+ AddItem(context, episodeId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+
+ AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate);
+ }
+
+ private void AddItem(JellyfinDbContext context, Guid id, string type, string name)
+ => context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = type,
+ Name = name,
+ SortName = name,
+ PresentationUniqueKey = id.ToString("N")
+ });
+
+ private void AddAlternateVersion(JellyfinDbContext context, Guid primaryId, string type, string name, bool played)
+ {
+ var alternateId = Guid.NewGuid();
+
+ // An alternate presents under its primary's key, which is what collapses the group in listings.
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = alternateId,
+ Type = type,
+ Name = name,
+ SortName = name,
+ PresentationUniqueKey = primaryId.ToString("N"),
+ PrimaryVersionId = primaryId
+ });
+
+ if (played)
+ {
+ AddPlayedUserData(context, alternateId);
+ }
+ }
+
+ private void AddPlayedUserData(JellyfinDbContext context, Guid itemId)
+ => context.UserData.Add(new UserData
+ {
+ ItemId = itemId,
+ UserId = _user.Id,
+ CustomDataKey = itemId.ToString("N"),
+ Played = true,
+ Item = null!,
+ User = null!
+ });
+}
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
+ };
+}