Share played state across alternate versions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
// Leaf items carry their own played state.
|
||||
return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
|
||||
.Or(IsFolderFilter.Not().And(BuildLeafIsPlayedFilter(context, user.Id)));
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
+173
@@ -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!
|
||||
});
|
||||
}
|
||||
+155
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user