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;
///
/// 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.
///
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 { _playedOnPrimary, _playedViaAlternate },
Ids(BaseItemKind.Movie, isPlayed: true));
}
[Fact]
public void IsUnplayed_DropsAMoviePlayedThroughItsAlternateVersion()
{
Assert.Equal(
new HashSet { _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 { _seriesPlayedViaAlternate }, Ids(BaseItemKind.Series, isPlayed: true));
Assert.Equal(new HashSet { _unplayedSeries }, Ids(BaseItemKind.Series, isPlayed: false));
}
private HashSet 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!
});
}