diff options
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests')
18 files changed, 2514 insertions, 3 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index bdac59c013..679e6d17e3 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -154,7 +154,7 @@ public class DtoServiceTests .Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny<IReadOnlyList<Guid>>(), user)) .Returns(new Dictionary<Guid, (int Played, int Total)> { [season.Id] = (playedCount, totalCount) }); _libraryManagerMock - .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<Guid?>())) + .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>())) .Returns(new Dictionary<Guid, int> { [season.Id] = childCount }); return (season, user); diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs new file mode 100644 index 0000000000..cdb261de8d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.EntryPoints; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.EntryPoints; + +public class LibraryChangedNotifierTests +{ + // How long a test waits for the notifier's timer callback to run. Generous: the assertions are + // about a batch being sent at all, not about how promptly. + private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15); + + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly Mock<IServerConfigurationManager> _configurationManager = new(); + private readonly Mock<ISessionManager> _sessionManager = new(); + private readonly Mock<IUserManager> _userManager = new(); + private readonly Mock<IProviderManager> _providerManager = new(); + private readonly ServerConfiguration _configuration = new(); + + private int _flushCount; + + public LibraryChangedNotifierTests() + { + _configurationManager.SetupGet(e => e.Configuration).Returns(_configuration); + + // Reading the session list is the first thing a flush does, so it stands in for "a batch was + // sent" without having to mock a whole user library behind it. + _sessionManager.SetupGet(e => e.Sessions) + .Returns(() => + { + Interlocked.Increment(ref _flushCount); + return []; + }); + } + + [Fact] + public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow() + { + // Long enough that only the size cap can close the batch. + _configuration.LibraryUpdateDuration = 3600; + + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++) + { + RaiseItemUpdated(); + } + + Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + [Fact] + public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow() + { + // A scan changes items continuously. The window must run from the first change of a batch, or + // the batch never closes and holds every item it named alive for the length of the scan. + _configuration.LibraryUpdateDuration = 1; + + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0) + { + // Well below the window, and well below the size cap over the whole loop. + RaiseItemUpdated(); + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + private LibraryChangedNotifier CreateNotifier() + => new( + _libraryManager.Object, + _configurationManager.Object, + _sessionManager.Object, + _userManager.Object, + NullLogger<LibraryChangedNotifier>.Instance, + _providerManager.Object); + + // A folder passes the notifier's item filter without needing any of BaseItem's static services. + private void RaiseItemUpdated() + => _libraryManager.Raise( + e => e.ItemUpdated += null, + _libraryManager.Object, + new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } }); + + private async Task<bool> WaitForFlushAsync(int expected) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout) + { + if (Volatile.Read(ref _flushCount) >= expected) + { + return true; + } + + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + return false; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs new file mode 100644 index 0000000000..0274398f89 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.EntryPoints; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Session; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.EntryPoints; + +public class UserDataChangeNotifierTests +{ + // How long a test waits for the notifier's timer callback to run. Generous: the assertions are + // about a batch being sent at all, not about how promptly. + private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15); + + private readonly Mock<IUserDataManager> _userDataManager = new(); + private readonly Mock<ISessionManager> _sessionManager = new(); + private readonly Mock<IUserManager> _userManager = new(); + + private int _flushCount; + + public UserDataChangeNotifierTests() + { + _sessionManager + .Setup(e => e.SendMessageToUserSessions( + It.IsAny<System.Collections.Generic.List<Guid>>(), + SessionMessageType.UserDataChanged, + It.IsAny<Func<UserDataChangeInfo>>(), + It.IsAny<CancellationToken>())) + .Callback(() => Interlocked.Increment(ref _flushCount)) + .Returns(Task.CompletedTask); + } + + [Fact] + public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow() + { + // A scan changes user data continuously. The window must run from the first change of a batch, + // or the batch never closes and holds every item it named alive for the length of the scan. + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + var userId = Guid.NewGuid(); + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0) + { + // Well below the window, and well below the size cap over the whole loop. + RaiseUserDataSaved(userId); + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + private UserDataChangeNotifier CreateNotifier() + => new(_userDataManager.Object, _sessionManager.Object, _userManager.Object); + + // A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the + // notifier ignores outright. + private void RaiseUserDataSaved(Guid userId) + => _userDataManager.Raise( + e => e.UserDataSaved += null, + _userDataManager.Object, + new UserDataSaveEventArgs + { + UserId = userId, + SaveReason = UserDataSaveReason.UpdateUserRating, + Item = new Folder { Id = Guid.NewGuid() } + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs new file mode 100644 index 0000000000..298340d1b0 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs @@ -0,0 +1,215 @@ +using System; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Querying; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// The by-name listings count what a cleaned value is attached to by joining ItemValuesMap to +/// BaseItems. One item can reach the same clean value through more than one value row, so the +/// join has to be counted per distinct item; counting rows reports a multiple of the truth. +/// </summary> +public sealed class BaseItemRepositoryByNameItemCountsTests : SqliteDbTestFixture +{ + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryByNameItemCountsTests() + { + _itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(_itemTypeLookup); + } + + [Fact] + public void GetAllArtists_AlbumCreditedAsArtistAndAlbumArtist_CountsTheAlbumOnce() + { + // GetAllArtists spans both credit types, so an album whose artist is also its album artist + // reaches the one clean value through two rows. + SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.AlbumArtist); + + var result = _repository.GetAllArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + Assert.Equal(1, counts.ItemCount); + } + + [Fact] + public void GetAlbumArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() + { + // The shape that actually reaches users: only (Type, Value) is unique, so two differently + // cased credits of one type both clean down to a single name and both map the album. + SeedArtistWithAlbum(ItemValueType.AlbumArtist, ItemValueType.AlbumArtist); + + var result = _repository.GetAlbumArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + } + + [Fact] + public void GetArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() + { + SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.Artist); + + var result = _repository.GetArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + } + + [Theory] + [InlineData(BaseItemKind.Book)] + [InlineData(BaseItemKind.BoxSet)] + public void GetGenres_TaggedBookOrBoxSet_CountsIt(BaseItemKind kind) + { + // The listing used to dispatch only nine of the eleven counted types, so a genre on a book + // or a box set read as zero in a list and as one on the genre's own page. + SeedGenreWith(kind); + + var result = _repository.GetGenres(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, kind == BaseItemKind.Book ? counts.BookCount : counts.BoxSetCount); + Assert.Equal(1, counts.ItemCount); + } + + /// <summary> + /// Seeds one genre carried by a single item of the given kind. + /// </summary> + /// <param name="kind">The kind of the tagged item.</param> + private void SeedGenreWith(BaseItemKind kind) + { + const string Name = "Reference"; + const string CleanName = "reference"; + + using var ctx = CreateDbContext(); + + var genreId = Guid.Parse("dddddddd-0000-0000-0000-000000000001"); + var taggedId = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001"); + + ctx.BaseItems.Add(new BaseItemEntity + { + Id = genreId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre], + Name = Name, + CleanName = CleanName, + PresentationUniqueKey = genreId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }); + + var tagged = new BaseItemEntity + { + Id = taggedId, + Type = _itemTypeLookup.BaseItemKindNames[kind], + Name = "Tagged", + CleanName = "tagged", + PresentationUniqueKey = taggedId.ToString("N"), + IsFolder = false, + IsVirtualItem = false + }; + ctx.BaseItems.Add(tagged); + + var itemValue = new ItemValue + { + ItemValueId = Guid.Parse("ffffffff-0000-0000-0000-000000000001"), + Type = ItemValueType.Genre, + Value = Name, + CleanValue = CleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = taggedId, + ItemValueId = itemValue.ItemValueId, + Item = tagged, + ItemValue = itemValue + }); + + ctx.SaveChanges(); + } + + private static InternalItemsQuery CreateCountingQuery() + { + return new InternalItemsQuery(new User("test", "auth", "reset")) + { + DtoOptions = new DtoOptions(true) { Fields = [ItemFields.ItemCounts] } + }; + } + + /// <summary> + /// Seeds one artist and a single album mapped to that artist's clean name through two value + /// rows of the given types. + /// </summary> + /// <param name="first">The type of the first value row.</param> + /// <param name="second">The type of the second value row.</param> + private void SeedArtistWithAlbum(ItemValueType first, ItemValueType second) + { + const string Name = "Tangerine Dream"; + const string CleanName = "tangerine dream"; + + using var ctx = CreateDbContext(); + + var artistId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + var albumId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"); + + ctx.BaseItems.Add(new BaseItemEntity + { + Id = artistId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], + Name = Name, + CleanName = CleanName, + PresentationUniqueKey = artistId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }); + + var album = new BaseItemEntity + { + Id = albumId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum], + Name = "Phaedra", + CleanName = "phaedra", + PresentationUniqueKey = albumId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }; + ctx.BaseItems.Add(album); + + var types = new[] { first, second }; + for (var i = 0; i < types.Length; i++) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}"), + Type = types[i], + // Distinct values, one clean name: exactly what the unique index permits. + Value = i == 0 ? Name : Name.ToUpperInvariant(), + CleanValue = CleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = albumId, + ItemValueId = itemValue.ItemValueId, + Item = album, + ItemValue = itemValue + }); + } + + ctx.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs new file mode 100644 index 0000000000..0ca11eb58d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a +/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a +/// linked folder are only reachable by following the link and then the ancestor chain. +/// </summary> +public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _collection = Guid.NewGuid(); + private readonly Guid _series = Guid.NewGuid(); + private readonly Guid _season = Guid.NewGuid(); + private readonly Guid _episode = Guid.NewGuid(); + + // A movie the collection links directly, so the direct-child case is covered alongside the nested one. + private readonly Guid _collectionMovie = Guid.NewGuid(); + + // In the same library but outside the collection, as the control the assertions are read against. + private readonly Guid _otherSeries = Guid.NewGuid(); + private readonly Guid _otherEpisode = Guid.NewGuid(); + + public BaseItemRepositoryDescendantFilterTests() + { + using (var ctx = CreateDbContext()) + { + Seed(ctx); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void DescendantOfId_ReachesEpisodesOfALinkedSeries() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + DescendantOfId = _collection, + IncludeItemTypes = [BaseItemKind.Episode] + }); + + Assert.Equal([_episode], ids); + } + + [Fact] + public void DescendantOfId_ReturnsEveryLevelBelowTheCollection() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet(); + + Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order()); + } + + [Fact] + public void DescendantOfId_KeepsDirectlyLinkedChildren() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + DescendantOfId = _collection, + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal([_collectionMovie], ids); + } + + [Fact] + public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() }); + + Assert.Empty(ids); + } + + private void Seed(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" }); + context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" }); + context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" }); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. + AddAncestors(context, _series, _library); + AddAncestors(context, _season, _series, _library); + AddAncestors(context, _episode, _season, _series, _library); + AddAncestors(context, _collectionMovie, _library); + AddAncestors(context, _otherSeries, _library); + AddAncestors(context, _otherEpisode, _otherSeries, _library); + + AddLink(context, _series, 0); + AddLink(context, _collectionMovie, 1); + + context.SaveChanges(); + } + + private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds) + { + foreach (var ancestorId in ancestorIds) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = ancestorId, + Item = null!, + ParentItem = null! + }); + } + } + + private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _collection, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs new file mode 100644 index 0000000000..91148501ce --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs @@ -0,0 +1,174 @@ +using System; +using Emby.Server.Implementations.Data; +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; + +public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture +{ + private readonly BaseItemRepository _repository; + private readonly string _audioTypeName; + private readonly string _movieTypeName; + + public BaseItemRepositoryItemValueTests() + { + var itemTypeLookup = new ItemTypeLookup(); + _audioTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + _repository = CreateBaseItemRepository(itemTypeLookup); + } + + [Fact] + public void GetQueryFiltersLegacy_GroupsAndFiltersItemValues() + { + var firstItem = CreateMovieEntity(Guid.NewGuid(), "First"); + var secondItem = CreateMovieEntity(Guid.NewGuid(), "Second"); + var excludedItem = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Excluded Audio", + MediaType = "Audio", + IsMovie = false, + IsFolder = false, + IsVirtualItem = false + }; + var firstTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Alpha", + CleanValue = "alpha" + }; + var duplicateTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "alpha", + CleanValue = "alpha" + }; + var secondTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Beta", + CleanValue = "beta" + }; + var genre = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = "Genre Leak", + CleanValue = "genre leak" + }; + var excludedTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Excluded Tag", + CleanValue = "excluded tag" + }; + var excludedGenre = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = "Excluded Genre", + CleanValue = "excluded genre" + }; + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(firstItem, secondItem, excludedItem); + context.ItemValues.AddRange(firstTag, duplicateTag, secondTag, genre, excludedTag, excludedGenre); + context.ItemValuesMap.AddRange( + CreateMap(firstItem, firstTag), + CreateMap(firstItem, duplicateTag), + CreateMap(secondItem, secondTag), + CreateMap(firstItem, genre), + CreateMap(excludedItem, excludedTag), + CreateMap(excludedItem, excludedGenre)); + context.SaveChanges(); + } + + var result = _repository.GetQueryFiltersLegacy(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal(["Alpha", "Beta"], result.Tags); + Assert.Equal(["Genre Leak"], result.Genres); + } + + [Fact] + public void GetGenreNames_GroupsAndFiltersMappedItemValues() + { + var movie = CreateMovieEntity(Guid.NewGuid(), "Movie"); + var audio = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Audio", + MediaType = "Audio", + IsFolder = false, + IsVirtualItem = false + }; + var movieGenre = CreateItemValue(ItemValueType.Genre, "Movie Genre", "movie genre"); + var duplicateMovieGenre = CreateItemValue(ItemValueType.Genre, "movie genre", "movie genre"); + var musicGenre = CreateItemValue(ItemValueType.Genre, "Music Genre", "music genre"); + var orphanedGenre = CreateItemValue(ItemValueType.Genre, "Orphaned Genre", "orphaned genre"); + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(movie, audio); + context.ItemValues.AddRange(movieGenre, duplicateMovieGenre, musicGenre, orphanedGenre); + context.ItemValuesMap.AddRange( + CreateMap(movie, movieGenre), + CreateMap(movie, duplicateMovieGenre), + CreateMap(audio, musicGenre)); + context.SaveChanges(); + } + + Assert.Equal(["Movie Genre"], _repository.GetGenreNames()); + Assert.Equal(["Music Genre"], _repository.GetMusicGenreNames()); + } + + private BaseItemEntity CreateMovieEntity(Guid id, string name) + { + return new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } + + private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue) + { + return new ItemValueMap + { + ItemId = item.Id, + ItemValueId = itemValue.ItemValueId, + Item = item, + ItemValue = itemValue + }; + } + + private static ItemValue CreateItemValue(ItemValueType type, string value, string cleanValue) + { + return new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = value, + CleanValue = cleanValue + }; + } +} 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/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index 947cf54d85..ff683dc57a 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; @@ -12,6 +15,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -25,6 +29,8 @@ public sealed class ItemCountServiceTests : IDisposable private readonly DbContextOptions<JellyfinDbContext> _dbOptions; private readonly IApplicationPaths _applicationPaths; private readonly ItemCountService _service; + private int _contextsCreated; + private List<string>? _capturedSql; public ItemCountServiceTests() { @@ -35,6 +41,7 @@ public sealed class ItemCountServiceTests : IDisposable _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() .UseSqlite(_connection) + .LogTo(CaptureStatement, LogLevel.Information) .Options; using (var context = CreateDbContext()) @@ -43,7 +50,11 @@ public sealed class ItemCountServiceTests : IDisposable } var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContext()).Returns(() => + { + _contextsCreated++; + return CreateDbContext(); + }); var queryHelpers = new Mock<IItemQueryHelpers>(); queryHelpers @@ -53,9 +64,25 @@ public sealed class ItemCountServiceTests : IDisposable It.IsAny<InternalItemsQuery>())) .Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query); + var typeLookup = new Mock<IItemTypeLookup>(); + typeLookup.Setup(l => l.BaseItemKindNames).Returns(new Dictionary<BaseItemKind, string> + { + [BaseItemKind.Movie] = "Movie", + [BaseItemKind.Series] = "Series", + [BaseItemKind.Episode] = "Episode", + [BaseItemKind.MusicAlbum] = "MusicAlbum", + [BaseItemKind.MusicArtist] = "MusicArtist", + [BaseItemKind.MusicVideo] = "MusicVideo", + [BaseItemKind.Audio] = "Audio", + [BaseItemKind.Trailer] = "Trailer", + [BaseItemKind.BoxSet] = "BoxSet", + [BaseItemKind.Book] = "Book", + [BaseItemKind.LiveTvProgram] = "LiveTvProgram" + }); + _service = new ItemCountService( factory.Object, - new Mock<IItemTypeLookup>().Object, + typeLookup.Object, queryHelpers.Object); } @@ -64,6 +91,14 @@ public sealed class ItemCountServiceTests : IDisposable _connection.Dispose(); } + private void CaptureStatement(string message) + { + if (_capturedSql is not null && message.Contains("SELECT", StringComparison.Ordinal)) + { + _capturedSql.Add(message[message.IndexOf("SELECT", StringComparison.Ordinal)..]); + } + } + [Fact] public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit() { @@ -198,6 +233,78 @@ public sealed class ItemCountServiceTests : IDisposable Assert.Equal(2, result[seriesB]); } + [Fact] + public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason() + { + var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false); + + var result = _service.GetChildCountBatch([seriesId, seasonId], null); + + Assert.Equal(2, result[seasonId]); + + // The series holds the season, not the episodes: counting those here would double them up. + Assert.Equal(1, result[seriesId]); + } + + [Fact] + public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce() + { + var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false); + + var result = _service.GetChildCountBatch([seriesId, seasonId], null); + + Assert.Equal(2, result[seasonId]); + Assert.Equal(1, result[seriesId]); + } + + [Fact] + public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem() + { + var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true); + var user = new User("count-test", "provider", "reset"); + + user.DisplayMissingEpisodes = true; + Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]); + + // Nothing this user can open, so nothing to report. + user.DisplayMissingEpisodes = false; + Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]); + } + + [Fact] + public void GetChildCountBatch_NoUser_CountsMissingEpisodes() + { + var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true); + + Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]); + } + + private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes) + { + var seriesId = Guid.NewGuid(); + var seasonId = Guid.NewGuid(); + + using var context = CreateDbContext(); + context.BaseItems.Add(CreateItem(seriesId)); + context.BaseItems.Add(CreateItem(seasonId, seriesId)); + + // Flat: the episodes sit in the series folder, so ParentId points at the series and only + // SeasonId ties them to the season they belong to. + for (var i = 0; i < 2; i++) + { + var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId); + episode.Type = "MediaBrowser.Controller.Entities.TV.Episode"; + episode.IsFolder = false; + episode.IsVirtualItem = virtualEpisodes; + episode.SeasonId = seasonId; + context.BaseItems.Add(episode); + } + + context.SaveChanges(); + + return (seriesId, seasonId); + } + private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId) { var user = new User("count-test", "provider", "reset"); @@ -263,6 +370,695 @@ public sealed class ItemCountServiceTests : IDisposable }; } + [Fact] + public void GetItemCountsForNameItems_MatchesCountingEachNameItemOnItsOwn() + { + // Three genres tagging a different number of movies each, plus one tagging nothing. + var genres = SeedGenres(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie, BaseItemKind.Series]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); + + // Every requested id is answered, so a caller can index the result without checking. + Assert.Equal(genres.Count, batch.Count); + + foreach (var genreId in genres) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + + // And the counts are the seeded ones rather than all zero, which would match trivially. + Assert.Equal([3, 2, 1, 0], genres.Select(g => batch[g].MovieCount).ToArray()); + } + + [Fact] + public void GetItemCountsForNameItems_UnknownId_CountsZero() + { + var unknown = Guid.NewGuid(); + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.Genre, + [unknown], + [BaseItemKind.Movie], + new InternalItemsQuery()); + + Assert.Equal(0, batch[unknown].ItemCount); + } + + [Fact] + public void GetItemCountsForNameItems_ArtistTaggedTwiceOnOneAlbum_CountsTheAlbumOnce() + { + // An album whose artist is also its album artist maps to the same artist twice. + var artistId = SeedArtistWithAlbum(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.MusicAlbum]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.MusicArtist, [artistId], related, filter); + var single = _service.GetItemCountsForNameItem(BaseItemKind.MusicArtist, artistId, related, filter); + + Assert.Equal(1, batch[artistId].AlbumCount); + Assert.Equal(single.AlbumCount, batch[artistId].AlbumCount); + Assert.Equal(single.ItemCount, batch[artistId].ItemCount); + } + + /// <summary> + /// Seeds one artist and a single album tagged with it as both artist and album artist. + /// </summary> + /// <returns>The id of the seeded artist.</returns> + private Guid SeedArtistWithAlbum() + { + const string Name = "artist-0"; + var artistId = Guid.NewGuid(); + var albumId = Guid.NewGuid(); + + using var context = CreateDbContext(); + + var artist = CreateItem(artistId); + artist.Type = "MusicArtist"; + artist.Name = Name; + artist.CleanName = Name; + context.BaseItems.Add(artist); + + var album = CreateItem(albumId); + album.Type = "MusicAlbum"; + context.BaseItems.Add(album); + context.SaveChanges(); + + foreach (var type in new[] { ItemValueType.Artist, ItemValueType.AlbumArtist }) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = Name, + CleanValue = Name + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = albumId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + + context.SaveChanges(); + + return artistId; + } + + [Fact] + public void GetItemCountsForNameItems_LargeIdSet_DoesNotExceedSqliteVariableLimit() + { + // Seeded rather than random, so the clean names of every one of them reach the second + // query's IN list and the join behind it, instead of stopping at the empty-name return. + var seeded = SeedArtists(50, out var taggedArtistId); + + var ids = seeded.Concat(Enumerable.Range(0, 40_000).Select(_ => Guid.NewGuid())).ToList(); + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, + ids, + [BaseItemKind.MusicAlbum], + new InternalItemsQuery()); + + Assert.Equal(ids.Count, batch.Count); + + // And the grouped query really ran, rather than every id coming back zeroed. + Assert.Equal(1, batch[taggedArtistId].AlbumCount); + } + + [Fact] + public void GetItemCountsForNameItems_QueryShape_DoesNotVaryWithBatchSize() + { + // Every id list has to be bound as one parameter rather than one placeholder each: that is + // what keeps the statement off the SQLite variable ceiling and out of a per-size entry in + // EF's compiled query cache. Identical SQL for two batch sizes is exactly that property. + var seeded = SeedArtists(6, out _); + + var small = CaptureSql(() => _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, seeded.Take(2).ToList(), [BaseItemKind.MusicAlbum], new InternalItemsQuery())); + + var large = CaptureSql(() => _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, seeded, [BaseItemKind.MusicAlbum], new InternalItemsQuery())); + + Assert.NotEmpty(small); + Assert.Equal(small, large); + } + + private List<string> CaptureSql(Action action) + { + _capturedSql = []; + try + { + action(); + return _capturedSql; + } + finally + { + _capturedSql = null; + } + } + + /// <summary> + /// Seeds the requested number of artists, each with a clean name of its own, one of which is + /// credited on a single album. + /// </summary> + /// <param name="count">The number of artists to seed.</param> + /// <param name="taggedArtistId">The id of the artist credited on an album.</param> + /// <returns>The ids of the seeded artists.</returns> + private List<Guid> SeedArtists(int count, out Guid taggedArtistId) + { + var ids = new List<Guid>(count); + using var context = CreateDbContext(); + + ItemValue? taggedValue = null; + taggedArtistId = Guid.Empty; + + for (var i = 0; i < count; i++) + { + var name = "bulk-artist-" + i.ToString(CultureInfo.InvariantCulture); + var artistId = Guid.NewGuid(); + ids.Add(artistId); + + var artist = CreateItem(artistId); + artist.Type = "MusicArtist"; + artist.Name = name; + artist.CleanName = name; + context.BaseItems.Add(artist); + + if (i == 0) + { + taggedArtistId = artistId; + taggedValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Artist, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(taggedValue); + } + } + + context.SaveChanges(); + + var albumId = Guid.NewGuid(); + var album = CreateItem(albumId); + album.Type = "MusicAlbum"; + context.BaseItems.Add(album); + context.SaveChanges(); + + Tag(context, albumId, taggedValue!.ItemValueId); + context.SaveChanges(); + + return ids; + } + + [Fact] + public void GetItemCountsForNameItems_KindWithoutItemValues_FallsBackToTheSingleItemPath() + { + // Year is keyed by ProductionYear rather than a cleaned item value, so it cannot be grouped. + var yearId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + var year = CreateItem(yearId); + year.Type = "Year"; + year.Name = "2001"; + year.CleanName = "2001"; + context.BaseItems.Add(year); + + for (var i = 0; i < 2; i++) + { + var movie = CreateItem(Guid.NewGuid()); + movie.Type = "Movie"; + movie.IsFolder = false; + movie.ProductionYear = 2001; + context.BaseItems.Add(movie); + } + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Year, [yearId], related, filter); + var single = _service.GetItemCountsForNameItem(BaseItemKind.Year, yearId, related, filter); + + Assert.Equal(2, batch[yearId].MovieCount); + Assert.Equal(single.MovieCount, batch[yearId].MovieCount); + } + + [Fact] + public void GetItemCountsForNameItems_PeopleAndYears_AreBatchedToo() + { + var (personIds, yearIds) = SeedPeopleAndYears(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + foreach (var (kind, ids) in new[] { (BaseItemKind.Person, personIds), (BaseItemKind.Year, yearIds) }) + { + var contextsBefore = _contextsCreated; + var batch = _service.GetItemCountsForNameItems(kind, ids, related, filter); + + // These two used to be answered one query per id; only the value keyed kinds batched. + Assert.Equal(1, _contextsCreated - contextsBefore); + + Assert.Equal(ids.Count, batch.Count); + Assert.Equal(2, batch[ids[0]].MovieCount); + Assert.Equal(1, batch[ids[1]].MovieCount); + + foreach (var id in ids) + { + var single = _service.GetItemCountsForNameItem(kind, id, related, filter); + Assert.Equal(single.MovieCount, batch[id].MovieCount); + Assert.Equal(single.ItemCount, batch[id].ItemCount); + } + } + } + + /// <summary> + /// Seeds two people and two years, the first of each on two movies and the second on one. + /// </summary> + /// <returns>The ids of the seeded people and years.</returns> + private (List<Guid> PersonIds, List<Guid> YearIds) SeedPeopleAndYears() + { + var personIds = new List<Guid>(); + var yearIds = new List<Guid>(); + + using var context = CreateDbContext(); + + for (var i = 0; i < 2; i++) + { + var personName = "person-" + i.ToString(CultureInfo.InvariantCulture); + var personId = Guid.NewGuid(); + personIds.Add(personId); + + var person = CreateItem(personId); + person.Type = "Person"; + person.Name = personName; + person.CleanName = personName; + context.BaseItems.Add(person); + + var people = new People { Id = Guid.NewGuid(), Name = personName }; + context.Peoples.Add(people); + + var year = 2000 + i; + var yearId = Guid.NewGuid(); + yearIds.Add(yearId); + + var yearItem = CreateItem(yearId); + yearItem.Type = "Year"; + yearItem.Name = year.ToString(CultureInfo.InvariantCulture); + yearItem.CleanName = yearItem.Name; + context.BaseItems.Add(yearItem); + context.SaveChanges(); + + // Two movies for the first of each, one for the second. + for (var m = 0; m < 2 - i; m++) + { + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + movie.ProductionYear = year; + context.BaseItems.Add(movie); + context.SaveChanges(); + + context.PeopleBaseItemMap.Add(new PeopleBaseItemMap + { + ItemId = movieId, + PeopleId = people.Id, + Item = null!, + People = null!, + Role = "Actor", + ListOrder = m, + SortOrder = m + }); + } + + context.SaveChanges(); + } + + return (personIds, yearIds); + } + + [Theory] + // The set the by-name listing actually asks for: it rolls the episodes of a tagged series up + // into the genre, which is the case the batch has to reproduce query for query. + [InlineData(BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie)] + // And the same seeded data without the roll-up, which takes the plain grouped path. + [InlineData(BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicAlbum)] + public void GetItemCountsForNameItems_TaggedSeriesAndEpisodes_MatchesCountingEachNameItemOnItsOwn( + BaseItemKind first, + BaseItemKind second, + BaseItemKind third) + { + var genres = SeedGenresTaggingSeriesAndEpisodes(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [first, second, third]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); + + Assert.Equal(genres.Count, batch.Count); + + foreach (var genreId in genres) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + + Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); + Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + } + + [Fact] + public void GetItemCountsForNameItems_TaggedSeries_RollsEpisodesUpIntoTheGenre() + { + var genres = SeedGenresTaggingSeriesAndEpisodes(); + + var contextsBefore = _contextsCreated; + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.Genre, + genres, + [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie], + new InternalItemsQuery()); + + // The whole point of the batch: one context for every genre on the page, not one each. + // The roll-up used to force this shape back onto the single item path. + Assert.Equal(1, _contextsCreated - contextsBefore); + + // "rolled": one tagged series of two episodes, one of which carries the genre itself, plus + // a loose tagged episode of an untagged series. The tagged episode of the tagged series + // must not be counted twice. + Assert.Equal(3, batch[genres[0]].EpisodeCount); + Assert.Equal(1, batch[genres[0]].SeriesCount); + + // "loose": a tagged episode whose series carries no genre at all. + Assert.Equal(1, batch[genres[1]].EpisodeCount); + Assert.Equal(0, batch[genres[1]].SeriesCount); + + // "empty": tags nothing. + Assert.Equal(0, batch[genres[2]].EpisodeCount); + } + + [Fact] + public void GetItemCountsForNameItems_EpisodeAndItsSeriesTaggedDifferently_KeepsTheGenresApart() + { + var seriesId = Guid.NewGuid(); + var episodeId = Guid.NewGuid(); + var genreIds = new List<Guid>(); + + using (var context = CreateDbContext()) + { + var values = new Dictionary<string, Guid>(StringComparer.Ordinal); + foreach (var name in new[] { "on-series", "on-episode" }) + { + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + values[name] = itemValue.ItemValueId; + } + + var series = CreateItem(seriesId); + series.Type = "Series"; + context.BaseItems.Add(series); + context.BaseItems.Add(CreateEpisode(episodeId, seriesId)); + context.SaveChanges(); + + Tag(context, seriesId, values["on-series"]); + Tag(context, episodeId, values["on-episode"]); + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genreIds, related, filter); + + // The episode rolls up into the genre on its series. + Assert.Equal(1, batch[genreIds[0]].EpisodeCount); + + // Its own genre is carried by no series, so the episode stays a direct count there. Keyed + // on the series id alone the episode would be subtracted here and this would read 0. + Assert.Equal(1, batch[genreIds[1]].EpisodeCount); + Assert.Equal(0, batch[genreIds[1]].SeriesCount); + + foreach (var genreId in genreIds) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + } + + [Fact] + public void GetItemCountsForNameItems_TwoNameItemsSharingACleanName_BothGetTheCounts() + { + // Distinct rows cleaning down to one name are what the batch keys on; the unique index + // permits them, so two genre items can legitimately share a clean name. + var firstId = Guid.NewGuid(); + var secondId = Guid.NewGuid(); + var movieId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + foreach (var (id, name) in new[] { (firstId, "Sci-Fi"), (secondId, "SCI-FI") }) + { + var genre = CreateItem(id); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = "sci-fi"; + context.BaseItems.Add(genre); + } + + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + context.SaveChanges(); + + foreach (var name in new[] { "Sci-Fi", "SCI-FI" }) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = "sci-fi" + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + Tag(context, movieId, itemValue.ItemValueId); + } + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, [firstId, secondId], related, filter); + + // One movie, reached through two value rows: counted once for each genre item, not twice. + Assert.Equal(1, batch[firstId].MovieCount); + Assert.Equal(1, batch[secondId].MovieCount); + + foreach (var genreId in new[] { firstId, secondId }) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + } + } + + /// <summary> + /// Seeds three genres: one tagging a series whose episodes roll up (one of them tagged too) + /// plus a loose episode, one tagging only an episode of an untagged series, and one tagging + /// nothing. + /// </summary> + /// <returns>The ids of the seeded genres, in that order.</returns> + private List<Guid> SeedGenresTaggingSeriesAndEpisodes() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + var values = new Dictionary<string, Guid>(StringComparer.Ordinal); + foreach (var name in new[] { "rolled", "loose", "empty" }) + { + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + values[name] = itemValue.ItemValueId; + } + + context.SaveChanges(); + + // A series tagged "rolled" holding two episodes; the second carries "rolled" itself, so the + // roll-up and the direct tag both see it. + var taggedSeriesId = Guid.NewGuid(); + var taggedSeries = CreateItem(taggedSeriesId); + taggedSeries.Type = "Series"; + context.BaseItems.Add(taggedSeries); + + var episodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); + var taggedEpisodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); + context.BaseItems.AddRange(episodeOfTaggedSeries, taggedEpisodeOfTaggedSeries); + + // An untagged series whose episode carries a genre on its own. + var untaggedSeriesId = Guid.NewGuid(); + var untaggedSeries = CreateItem(untaggedSeriesId); + untaggedSeries.Type = "Series"; + context.BaseItems.Add(untaggedSeries); + + var looseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); + var rolledLooseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); + context.BaseItems.AddRange(looseEpisode, rolledLooseEpisode); + + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + + context.SaveChanges(); + + Tag(context, taggedSeriesId, values["rolled"]); + Tag(context, taggedEpisodeOfTaggedSeries.Id, values["rolled"]); + Tag(context, rolledLooseEpisode.Id, values["rolled"]); + Tag(context, looseEpisode.Id, values["loose"]); + Tag(context, movieId, values["rolled"]); + + context.SaveChanges(); + + return genreIds; + } + + private static void Tag(JellyfinDbContext context, Guid itemId, Guid itemValueId) + { + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = itemId, + ItemValueId = itemValueId, + Item = null!, + ItemValue = null! + }); + } + + private static BaseItemEntity CreateEpisode(Guid id, Guid seriesId) + { + return new BaseItemEntity + { + Id = id, + Type = "Episode", + IsFolder = false, + IsVirtualItem = false, + ParentId = seriesId, + SeriesId = seriesId + }; + } + + /// <summary> + /// Seeds four genres tagging three, two, one and no movies, in that order. + /// </summary> + /// <returns>The ids of the seeded genres.</returns> + private List<Guid> SeedGenres() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + for (var i = 0; i < 4; i++) + { + var name = "genre-" + i.ToString(CultureInfo.InvariantCulture); + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + + // 3 movies for the first genre, 2 for the second, 1 for the third, none for the last. + for (var m = 0; m < 3 - i; m++) + { + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + context.SaveChanges(); + + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = movieId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + + context.SaveChanges(); + } + + return genreIds; + } + private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null) { return new BaseItemEntity diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs new file mode 100644 index 0000000000..7997c6d771 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture +{ + private readonly ItemPersistenceService _service; + + public ItemPersistenceServiceSaveImagesTests() + { + _service = new ItemPersistenceService( + CreateDbContextFactory(), + Mock.Of<IServerApplicationHost>(), + NullLogger<ItemPersistenceService>.Instance); + } + + [Fact] + public async Task SaveImagesAsync_ReplacesThePreviousImages() + { + var itemId = Guid.NewGuid(); + Seed(itemId); + + await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken); + await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken); + + using var context = CreateDbContext(); + var paths = context.BaseItemImageInfos + .Where(e => e.ItemId.Equals(itemId)) + .Select(e => e.Path) + .ToList(); + + Assert.Equal(["/second.jpg"], paths); + } + + [Fact] + public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp() + { + // A scan can delete the item between the refresh reading it and the images being written. That + // must not fail the whole refresh, and must not leave the images of an item that is gone. + var itemId = Guid.NewGuid(); + + await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken); + + using var context = CreateDbContext(); + Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId))); + } + + private static BaseItem CreateItem(Guid itemId, string imagePath) + => new Folder + { + Id = itemId, + ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }] + }; + + private void Seed(Guid itemId) + { + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = itemId, + Type = "Folder", + IsFolder = true + }); + context.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs index 87efa8fea5..cfc9c9496c 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Locking; @@ -58,6 +59,8 @@ public abstract class SqliteDbTestFixture : IDisposable { var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); return factory.Object; } 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 + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs new file mode 100644 index 0000000000..30f7bed208 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.Library.Validators; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +/// <summary> +/// Tests for how the people validator decides which credits need a person item and which person items +/// nothing credits any more. Keying either half on the item's name rather than its id put the two halves +/// in a loop that created, refreshed and deleted the same people on every run, so these pin the id. +/// </summary> +public class PeopleValidatorPartitionTests +{ + // Stands in for the real item-by-name id: derived from the credit name, case-insensitively, and + // from nothing else. The property that matters is that it does not depend on the item's own name. + private static Guid PersonId(string creditName) + { +#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms + var hash = System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.Unicode.GetBytes(creditName.ToLowerInvariant())); +#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms + return new Guid(hash); + } + + [Fact] + public void PartitionCreditsByPersonId_ProviderRenamedThePerson_KeepsThemAndCreatesNothing() + { + // The credit still says "AURORA"; the item it made has been renamed to "Aurora" by the provider + // that refreshed it. Nothing about the library changed, so nothing should be created or deleted. + var credits = new[] { "AURORA" }; + var existing = new HashSet<Guid> { PersonId("AURORA") }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + + Assert.Empty(newNames); + Assert.Empty(deadIds); + } + + [Theory] + // Every shape of rename seen in the wild on a real library. + [InlineData("AURORA")] + [InlineData("Amir AboulEla")] + [InlineData("Miguel Ángel Fuentes")] + [InlineData("a‐ha")] + [InlineData("윤현민")] + public void PartitionCreditsByPersonId_CreditWithAnItem_IsNeverBothCreatedAndDeleted(string creditName) + { + var existing = new HashSet<Guid> { PersonId(creditName) }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId([creditName], PersonId, existing); + + Assert.Empty(newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_CreditWithNoItem_IsCreated() + { + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId( + ["Wanted Person"], + PersonId, + new HashSet<Guid>()); + + Assert.Equal(["Wanted Person"], newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_ItemNoCreditNames_IsDead() + { + var orphan = PersonId("Nobody Credits Me"); + var existing = new HashSet<Guid> { PersonId("Credited"), orphan }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(["Credited"], PersonId, existing); + + Assert.Empty(newNames); + Assert.Equal([orphan], deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_CreditsNormalizingOntoOneId_CreateOneItem() + { + // "AURORA" and "Aurora" are one person as far as the item-by-name id is concerned, so exactly + // one of them should create the item and neither should end up dead. + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId( + ["AURORA", "Aurora", "aurora"], + PersonId, + new HashSet<Guid>()); + + Assert.Single(newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_SecondRunAfterCreating_AsksForNothingFurther() + { + // The churn showed up as a run that never settled, so drive two rounds: whatever round one + // created must leave round two with nothing to do. + string[] credits = ["AURORA", "Amir AboulEla", "Miguel Ángel Fuentes"]; + var existing = new HashSet<Guid>(); + + var (firstNames, firstDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + Assert.Equal(3, firstNames.Count); + Assert.Empty(firstDead); + + foreach (var created in firstNames) + { + existing.Add(PersonId(created)); + } + + var (secondNames, secondDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + + Assert.Empty(secondNames); + Assert.Empty(secondDead); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs index feb2d8a625..67d924d152 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs @@ -62,6 +62,36 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.Equal(expectedId, actualId); } + [Theory] + [InlineData("/media/Show/Season 01 [anidbid=11111]", "AniDB", "11111")] + [InlineData("/media/Show/Season 01 [anidbid-11111]", "AniDB", "11111")] + [InlineData("/media/Show/Season 02 [anilistid=22222]", "AniList", "22222")] + [InlineData("/media/Show/Season 02 (anilistid=22222)", "AniList", "22222")] + [InlineData("/media/Show/Season 03 [anisearchid=33333]", "AniSearch", "33333")] + public void Resolve_SeasonFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId) + { + var series = new Series { Path = "/media/Show" }; + + var args = new MediaBrowser.Controller.Library.ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + LibraryOptions = new LibraryOptions(), + FileInfo = new FileSystemMetadata + { + FullName = path, + IsDirectory = true + } + }; + + var season = _resolver.Resolve(args); + + Assert.NotNull(season); + Assert.True(season.TryGetProviderId(providerKey, out var actualId)); + Assert.Equal(expectedId, actualId); + } + [Fact] public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll() { @@ -140,6 +170,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _)); Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _)); Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _)); + Assert.False(season.TryGetProviderId("AniDB", out _)); + Assert.False(season.TryGetProviderId("AniList", out _)); + Assert.False(season.TryGetProviderId("AniSearch", out _)); } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index 265b6a7f43..ee41b968e1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -240,6 +240,28 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins } [Fact] + public async Task PopulateManifest_ExistingImage_IsNotDownloaded() + { + const string ImageContent = "not really a png"; + + var packageInfo = GenerateTestPackage(); + packageInfo.ImageUrl = "https://example.org/some-plugin.png"; + + var imagePath = Path.Combine(_pluginPath, "some-plugin.png"); + await File.WriteAllTextAsync(imagePath, ImageContent, TestContext.Current.CancellationToken); + + // The application host is null, so attempting to download the image would throw. + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, null!, new Version(1, 0)); + + Assert.True(await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active)); + + var result = pluginManager.LoadManifest(_pluginPath).Manifest; + + Assert.Equal(imagePath, result.ImagePath); + Assert.Equal(ImageContent, await File.ReadAllTextAsync(imagePath, TestContext.Current.CancellationToken)); + } + + [Fact] public async Task PopulateManifest_ExistingMetafileMismatchedIds_Status_Malfunctioned() { var packageInfo = GenerateTestPackage(); diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs index a5a67046d1..f803c69af2 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs @@ -1,6 +1,9 @@ using System; +using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; @@ -8,7 +11,9 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -108,4 +113,136 @@ public class SessionManagerTests return data; } + + [Fact] + public async Task SendMessageCommand_Should_ThrowSecurityException_WhenControllingAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand( + attackerSession.Id, + victimSession.Id, + new MessageCommand { Header = "Custom Message", Text = "test exploit!" }, + CancellationToken.None)); + } + + [Fact] + public async Task SendMessageCommand_Should_Succeed_WhenAllowedToControlOtherUsers() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("controller", "default", "default"); + attacker.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var controllingSession = await LogSessionActivity(sessionManager, attacker); + + await sessionManager.SendMessageCommand( + controllingSession.Id, + victimSession.Id, + new MessageCommand { Header = "Custom Message", Text = "hello" }, + CancellationToken.None); + } + + [Fact] + public async Task LogSessionActivity_Should_NotReuseAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + // Client name and device id are attacker controlled, so they must not identify a session on their own. + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.NotEqual(victimSession.Id, attackerSession.Id); + Assert.Equal(victim.Id, victimSession.UserId); + } + + [Fact] + public async Task AddAdditionalUser_Should_ThrowSecurityException_WhenAttachingAnotherUser() + { + var attacker = new User("attacker", "default", "default"); + var victim = new User("victim", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id)); + } + + [Fact] + public async Task AddAdditionalUser_Should_Succeed_WhenCallerIsAdministrator() + { + var admin = new User("admin", "default", "default"); + admin.SetPermission(PermissionKind.IsAdministrator, true); + var guest = new User("guest", "default", "default"); + await using var sessionManager = CreateSessionManager(admin, guest); + + var adminSession = await LogSessionActivity(sessionManager, admin); + + sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id); + + Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id)); + } + + [Fact] + public async Task RemoveAdditionalUser_Should_ThrowSecurityException_WhenModifyingAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id)); + } + + [Fact] + public async Task ReportCapabilities_Should_ThrowSecurityException_WhenReportingForAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities())); + } + + private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users) + { + var userManager = new Mock<IUserManager>(); + foreach (var user in users) + { + userManager.Setup(i => i.GetUserById(user.Id)).Returns(user); + } + + return new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + Mock.Of<IEventManager>(), + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILibraryManager>(), + userManager.Object, + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + } + + // All sessions are logged with the same client and device id on purpose, those values are taken + // from the request headers and are not bound to the access token of the calling user. + private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user) + => sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs index 32685556b2..05e8a40de1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs @@ -143,6 +143,36 @@ public class PlayQueueManagerTests } [Fact] + public void SetShuffleMode_SortedWhileAlreadySorted_KeepsPlayingItem() + { + var queue = CreateQueue(3); + queue.SetPlayingItemByIndex(1); + var expectedItemId = queue.GetPlayingItemId(); + + queue.SetShuffleMode(GroupShuffleMode.Sorted); + + Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode); + Assert.Equal(1, queue.PlayingItemIndex); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] + public void SetShuffleMode_SortedTwiceAfterShuffle_KeepsPlayingItem() + { + var queue = CreateQueue(5); + queue.SetPlayingItemByIndex(2); + var expectedItemId = queue.GetPlayingItemId(); + + queue.SetShuffleMode(GroupShuffleMode.Shuffle); + queue.SetShuffleMode(GroupShuffleMode.Sorted); + queue.SetShuffleMode(GroupShuffleMode.Sorted); + + Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode); + Assert.Equal(5, queue.GetPlaylist().Count); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] public void SetPlayingItemByIndex_InBounds_SetsPlayingItem() { var queue = CreateQueue(2); diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs new file mode 100644 index 0000000000..b1221f6f71 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.Requests; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class SyncPlayManagerTests +{ + [Fact] + public void LeaveGroup_AfterJoiningTheSameGroupTwice_ClearsTheActiveSessionCounter() + { + var harness = new ManagerHarness(); + + var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + Assert.True(harness.Manager.IsUserActive(harness.User.Id)); + + // A client that re-sends Join for the group it is already in must not be counted twice. + harness.Manager.JoinGroup(harness.Session, new JoinGroupRequest(info.GroupId), CancellationToken.None); + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + [Fact] + public void LeaveGroup_AfterASingleJoin_ClearsTheActiveSessionCounter() + { + var harness = new ManagerHarness(); + + harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + [Fact] + public void IsUserActive_WithTwoSessionsOfTheSameUser_TracksBothSeparately() + { + var harness = new ManagerHarness(); + var second = harness.CreateSession("session-2"); + + var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None); + + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + Assert.True(harness.Manager.IsUserActive(harness.User.Id)); + + harness.Manager.LeaveGroup(second, new LeaveGroupRequest(), CancellationToken.None); + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + private sealed class ManagerHarness + { + private readonly Mock<ISessionManager> _sessionManager = new(); + + public ManagerHarness() + { + var userManager = new Mock<IUserManager>(); + var libraryManager = new Mock<ILibraryManager>(); + + User = new User("tester", "auth-provider", "pwdreset-provider"); + userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User); + + Manager = new SyncPlayManager( + NullLoggerFactory.Instance, + userManager.Object, + _sessionManager.Object, + libraryManager.Object); + + Session = CreateSession("session-1"); + } + + public SyncPlayManager Manager { get; } + + public User User { get; } + + public SessionInfo Session { get; } + + public SessionInfo CreateSession(string id) + { + return new SessionInfo(_sessionManager.Object, NullLogger.Instance) + { + Id = id, + UserId = User.Id, + UserName = User.Username + }; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs new file mode 100644 index 0000000000..0cccd5d4ca --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class WaitingGroupStateTests +{ + [Fact] + public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds() + { + var harness = new GroupHarness(); + var group = harness.Group; + + // Both members report a ping well under the default, so the floor is what decides the delay. + group.UpdatePing(harness.First, 10); + group.UpdatePing(harness.Second, 10); + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetBuffering(harness.First, true); + group.SetBuffering(harness.Second, false); + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + + var before = DateTime.UtcNow; + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + // DefaultPing is expressed in milliseconds, so the floor must be converted before being + // compared against a tick count. Without the conversion the floor is 500 ticks (0.05 ms) + // and never applies. + var scheduledDelay = group.LastActivity - before; + Assert.True( + scheduledDelay >= TimeSpan.FromMilliseconds(group.DefaultPing), + $"expected a resume delay of at least {group.DefaultPing} ms, got {scheduledDelay.TotalMilliseconds} ms"); + } + + [Theory] + [InlineData(4_000_000_000L)] + [InlineData(1_000_000_000_000_000L)] + [InlineData(long.MaxValue)] + [InlineData(-1L)] + public void UpdatePing_ClientReportsAnUnusablePing_IsClampedAndCannotStallTheGroup(long reportedPing) + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.UpdatePing(harness.First, reportedPing); + + Assert.InRange(group.GetHighestPing(), 0, group.MaxPing); + + // The reported ping is scaled into the group's resume point, so an unclamped value either + // pushes playback months out or overflows the arithmetic outright. + var state = new PlayingGroupState(NullLoggerFactory.Instance); + var before = DateTime.UtcNow; + state.HandleRequest( + new UnpauseGroupRequest(), + group, + GroupStateType.Paused, + harness.First, + CancellationToken.None); + + Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1)); + } + + private sealed class GroupHarness + { + public GroupHarness() + { + var userManager = new Mock<IUserManager>(); + var sessionManager = new Mock<ISessionManager>(); + var libraryManager = new Mock<ILibraryManager>(); + + var user = new User("tester", "auth-provider", "pwdreset-provider"); + userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(user); + + var item = new Mock<BaseItem>(); + item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true); + item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks; + libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object); + + sessionManager + .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + sessionManager + .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + Group = new SyncPlayGroup( + NullLoggerFactory.Instance, + userManager.Object, + sessionManager.Object, + libraryManager.Object); + + First = new SessionInfo(sessionManager.Object, NullLogger.Instance) + { + Id = "first", + UserId = user.Id, + UserName = "first" + }; + Second = new SessionInfo(sessionManager.Object, NullLogger.Instance) + { + Id = "second", + UserId = user.Id, + UserName = "second" + }; + + Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None); + Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None); + Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0); + PlaylistItemId = Group.PlayQueue.GetPlayingItemPlaylistId(); + } + + public SyncPlayGroup Group { get; } + + public SessionInfo First { get; } + + public SessionInfo Second { get; } + + public Guid PlaylistItemId { get; } + } +} |
