Share played state across alternate versions

This commit is contained in:
Shadowghost
2026-09-06 17:19:36 +02:00
parent f898c35b96
commit fd016cfb13
4 changed files with 350 additions and 11 deletions
@@ -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.
@@ -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!
});
}
@@ -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
};
}