diff options
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests')
11 files changed, 1569 insertions, 25 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs index 96625ae670..6a3dcab57a 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; @@ -7,12 +8,14 @@ using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; using Moq; using Xunit; @@ -99,9 +102,199 @@ public class DtoServiceImageInheritanceTests Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]); } + [Fact] + public void GetBaseItemDtos_MusicAlbums_ResolveInheritedThumbFromArtistBatch_WithoutPerAlbumLookup() + { + var artist = new MusicArtist + { + Id = Guid.NewGuid(), + Name = "Some Artist", + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Thumb, + Path = "/images/artist-thumb.jpg", + DateModified = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) + } + ] + }; + + static MusicAlbum MakeAlbum() => new MusicAlbum + { + Id = Guid.NewGuid(), + Name = "Album", + AlbumArtists = ["Some Artist"], + ImageInfos = [] + }; + + var libraryManager = new Mock<ILibraryManager>(); + + // DtoService resolves every album-artist name in ONE batch (GetArtists). The album's inherited + // Thumb/Backdrop images must come from that batch, not a per-album GetArtist/GetItemList lookup + // (the N+1). GetArtist is intentionally left unset: a regression to the per-album path would + // resolve no artist and fail the assertions below. + libraryManager + .Setup(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>())) + .Returns(new Dictionary<string, MusicArtist[]>(StringComparer.OrdinalIgnoreCase) + { + ["Some Artist"] = [artist] + }); + + var dtoService = BuildDtoService(libraryManager); + + var dtos = dtoService.GetBaseItemDtos([MakeAlbum(), MakeAlbum()], new DtoOptions(false)); + + Assert.Equal(2, dtos.Count); + foreach (var dto in dtos) + { + Assert.Equal(artist.Id, dto.ParentThumbItemId); + Assert.Equal("/images/artist-thumb.jpg", dto.ParentThumbImageTag); + } + + // The artist lookup is batched once for the whole set, never once per album. + libraryManager.Verify(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>()), Times.Once); + libraryManager.Verify(x => x.GetArtist(It.IsAny<string>(), It.IsAny<DtoOptions>()), Times.Never); + } + + [Fact] + public void GetBaseItemDtos_Items_ResolvePeopleFromBatch_WithoutPerItemLookup() + { + static MusicAlbum MakeAlbum() => new MusicAlbum + { + Id = Guid.NewGuid(), + Name = "Album", + ImageInfos = [] + }; + + var albumOne = MakeAlbum(); + var albumTwo = MakeAlbum(); + + var libraryManager = new Mock<ILibraryManager>(); + + // DtoService resolves people for every item in ONE batch (GetPeopleByItems) before the + // per-item loop. A regression to the per-item path would call GetPeople(BaseItem) once per + // item (the N+1); it is intentionally left unset so such a regression fails here. + libraryManager + .Setup(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>())) + .Returns(new Dictionary<Guid, IReadOnlyList<PersonInfo>> + { + [albumOne.Id] = [new PersonInfo { ItemId = albumOne.Id, Name = "Some Actor", Type = PersonKind.Actor }], + [albumTwo.Id] = [new PersonInfo { ItemId = albumTwo.Id, Name = "Some Actor", Type = PersonKind.Actor }] + }); + + // AttachPeople still resolves each distinct name to its Person entity to attach images. + libraryManager + .Setup(x => x.GetPerson("Some Actor")) + .Returns(new Person { Id = Guid.NewGuid(), Name = "Some Actor" }); + + var dtoService = BuildDtoService(libraryManager); + + var options = new DtoOptions(false) { Fields = [ItemFields.People] }; + var dtos = dtoService.GetBaseItemDtos([albumOne, albumTwo], options); + + Assert.Equal(2, dtos.Count); + foreach (var dto in dtos) + { + Assert.NotNull(dto.People); + Assert.Single(dto.People); + Assert.Equal("Some Actor", dto.People[0].Name); + } + + // People are batched once for the whole set, never once per item. + libraryManager.Verify(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>()), Times.Once); + libraryManager.Verify(x => x.GetPeople(It.IsAny<BaseItem>()), Times.Never); + } + + [Fact] + public void GetBaseItemDtos_Videos_ResolveMediaSourceCountFromBatch_WithoutPerItemLookup() + { + static Movie MakeMovie() => new Movie + { + Id = Guid.NewGuid(), + Name = "Movie", + ImageInfos = [] + }; + + var movieOne = MakeMovie(); + var movieTwo = MakeMovie(); + + var libraryManager = new Mock<ILibraryManager>(); + + // DtoService detects which videos own alternate versions in ONE batch + // (GetItemIdsWithAlternateVersions) before the per-item loop. Videos absent from that set have a + // single media source, so the per-item GetLinkedAlternateVersions/GetLocalAlternateVersionIds + // queries (the N+1) must be skipped entirely. Here neither movie has alternate versions. + libraryManager + .Setup(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>())) + .Returns(new HashSet<Guid>()); + + var dtoService = BuildDtoService(libraryManager); + + var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] }; + var dtos = dtoService.GetBaseItemDtos([movieOne, movieTwo], options); + + Assert.Equal(2, dtos.Count); + + // A single media source is the default, so the count is left unset (the client treats null as one). + foreach (var dto in dtos) + { + Assert.Null(dto.MediaSourceCount); + } + + // The alternate-version check is batched once for the whole set, and the per-item lookups are + // never reached because the batch already ruled out alternate versions. + libraryManager.Verify(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once); + libraryManager.Verify(x => x.GetLinkedAlternateVersions(It.IsAny<Video>()), Times.Never); + libraryManager.Verify(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()), Times.Never); + } + + [Fact] + public void GetBaseItemDtos_VideoInAlternateVersionBatch_ResolvesRealCount() + { + var movie = new Movie + { + Id = Guid.NewGuid(), + Name = "Movie", + ImageInfos = [] + }; + + var libraryManager = new Mock<ILibraryManager>(); + + // This movie IS in the batch set, so the fast path must not short-circuit it: the per-item + // lookups still run and the count is computed exactly as it was before batching. Two linked + // alternate versions plus the movie itself is a count of three. + libraryManager + .Setup(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>())) + .Returns(new HashSet<Guid> { movie.Id }); + libraryManager + .Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())) + .Returns([new Movie { Id = Guid.NewGuid() }, new Movie { Id = Guid.NewGuid() }]); + libraryManager + .Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())) + .Returns([]); + + var dtoService = BuildDtoService(libraryManager); + + var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] }; + var dtos = dtoService.GetBaseItemDtos([movie], options); + + Assert.Single(dtos); + Assert.Equal(3, dtos[0].MediaSourceCount); + libraryManager.Verify(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once); + } + private static DtoService BuildDtoService(BaseItem displayParent) { var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(x => x.GetItemById(displayParent.Id)) + .Returns(displayParent); + return BuildDtoService(libraryManager); + } + + private static DtoService BuildDtoService(Mock<ILibraryManager> libraryManager) + { var userDataManager = new Mock<IUserDataManager>(); var imageProcessor = new Mock<IImageProcessor>(); var providerManager = new Mock<IProviderManager>(); @@ -113,14 +306,14 @@ public class DtoServiceImageInheritanceTests var chapterManager = new Mock<IChapterManager>(); var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>(); - libraryManager - .Setup(x => x.GetItemById(displayParent.Id)) - .Returns(displayParent); - imageProcessor .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>())) .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path); + // Video.IsActiveRecording() dereferences this static during DTO building. + Video.RecordingsManager = recordingsManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + return new DtoService( logger.Object, libraryManager.Object, diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs new file mode 100644 index 0000000000..f675621e21 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs @@ -0,0 +1,199 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// The by-name endpoints (artists, album artists, genres, studios) all funnel through +/// <c>GetItemValues</c>. A query without a <c>Limit</c> used to have its total record count +/// silently disabled, so callers got a populated <c>Items</c> array next to a zero total. +/// </summary> +public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryByNameTotalCountTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + _itemTypeLookup = new ItemTypeLookup(); + + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + _repository = new BaseItemRepository( + factory.Object, + new Mock<IServerApplicationHost>().Object, + _itemTypeLookup, + serverConfigurationManager.Object, + NullLogger<BaseItemRepository>.Instance); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public void GetArtists_WithoutLimit_ReportsTotalRecordCount() + { + SeedArtists(3); + + var result = _repository.GetArtists(CreateQuery(limit: null)); + + Assert.Equal(3, result.Items.Count); + Assert.Equal(3, result.TotalRecordCount); + } + + [Fact] + public void GetArtists_WithLimit_ReportsTotalBeyondThePage() + { + SeedArtists(3); + + var result = _repository.GetArtists(CreateQuery(limit: 2)); + + Assert.Equal(2, result.Items.Count); + Assert.Equal(3, result.TotalRecordCount); + } + + [Fact] + public void GetArtists_TotalRecordCountDisabled_StaysZero() + { + SeedArtists(3); + + var query = CreateQuery(limit: null); + query.EnableTotalRecordCount = false; + + var result = _repository.GetArtists(query); + + Assert.Equal(3, result.Items.Count); + Assert.Equal(0, result.TotalRecordCount); + } + + [Fact] + public void GetArtists_WithoutLimit_DoesNotMutateCallerQuery() + { + SeedArtists(1); + + var query = CreateQuery(limit: null); + Assert.True(query.EnableTotalRecordCount); + + _repository.GetArtists(query); + + // The repository used to flip this flag on the caller's own query object, so a + // reused query silently lost its total on every subsequent call. + Assert.True(query.EnableTotalRecordCount); + } + + private static InternalItemsQuery CreateQuery(int? limit) + { + return new InternalItemsQuery(new User("test", "auth", "reset")) + { + Limit = limit + }; + } + + /// <summary> + /// Creates <paramref name="count"/> artists, each credited on one song, which is what + /// makes them visible to the item-value join behind the by-name endpoints. + /// </summary> + private void SeedArtists(int count) + { + using var ctx = CreateDbContext(); + + for (var i = 0; i < count; i++) + { + var name = $"Artist {i}"; + var cleanName = name.ToLowerInvariant(); + + var artistId = Guid.Parse($"aaaaaaaa-0000-0000-0000-{i:D12}"); + var songId = Guid.Parse($"55555555-0000-0000-0000-{i:D12}"); + var valueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}"); + + var artist = new BaseItemEntity + { + Id = artistId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], + Name = name, + CleanName = cleanName, + PresentationUniqueKey = artistId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }; + + var song = new BaseItemEntity + { + Id = songId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio], + Name = $"Song {i}", + CleanName = $"song {i}", + PresentationUniqueKey = songId.ToString("N"), + MediaType = "Audio", + IsFolder = false, + IsVirtualItem = false + }; + + var itemValue = new ItemValue + { + ItemValueId = valueId, + Type = ItemValueType.Artist, + Value = name, + CleanValue = cleanName + }; + + ctx.BaseItems.Add(artist); + ctx.BaseItems.Add(song); + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = songId, + ItemValueId = valueId, + Item = song, + ItemValue = itemValue + }); + } + + ctx.SaveChanges(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs new file mode 100644 index 0000000000..0766ca8d1e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ItemCountServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly IApplicationPaths _applicationPaths; + private readonly ItemCountService _service; + + public ItemCountServiceTests() + { + _applicationPaths = new Mock<IApplicationPaths>().Object; + + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var context = CreateDbContext()) + { + context.Database.EnsureCreated(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + _service = new ItemCountService( + factory.Object, + new Mock<IItemTypeLookup>().Object, + new Mock<IItemQueryHelpers>().Object); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit() + { + var hierarchicalParentId = Guid.NewGuid(); + var linkedParentId = Guid.NewGuid(); + + var hierarchicalChildId = Guid.NewGuid(); + var linkedChildId1 = Guid.NewGuid(); + var linkedChildId2 = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + CreateItem(hierarchicalParentId), + CreateItem(linkedParentId), + CreateItem(hierarchicalChildId, hierarchicalParentId), + CreateItem(linkedChildId1), + CreateItem(linkedChildId2)); + + context.LinkedChildren.AddRange( + new LinkedChildEntity + { + ParentId = linkedParentId, + ChildId = linkedChildId1, + ChildType = LinkedChildType.Manual, + SortOrder = 0 + }, + new LinkedChildEntity + { + ParentId = linkedParentId, + ChildId = linkedChildId2, + ChildType = LinkedChildType.Manual, + SortOrder = 1 + }); + + context.SaveChanges(); + } + + var parentIds = Enumerable.Range(0, 40_000) + .Select(_ => Guid.NewGuid()) + .ToList(); + + parentIds.Add(hierarchicalParentId); + parentIds.Add(linkedParentId); + + var result = _service.GetChildCountBatch(parentIds, null); + + Assert.Equal(1, result[hierarchicalParentId]); + Assert.Equal(2, result[linkedParentId]); + Assert.Equal(parentIds.Count, result.Count); + } + + private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null) + { + return new BaseItemEntity + { + Id = id, + Type = "Folder", + ParentId = parentId, + IsFolder = true + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider( + _applicationPaths, + NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs new file mode 100644 index 0000000000..6324706452 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ItemPersistenceOwnedRowTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly ItemPersistenceService _service; + private readonly IApplicationPaths _applicationPaths; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IServerConfigurationManager? _previousConfigurationManager; + + public ItemPersistenceOwnedRowTests() + { + _applicationPaths = new Mock<IApplicationPaths>().Object; + + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousConfigurationManager = BaseItem.ConfigurationManager; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>())) + .Returns([]); + BaseItem.LibraryManager = libraryManager.Object; + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + BaseItem.ConfigurationManager = configurationManager.Object; + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + _service = new ItemPersistenceService( + factory.Object, + new Mock<IServerApplicationHost>().Object, + NullLogger<ItemPersistenceService>.Instance); + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.ConfigurationManager = _previousConfigurationManager!; + _connection.Dispose(); + } + + [Fact] + public void SaveItems_UpdateExistingItem_ReplacesOwnedRows() + { + var id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + _service.SaveItems( + [CreateBook(id, new() { ["Imdb"] = "tt0001", ["Tmdb"] = "555" }, [MetadataField.Name])], + CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + Assert.Equal(2, ctx.BaseItemProviders.Count(e => e.ItemId.Equals(id))); + Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id))); + Assert.Equal(1, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id))); + } + + // Re-save with different owned rows: the update path rewrites all three tables wholesale. + _service.SaveItems( + [CreateBook(id, new() { ["Imdb"] = "tt9999" }, [MetadataField.Name, MetadataField.Genres])], + CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + var providers = ctx.BaseItemProviders.Where(e => e.ItemId.Equals(id)).ToList(); + Assert.Equal("tt9999", Assert.Single(providers).ProviderValue); + + Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id))); + Assert.Equal(2, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id))); + } + } + + [Fact] + public void SaveItems_MixedNewAndExistingBatch_ReplacesOnlyExistingOwnedRows() + { + var existing = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + var fresh = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + + _service.SaveItems([CreateBook(existing, new() { ["Imdb"] = "tt0001" }, [])], CancellationToken.None); + + // One already-persisted item and one brand new item in the same batch. + _service.SaveItems( + [ + CreateBook(existing, new() { ["Imdb"] = "tt0002" }, []), + CreateBook(fresh, new() { ["Tmdb"] = "777" }, []) + ], + CancellationToken.None); + + using var ctx = CreateDbContext(); + Assert.Equal("tt0002", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(existing))).ProviderValue); + Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue); + } + + private static Book CreateBook(Guid id, Dictionary<string, string> providerIds, MetadataField[] lockedFields) + { + var book = new Book + { + Id = id, + Name = "Book", + ProviderIds = providerIds, + LockedFields = lockedFields + }; + + book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0); + return book; + } + + private JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs new file mode 100644 index 0000000000..70d8e1f833 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs @@ -0,0 +1,186 @@ +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.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable +{ + private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly PeopleRepository _repository; + + public PeopleRepositoryUpdatePeopleTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + var itemTypeLookup = new ItemTypeLookup(); + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + ctx.BaseItems.Add(new BaseItemEntity + { + Id = _itemId, + Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie], + Name = "Movie", + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }); + ctx.SaveChanges(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + _repository = new PeopleRepository( + factory.Object, + itemTypeLookup, + new Mock<IItemQueryHelpers>().Object); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel"), + CreatePerson("Person A", PersonKind.Writer, "Screenplay") + ]); + + using var ctx = CreateDbContext(); + Assert.Single(ctx.Peoples); + Assert.Equal( + ["Novel", "Screenplay"], + ctx.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditsDifferingOnlyInCase_AreDeduped() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("person a", PersonKind.Actor, "hero") + ]); + + using var ctx = CreateDbContext(); + Assert.Single(ctx.Peoples); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Hero", map.Role); + } + + [Fact] + public void UpdatePeople_SamePersonAsDifferentTypes_CreatesOnePersonPerType() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person A", PersonKind.Director, string.Empty) + ]); + + using var ctx = CreateDbContext(); + Assert.Equal(2, ctx.Peoples.Count()); + Assert.Equal(2, ctx.PeopleBaseItemMap.Count()); + } + + [Fact] + public void UpdatePeople_RepeatedUpdate_ReusesMappingsAndRefreshesOrder() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person B", PersonKind.Actor, "Sidekick") + ]); + + Guid[] peopleIdsBefore; + using (var ctx = CreateDbContext()) + { + peopleIdsBefore = ctx.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray(); + } + + // Reversed order, so the list order of both mappings has to be rewritten. + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person B", PersonKind.Actor, "Sidekick"), + CreatePerson("Person A", PersonKind.Actor, "Hero") + ]); + + using var after = CreateDbContext(); + Assert.Equal(peopleIdsBefore, after.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray()); + Assert.Equal( + ["Sidekick", "Hero"], + after.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditRemoved_DropsOnlyThatMapping() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel"), + CreatePerson("Person A", PersonKind.Writer, "Screenplay") + ]); + + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel") + ]); + + using var ctx = CreateDbContext(); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Novel", map.Role); + } + + [Fact] + public void UpdatePeople_RoleCaseChanged_KeepsExistingMapping() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "HERO")]); + + using var ctx = CreateDbContext(); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Hero", map.Role); + } + + private static PersonInfo CreatePerson(string name, PersonKind type, string role) + { + return new PersonInfo + { + Name = name, + Type = type, + Role = role + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index 562711337f..a28c1d6dfb 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using AutoFixture; using AutoFixture.AutoMoq; using Emby.Naming.Common; @@ -17,6 +18,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using Moq; using Xunit; @@ -38,9 +40,15 @@ public class FindExtrasTests itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null); _fileSystemMock = fixture.Freeze<Mock<IFileSystem>>(); _fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path }); + + var strings = LoadCoreStrings(); + fixture.Freeze<Mock<ILocalizationManager>>() + .Setup(l => l.GetServerLocalizedString(It.IsAny<string>())) + .Returns<string>(key => strings.TryGetValue(key, out var value) ? value : key); + _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts( fixture.Create<IEnumerable<IResolverIgnoreRule>>(), - new List<IItemResolver> { new AudioResolver(fixture.Create<NamingOptions>()) }, + [new AudioResolver(fixture.Create<NamingOptions>())], fixture.Create<IEnumerable<IIntroProvider>>(), fixture.Create<IEnumerable<IBaseItemComparer>>(), fixture.Create<IEnumerable<ILibraryPostScanTask>>())) @@ -51,6 +59,16 @@ public class FindExtrasTests BaseItem.MediaSourceManager ??= fixture.Create<IMediaSourceManager>(); } + private static Dictionary<string, string> LoadCoreStrings() + { + using var stream = typeof(Emby.Server.Implementations.Library.LibraryManager).Assembly + .GetManifestResourceStream("Emby.Server.Implementations.Localization.Core.en-US.json") + ?? throw new InvalidOperationException("Core localization resource is missing"); + + return JsonSerializer.Deserialize<Dictionary<string, string>>(stream) + ?? throw new InvalidOperationException("Core localization resource is empty"); + } + [Fact] public void FindExtras_SeparateMovieFolder_FindsCorrectExtras() { @@ -132,60 +150,60 @@ public class FindExtrasTests It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/some trailer.mkv", Name = "some trailer.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/behind the scenes", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/behind the scenes/the making of Up.mkv", Name = "the making of Up.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/theme-music", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/theme-music/theme2.mp3", Name = "theme2.mp3", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/extras", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/extras/Honest Trailer.mkv", Name = "Honest Trailer.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var files = paths.Select(p => new FileSystemMetadata { @@ -289,15 +307,15 @@ public class FindExtrasTests It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/trailer.jpg", Name = "trailer.jpg", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList(); @@ -306,6 +324,47 @@ public class FindExtrasTests } [Fact] + public void FindExtras_TrailerWithYearInFilename_SetsProductionYearFromFilename() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny<string[]>(), + false, + false)) + .Returns( + [ + new() + { + FullName = "/movies/Up/trailers/Trailer 1 (2013).mkv", + Name = "Trailer 1 (2013).mkv", + IsDirectory = false + } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).ToList(); + + _fileSystemMock.Verify(); + var trailer = Assert.Single(extras); + Assert.Equal(ExtraType.Trailer, trailer.ExtraType); + Assert.Equal(typeof(Trailer), trailer.GetType()); + Assert.Equal(2013, trailer.ProductionYear); + } + + [Fact] public void FindExtras_SeriesWithTrailers_FindsCorrectExtras() { var owner = new Series { Name = "Dexter", Path = "/series/Dexter" }; @@ -331,4 +390,198 @@ public class FindExtrasTests Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path); Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path); } + + [Fact] + public void FindExtras_SameExtraInSeveralContainers_ReturnsEach() + { + var owner = new Movie { Name = "Skyscraper", Path = "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv" }; + var paths = new List<string> + { + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + // A container is a separate file that plays on its own, so it is a separate extra + Assert.Equal(4, extras.Count); + Assert.Equal("Behind The Scenes", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv"]); + Assert.Equal("Behind The Scenes 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"]); + Assert.Equal("Trailer", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv"]); + Assert.Equal("Trailer 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4"]); + } + + [Fact] + public void FindExtras_SameExtraInSeveralResolutions_ReturnsEach() + { + var owner = new Movie { Name = "Dragon 2", Path = "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv" }; + var paths = new List<string> + { + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv", + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv", + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal(2, extras.Count); + Assert.Equal("Trailer", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv"]); + Assert.Equal("Trailer 2", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"]); + } + + [Fact] + public void FindExtras_NumberedExtras_AreKeptApart() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List<string> + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mp4", + "/movies/Up (2009)/Up (2009)-trailer3.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + + Assert.Equal(4, extras.Count); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer.mkv", extras[0].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mkv", extras[1].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mp4", extras[2].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer3.mkv", extras[3].Path); + + // The index in the file name is not the number the extra is given, which counts the + // extras of a type as they are found + Assert.Equal("Trailer", extras[0].Name); + Assert.Equal("Trailer 2", extras[1].Name); + Assert.Equal("Trailer 3", extras[2].Name); + Assert.Equal("Trailer 4", extras[3].Name); + } + + [Fact] + public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List<string> + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv", + "/movies/Up (2009)/Recording the audio-behindthescenes.mkv", + "/movies/Up (2009)/Up (2009)-behindthescenes.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal(3, extras.Count); + Assert.Equal("Trailer", extras["/movies/Up (2009)/Up (2009)-trailer.mkv"]); + + // A descriptive file name is a real title and survives, and does not consume a number + Assert.Equal("Recording the audio", extras["/movies/Up (2009)/Recording the audio-behindthescenes.mkv"]); + Assert.Equal("Behind The Scenes", extras["/movies/Up (2009)/Up (2009)-behindthescenes.mkv"]); + } + + [Fact] + public void FindExtras_ExtraInOwnFolder_IsNamedAfterItsFile() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny<string[]>(), + false, + false)) + .Returns( + [ + new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Comic-Con Reel.mkv", Name = "Comic-Con Reel.mkv", IsDirectory = false } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + _fileSystemMock.Verify(); + Assert.Equal(2, extras.Count); + Assert.Equal("Teaser", extras["/movies/Up/trailers/Teaser.mkv"]); + Assert.Equal("Comic-Con Reel", extras["/movies/Up/trailers/Comic-Con Reel.mkv"]); + } + + [Fact] + public void FindExtras_DistinctExtrasInSameFolder_AreKeptApart() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny<string[]>(), + false, + false)) + .Returns( + [ + new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Official.mkv", Name = "Official.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Official.mp4", Name = "Official.mp4", IsDirectory = false } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + + _fileSystemMock.Verify(); + Assert.Equal(3, extras.Count); + Assert.Equal("/movies/Up/trailers/Official.mkv", extras[0].Path); + Assert.Equal("/movies/Up/trailers/Official.mp4", extras[1].Path); + Assert.Equal("/movies/Up/trailers/Teaser.mkv", extras[2].Path); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 2ed880ed9c..d973076ed3 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -349,7 +349,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization }); var translated = localizationManager.GetLocalizedString("Artists", "de"); - Assert.Equal("Interpreten", translated); + Assert.Equal("Künstler", translated); } [Fact] @@ -406,7 +406,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization { CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr"); var translated = localizationManager.GetServerLocalizedString("Artists"); - Assert.Equal("Interpreten", translated); + Assert.Equal("Künstler", translated); } finally { @@ -427,7 +427,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization { CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); var translated = localizationManager.GetLocalizedString("Artists"); - Assert.Equal("Interpreten", translated); + Assert.Equal("Künstler", translated); } finally { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index ede9e61536..265b6a7f43 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -293,7 +293,84 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Assert.Equal(packageInfo.Versions[0].Version, result.Version); } - private PackageInfo GenerateTestPackage() + [Fact] + public async Task DisablePlugin_CatalogRefresh_StaysDisabled() + { + var pluginRoot = Path.Combine(_tempPath, "plugins"); + var pluginDir = CreateTestPlugin(pluginRoot, "Disable Me", PluginStatus.Active); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0)); + var plugin = Assert.Single(pluginManager.Plugins); + + pluginManager.DisablePlugin(plugin); + + Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(pluginDir).Manifest.Status); + + // The web shows that a restart is required, but the persisted state must not change. + Assert.Equal(PluginStatus.Restart, plugin.GetPluginInfo().Status); + Assert.Equal(PluginStatus.Disabled, plugin.Manifest.Status); + Assert.True(plugin.Manifest.AutoUpdate); + + // Every catalog fetch rewrites the manifests of installed plugins from the in-memory status. + var packageInfo = GenerateTestPackage(plugin.Id); + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), pluginDir, plugin.Manifest.Status); + + Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(pluginDir).Manifest.Status); + } + + [Fact] + public void Constructor_DisabledPluginSortingBeforeEnabledPlugin_IsNotDeleted() + { + var pluginRoot = Path.Combine(_tempPath, "plugins"); + var disabledDir = CreateTestPlugin(pluginRoot, "AAA Disabled", PluginStatus.Disabled); + CreateTestPlugin(pluginRoot, "ZZZ Active", PluginStatus.Active); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0)); + + Assert.True(Directory.Exists(disabledDir)); + Assert.Contains(pluginManager.Plugins, p => string.Equals(p.Name, "AAA Disabled", StringComparison.Ordinal)); + } + + [Fact] + public void LoadAssemblies_DisabledPluginWithSupersededVersion_DoesNotRevertToOldVersion() + { + var pluginRoot = Path.Combine(_tempPath, "plugins"); + var id = Guid.NewGuid(); + var oldDir = CreateTestPlugin(pluginRoot, "Two Versions", PluginStatus.Superseded, new Version(1, 0), id); + var newDir = CreateTestPlugin(pluginRoot, "Two Versions_2.0", PluginStatus.Disabled, new Version(2, 0), id, "Two Versions"); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0)); + + Assert.Empty(pluginManager.LoadAssemblies()); + + // Neither version may be touched: the old one stays superseded instead of being loaded + // as a stand-in for the version the user disabled. + Assert.Equal(PluginStatus.Superseded, pluginManager.LoadManifest(oldDir).Manifest.Status); + Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(newDir).Manifest.Status); + } + + private string CreateTestPlugin(string root, string folderName, PluginStatus status, Version? version = null, Guid? id = null, string? name = null) + { + var dir = Path.Combine(root, folderName); + Directory.CreateDirectory(dir); + FileHelper.CreateEmpty(Path.Combine(dir, "some.dll")); + + var manifest = new PluginManifest + { + Id = id ?? Guid.NewGuid(), + Name = name ?? folderName, + Status = status, + AutoUpdate = true, + TargetAbi = "1.0", + Version = (version ?? new Version(1, 0)).ToString() + }; + + File.WriteAllText(Path.Combine(dir, "meta.json"), JsonSerializer.Serialize(manifest, _options)); + + return dir; + } + + private PackageInfo GenerateTestPackage(Guid? id = null) { var fixture = new Fixture(); fixture.Customize<PackageInfo>(c => c.Without(x => x.Versions).Without(x => x.ImageUrl)); @@ -305,6 +382,10 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins var packageInfo = fixture.Create<PackageInfo>(); packageInfo.Versions = new[] { versionInfo }; + if (id.HasValue) + { + packageInfo.Id = id.Value; + } return packageInfo; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs new file mode 100644 index 0000000000..7722707cbe --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class IdlePlaybackTests +{ + [Theory] + [InlineData(null, null)] + [InlineData(123456789L, 123456789L)] + public async Task CheckForIdlePlayback_StopsAtLastClientReportedPosition(long? clientPositionTicks, long? expectedPositionTicks) + { + var playbackStopped = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously); + var eventManager = new Mock<IEventManager>(); + eventManager + .Setup(manager => manager.PublishAsync(It.IsAny<PlaybackStopEventArgs>())) + .Callback<PlaybackStopEventArgs>(eventArgs => playbackStopped.TrySetResult(eventArgs.PlaybackPositionTicks)) + .Returns(Task.CompletedTask); + await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + eventManager.Object, + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IUserManager>(), + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + var session = await sessionManager.LogSessionActivity( + "Test Client", + "1.0.0", + "test-device", + "Test Device", + "127.0.0.1", + null); + session.NowPlayingItem = new BaseItemDto + { + Id = Guid.NewGuid(), + Name = "Test Item" + }; + session.PlayState.PositionTicks = 987654321; + + if (clientPositionTicks.HasValue) + { + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = clientPositionTicks + }); + session.StopAutomaticProgress(); + } + + var idlePlaybackCallback = typeof(Emby.Server.Implementations.Session.SessionManager) + .GetMethod("CheckForIdlePlayback", BindingFlags.Instance | BindingFlags.NonPublic)!; + idlePlaybackCallback.Invoke(sessionManager, new object?[] { null }); + + var stoppedPositionTicks = await playbackStopped.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(expectedPositionTicks, stoppedPositionTicks); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs new file mode 100644 index 0000000000..c5b8f661b5 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading.Tasks; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class SessionInfoTests +{ + [Fact] + public async Task StartAutomaticProgress_SnapshotsClientReportedPosition() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + var progressInfo = new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 123456789 + }; + + session.StartAutomaticProgress(progressInfo); + + Assert.Equal(progressInfo.PositionTicks, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task AutomaticProgress_AdvancesEstimatedPositionWithoutAdvancingSnapshot() + { + var sessionManager = new Mock<ISessionManager>(); + await using var session = new SessionInfo(sessionManager.Object, NullLogger.Instance); + var automaticProgress = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously); + const long reportedPositionTicks = 123456789; + + sessionManager + .Setup(manager => manager.OnPlaybackProgress(It.IsAny<PlaybackProgressInfo>(), true)) + .Callback<PlaybackProgressInfo, bool>((info, _) => + { + session.PlayState.PositionTicks = info.PositionTicks; + automaticProgress.TrySetResult(info.PositionTicks); + }) + .Returns(Task.CompletedTask); + session.PlayState.PositionTicks = reportedPositionTicks; + + session.StartAutomaticProgress(new PlaybackProgressInfo + { + PositionTicks = reportedPositionTicks + }); + + var estimatedPositionTicks = await automaticProgress.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.StopAutomaticProgress(); + + Assert.Equal(reportedPositionTicks + TimeSpan.TicksPerSecond, estimatedPositionTicks); + Assert.Equal(estimatedPositionTicks, session.PlayState.PositionTicks); + Assert.Equal(reportedPositionTicks, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task StartAutomaticProgress_ReplacesSnapshotOnLaterClientReport() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 123456789 + }); + + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 987654321 + }); + + Assert.Equal(987654321, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task StartAutomaticProgress_PreservesExactPausedPosition() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + var pausedProgress = new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 314159265 + }; + + session.StartAutomaticProgress(pausedProgress); + + Assert.Equal(pausedProgress.PositionTicks, session.LastPlaybackCheckInPositionTicks); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs new file mode 100644 index 0000000000..c940f92109 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Events; +using MediaBrowser.Model.Cryptography; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +public sealed class UserManagerUpdateUserTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserManager _userManager; + + public UserManagerUpdateUserTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock<ICryptoProvider>(); + var configManager = new Mock<IServerConfigurationManager>(); + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock<IApplicationHost>(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger<DefaultAuthenticationProvider>.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock<INetworkManager>().Object, + appHost.Object, + new Mock<IImageProcessor>().Object, + NullLogger<UserManager>.Instance, + configManager.Object, + [defaultPasswordResetProvider], + [defaultAuthProvider, invalidAuthProvider]); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + [Fact] + public async Task UpdateUserAsync_DoesNotDetachPermissionsOrPreferences() + { + var user = await _userManager.CreateUserAsync("orphanuser"); + var permissionCount = user.Permissions.Count; + var preferenceCount = user.Preferences.Count; + + user.LastActivityDate = DateTime.UtcNow; + await _userManager.UpdateUserAsync(user); + await _userManager.UpdateUserAsync(user); + + await using var context = CreateDbContext(); + Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); + Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken)); + Assert.All( + await context.Permissions.ToListAsync(TestContext.Current.CancellationToken), + permission => Assert.Equal(user.Id, permission.UserId)); + Assert.All( + await context.Preferences.ToListAsync(TestContext.Current.CancellationToken), + preference => Assert.Equal(user.Id, preference.UserId)); + } + + [Fact] + public async Task UpdateUserAsync_WhenOnlyTheUserRowChanged_LeavesChildRowsUntouched() + { + var user = await _userManager.CreateUserAsync("churnuser"); + var before = await ReadChildRowsAsync(); + + // A session activity stamp goes through the same path. It must not rewrite all 37 child + // rows, which is what tearing the collections down and rebuilding them used to do. + user.LastActivityDate = DateTime.UtcNow; + await _userManager.UpdateUserAsync(user); + + Assert.Equal(before, await ReadChildRowsAsync()); + } + + [Fact] + public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges() + { + var user = await _userManager.CreateUserAsync("policyuser"); + Assert.False(user.HasPermission(PermissionKind.IsAdministrator)); + + user.SetPermission(PermissionKind.IsAdministrator, true); + user.SetPreference(PreferenceKind.BlockedTags, ["spoilers"]); + user.Permissions.Remove(user.Permissions.First(permission => permission.Kind == PermissionKind.EnableAllChannels)); + + await _userManager.UpdateUserAsync(user); + + var reloaded = _userManager.GetUserById(user.Id)!; + Assert.True(reloaded.HasPermission(PermissionKind.IsAdministrator)); + Assert.Equal(new[] { "spoilers" }, reloaded.GetPreference(PreferenceKind.BlockedTags)); + Assert.DoesNotContain(reloaded.Permissions, permission => permission.Kind == PermissionKind.EnableAllChannels); + + await using var context = CreateDbContext(); + Assert.Equal(reloaded.Permissions.Count, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + /// <summary> + /// Reads the identity and concurrency token of every permission and preference row. + /// </summary> + private async Task<List<(string Table, int Id, int Kind, uint RowVersion)>> ReadChildRowsAsync() + { + await using var context = CreateDbContext(); + var permissions = await context.Permissions + .OrderBy(permission => permission.Id) + .Select(permission => new ValueTuple<string, int, int, uint>("Permission", permission.Id, (int)permission.Kind, permission.RowVersion)) + .ToListAsync(TestContext.Current.CancellationToken); + var preferences = await context.Preferences + .OrderBy(preference => preference.Id) + .Select(preference => new ValueTuple<string, int, int, uint>("Preference", preference.Id, (int)preference.Kind, preference.RowVersion)) + .ToListAsync(TestContext.Current.CancellationToken); + + return permissions.Concat(preferences).ToList(); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish<T>(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync<T>(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } +} |
