diff options
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Library')
11 files changed, 1541 insertions, 18 deletions
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/Library/LibraryManager/ResolveAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs new file mode 100644 index 0000000000..31109b2968 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager; + +public sealed class ResolveAlternateVersionTests : IDisposable +{ + private const string PrimaryPath = "/movies/Up/Up.mkv"; + private const string AlternatePath = "/movies/Up/Up - 1080p.mkv"; + + private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; + private readonly Mock<IItemPersistenceService> _persistenceServiceMock; + private readonly Folder _staleParent; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IMediaSourceManager? _previousMediaSourceManager; + private readonly IItemRepository? _previousItemRepository; + + public ResolveAlternateVersionTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + fixture.Freeze<Mock<IServerConfigurationManager>>() + .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + _persistenceServiceMock = fixture.Freeze<Mock<IItemPersistenceService>>(); + var itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>(); + fixture.Freeze<Mock<IFileSystem>>() + .Setup(f => f.GetFileInfo(It.IsAny<string>())) + .Returns<string>(path => new FileSystemMetadata { FullName = path }); + + _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>() + .Do(s => s.AddParts( + fixture.Create<IEnumerable<IResolverIgnoreRule>>(), + [], + fixture.Create<IEnumerable<IIntroProvider>>(), + fixture.Create<IEnumerable<IBaseItemComparer>>(), + fixture.Create<IEnumerable<ILibraryPostScanTask>>())) + .Create(); + + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousMediaSourceManager = BaseItem.MediaSourceManager; + _previousItemRepository = BaseItem.ItemRepository; + BaseItem.LibraryManager = _libraryManager; + + var mediaSourceManagerMock = new Mock<IMediaSourceManager>(); + mediaSourceManagerMock.Setup(m => m.GetMediaStreams(It.IsAny<Guid>())).Returns([]); + mediaSourceManagerMock.Setup(m => m.GetMediaAttachments(It.IsAny<Guid>())).Returns([]); + BaseItem.MediaSourceManager = mediaSourceManagerMock.Object; + + // A reloaded listing comes back empty, so a stale entry surviving is visible. + itemRepositoryMock.Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>())).Returns([]); + BaseItem.ItemRepository = itemRepositoryMock.Object; + + BaseItem.FileSystem ??= fixture.Create<IFileSystem>(); + BaseItem.MediaSegmentManager ??= fixture.Create<IMediaSegmentManager>(); + BaseItem.ConfigurationManager ??= fixture.Create<IServerConfigurationManager>(); + Video.RecordingsManager ??= fixture.Create<IRecordingsManager>(); + + var primary = new Movie + { + Name = "Up", + Path = PrimaryPath, + LocalAlternateVersions = [AlternatePath], + Id = _libraryManager.GetNewItemId(PrimaryPath, typeof(Movie)) + }; + + _staleParent = new Folder + { + Name = "Up", + Path = "/movies/Up", + Id = _libraryManager.GetNewItemId("/movies/Up", typeof(Folder)) + }; + + var staleAlternate = new Video + { + Name = "Up - 1080p", + Path = AlternatePath, + OwnerId = primary.Id, + ParentId = _staleParent.Id, + Id = _libraryManager.GetNewItemId(AlternatePath, typeof(Video)) + }; + staleAlternate.SetPrimaryVersionId(primary.Id); + + itemRepositoryMock + .Setup(i => i.RetrieveItem(It.IsAny<Guid>())) + .Returns<Guid>(id => id.Equals(primary.Id) ? primary + : id.Equals(staleAlternate.Id) ? staleAlternate + : id.Equals(_staleParent.Id) ? _staleParent + : null!); + + StaleAlternateId = staleAlternate.Id; + } + + private Guid StaleAlternateId { get; } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.MediaSourceManager = _previousMediaSourceManager!; + BaseItem.ItemRepository = _previousItemRepository!; + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsRowWithoutResavingPrimary() + { + // The alternate is stored under the id of the generic Video type while its primary is a Movie. + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + _persistenceServiceMock.Verify( + p => p.DeleteItem(It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 1 && ids[0].Equals(StaleAlternateId))), + Times.Once); + + // Saving the primary is what re-enters this method before the stale row is gone. + _persistenceServiceMock.Verify( + p => p.SaveItems(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsCachedParentListing() + { + _staleParent.Children = [new Video { Name = "Up - 1080p", Path = AlternatePath }]; + + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + Assert.Empty(_staleParent.Children); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs new file mode 100644 index 0000000000..6d0c491382 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.ScheduledTasks.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Tasks; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class LibraryManagerScanTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StartScanInBackground_QueuesOnlyWhenIdle(bool scanRunning) + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + var tasks = fixture.Freeze<Mock<ITaskManager>>(); + var manager = fixture.Create<ServerLibraryManager>(); + typeof(ServerLibraryManager).GetProperty(nameof(ServerLibraryManager.IsScanRunning))!.SetValue(manager, scanRunning); + + await manager.StartScanInBackground().ConfigureAwait(true); + + tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), scanRunning ? Times.Never() : Times.Once()); + tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Never()); + } + + [Fact] + public async Task ValidateMediaLibrary_RestartsScheduledScan() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + var tasks = fixture.Freeze<Mock<ITaskManager>>(); + var manager = fixture.Create<ServerLibraryManager>(); + + await manager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(true); + + tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Once()); + tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), Times.Never()); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs new file mode 100644 index 0000000000..65ec41291d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.Library; +using Emby.Server.Implementations.Sorting; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; +using BaseItem = MediaBrowser.Controller.Entities.BaseItem; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class LibraryManagerSortTests +{ + [Fact] + public void Sort_UserDependentKey_NullUser_ThrowsArgumentException() + { + var libraryManager = CreateLibraryManager( + new IBaseItemComparer[] { new PlayCountComparer(), new SortNameComparer() }); + + BaseItem[] items = + { + new Audio { Name = "Zulu", SortName = "Zulu", Id = Guid.NewGuid() }, + new Audio { Name = "Alpha", SortName = "Alpha", Id = Guid.NewGuid() }, + }; + + Assert.Throws<ArgumentException>(() => libraryManager.Sort( + items, + user: null, + new[] { (ItemSortBy.PlayCount, SortOrder.Descending) }).ToArray()); + } + + [Fact] + public void Sort_DateLastContentAdded_NullUser_OrdersByDateNotSortName() + { + var libraryManager = CreateLibraryManager( + new IBaseItemComparer[] { new DateLastMediaAddedComparer(), new SortNameComparer() }); + + BaseItem[] items = + { + MakeFolder("Alpha", new DateTime(2026, 1, 1)), + MakeFolder("Mike", new DateTime(2025, 1, 1)), + MakeFolder("Zulu", new DateTime(2024, 1, 1)) + }; + + var sorted = libraryManager.Sort( + items, + user: null, + new[] { (ItemSortBy.DateLastContentAdded, SortOrder.Descending) }).ToArray(); + + Assert.Equal(new[] { "Alpha", "Mike", "Zulu" }, sorted.Select(i => i.Name)); + } + + private static Folder MakeFolder(string name, DateTime dateLastMediaAdded) + => new() { Name = name, Id = Guid.NewGuid(), DateLastMediaAdded = dateLastMediaAdded }; + + private static Emby.Server.Implementations.Library.LibraryManager CreateLibraryManager(IReadOnlyCollection<IBaseItemComparer> comparers) + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>(); + configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + BaseItem.ConfigurationManager ??= configMock.Object; + var itemRepository = fixture.Freeze<Mock<IItemRepository>>(); + itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null); + var fileSystemMock = fixture.Freeze<Mock<IFileSystem>>(); + fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path }); + + return fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts( + fixture.Create<IEnumerable<IResolverIgnoreRule>>(), + fixture.Create<IEnumerable<IItemResolver>>(), + fixture.Create<IEnumerable<IIntroProvider>>(), + comparers, + fixture.Create<IEnumerable<ILibraryPostScanTask>>())) + .Create(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index facdb2bc2e..131cb23fa4 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -1,12 +1,18 @@ using System; +using System.Collections.Generic; +using System.Linq; using AutoFixture; using AutoFixture.AutoMoq; using Castle.Components.DictionaryAdapter; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -144,5 +150,180 @@ namespace Jellyfin.Server.Implementations.Tests.Library _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex); } + + [Theory] + // A remembered full track must not survive a switch to "only forced" (it falls through to + // the forced track here); a remembered forced track and "off" still must. + [InlineData(SubtitlePlaybackMode.OnlyForced, 2, 3)] + [InlineData(SubtitlePlaybackMode.OnlyForced, 3, 3)] + [InlineData(SubtitlePlaybackMode.OnlyForced, -1, -1)] + [InlineData(SubtitlePlaybackMode.Default, 2, 2)] + [InlineData(SubtitlePlaybackMode.Always, 2, 2)] + [InlineData(SubtitlePlaybackMode.Smart, 2, 2)] + [InlineData(SubtitlePlaybackMode.None, 2, null)] + public void SetDefaultSubtitleStreamIndex_RememberedSelection_RespectsSubtitleMode( + SubtitlePlaybackMode mode, + int rememberedIndex, + int? expectedIndex) + { + _mockUserDataManager + .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = rememberedIndex }); + + var mediaInfo = new MediaSourceInfo + { + MediaStreams = new MediaStream[] + { + new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true }, + new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true }, + new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false }, + new() { Index = 3, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = false, IsForced = true } + } + }; + + _user.SubtitleMode = mode; + _user.SubtitleLanguagePreference = string.Empty; + _user.RememberSubtitleSelections = true; + _user.AudioLanguagePreference = string.Empty; + + _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); + + Assert.Equal(expectedIndex, mediaInfo.DefaultSubtitleStreamIndex); + } + + [Fact] + public void SetDefaultSubtitleStreamIndex_OnlyForcedRemembersFullTrackWithNoForcedStream_SelectsNothing() + { + _mockUserDataManager + .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = 2 }); + + var mediaInfo = new MediaSourceInfo + { + MediaStreams = new MediaStream[] + { + new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true }, + new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true }, + new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false } + } + }; + + _user.SubtitleMode = SubtitlePlaybackMode.OnlyForced; + _user.SubtitleLanguagePreference = string.Empty; + _user.RememberSubtitleSelections = true; + _user.AudioLanguagePreference = string.Empty; + + _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); + + Assert.Null(mediaInfo.DefaultSubtitleStreamIndex); + } + + [Fact] + public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + SetupUserDataBatch(new Dictionary<Guid, UserItemData> + { + [alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, + [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } + }); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user); + + // The most recently played version is the default source, so resuming plays the right file. + // Per-user positions live in each version's UserData, not on the source. + Assert.Equal(alt2.Id.ToString("N"), sources[0].Id); + } + + [Fact] + public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + SetupUserDataBatch(new Dictionary<Guid, UserItemData> + { + [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } + }); + + var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user); + + // An explicitly opened version keeps its own source first, even when a sibling was + // played more recently. + Assert.Equal(alt1.Id.ToString("N"), sources[0].Id); + Assert.Equal(3, sources.Count); + } + + [Fact] + public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst() + { + var (primary, _, _) = SetupVersionGroup(); + SetupUserDataBatch([]); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user); + + Assert.Equal(primary.Id.ToString("N"), sources[0].Id); + } + + [Fact] + public void GetStaticMediaSources_ItemWithoutMediaSources_ThrowsArgumentException() + { + // A container queued by mistake is a bad request, not a server fault. + Assert.Throws<ArgumentException>( + () => _mediaSourceManager.GetStaticMediaSources(new MusicArtist { Id = Guid.NewGuid() }, false, _user)); + } + + [Fact] + public void GetStaticMediaSources_NoUser_DoesNotTouchUserData() + { + var (primary, _, _) = SetupVersionGroup(); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false); + + Assert.Equal(primary.Id.ToString("N"), sources[0].Id); + _mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()), Times.Never); + } + + private void SetupUserDataBatch(Dictionary<Guid, UserItemData> userData) + { + _mockUserDataManager + .Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>())) + .Returns((IReadOnlyList<BaseItem> items, User _) => items + .Where(i => userData.ContainsKey(i.Id)) + .ToDictionary(i => i.Id, i => userData[i.Id])); + } + + private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup() + { + var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" }; + var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id }; + var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id }; + + // BaseItem.GetMediaSources runs against the static service locators. + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())).Returns(new List<MediaStream>()); + mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>()); + + var segmentManager = new Mock<IMediaSegmentManager>(); + segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id }); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary); + libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1); + libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2); + + var recordingsManager = new Mock<IRecordingsManager>(); + recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null); + + BaseItem.MediaSegmentManager = segmentManager.Object; + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + Video.RecordingsManager = recordingsManager.Object; + + return (primary, alt1, alt2); + } } } 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..421671b520 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs @@ -0,0 +1,247 @@ +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; +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 static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private readonly MovieSimilarItemsProvider _provider; + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly User _user = new("test", "auth-provider", "reset-provider"); + private readonly string _movieTypeName; + private readonly string _folderTypeName; + + 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(); + + // A second scenario, in two libraries and on a genre of its own, for the group whose primary the + // user may not be able to reach at all. + private readonly Guid _crossSource = Guid.NewGuid(); + private readonly Guid _crossLibraryPrimary = Guid.NewGuid(); + private readonly Guid _crossLibraryVersion = Guid.NewGuid(); + private readonly Guid _sameLibraryPrimary = Guid.NewGuid(); + private readonly Guid _sameLibraryVersion = Guid.NewGuid(); + + public MovieSimilarItemsProviderTests() + { + var itemTypeLookup = new ItemTypeLookup(); + _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!; + _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!; + + 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, + _libraryManager.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); + } + + [Fact] + public async Task GetSimilarItems_UserWithoutThePrimarysLibrary_OffersTheVersion() + { + // The user may only open the library the 1080p version is in, so its primary is out of reach + // and the version is all that is left to stand in for the group. + RestrictUserTo(_movieLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_crossLibraryVersion, items); + Assert.DoesNotContain(_crossLibraryPrimary, items); + } + + [Fact] + public async Task GetSimilarItems_UserWithBothLibraries_OffersThePrimaryOfTheGroupOnce() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_crossLibraryPrimary, items); + Assert.DoesNotContain(_crossLibraryVersion, items); + } + + [Fact] + public async Task GetSimilarItems_GroupMergedInsideOneLibrary_StillOffersOnlyThePrimary() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_sameLibraryPrimary, items); + Assert.DoesNotContain(_sameLibraryVersion, items); + } + + private void RestrictUserTo(params Guid[] libraryIds) + { + _libraryManager + .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>())) + .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds); + } + + private async Task<List<Guid>> GetSimilarItemsAsync(Guid? sourceId = null) + { + var results = await _provider.GetSimilarItemsAsync( + new Movie { Id = sourceId ?? _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); + + // The second scenario scores on a genre of its own, so it stays out of the results above. + var crossLibrary = CreateItemValue("Science Fiction", "science fiction"); + + AddLibrary(context, _movieLibraryId, "Movies"); + AddLibrary(context, _movie4KLibraryId, "Movies-4K"); + + var crossSource = AddMovie(context, _crossSource, "Cross Source", primaryVersionId: null, libraryId: _movieLibraryId); + + // The 4K version heads the group and lives in a library of its own. + var crossLibraryPrimary = AddMovie(context, _crossLibraryPrimary, "Coco 4K", primaryVersionId: null, libraryId: _movie4KLibraryId); + var crossLibraryVersion = AddMovie(context, _crossLibraryVersion, "Coco", primaryVersionId: _crossLibraryPrimary, libraryId: _movieLibraryId); + + // A group merged inside one library, as a control. + var sameLibraryPrimary = AddMovie(context, _sameLibraryPrimary, "Up 4K", primaryVersionId: null, libraryId: _movieLibraryId); + var sameLibraryVersion = AddMovie(context, _sameLibraryVersion, "Up", primaryVersionId: _sameLibraryPrimary, libraryId: _movieLibraryId); + + context.Users.Add(_user); + context.ItemValues.AddRange(shared, other, crossLibrary); + context.ItemValuesMap.AddRange( + CreateMap(source, shared), + CreateMap(sourceAlternate, shared), + CreateMap(similar, shared), + CreateMap(similarAlternate, shared), + CreateMap(unrelated, other), + CreateMap(crossSource, crossLibrary), + CreateMap(crossLibraryPrimary, crossLibrary), + CreateMap(crossLibraryVersion, crossLibrary), + CreateMap(sameLibraryPrimary, crossLibrary), + CreateMap(sameLibraryVersion, crossLibrary)); + + context.SaveChanges(); + } + + private void AddLibrary(JellyfinDbContext context, Guid id, string name) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = _folderTypeName, + Name = name, + Path = "/" + name, + IsFolder = true + }); + } + + private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId, Guid? libraryId = null) + { + var item = new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + SortName = name, + ParentId = libraryId, + TopParentId = libraryId, + 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/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index 650d67b195..e65bc1d31f 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library { [Theory] [InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")] [InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son", "imdbid", null)] [InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")] + [InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")] [InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")] [InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")] [InlineData("Superman: Red Son [providera id=4]", "providera id", "4")] [InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")] + [InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")] [InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")] - [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")] [InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")] + [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")] + [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")] + [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")] + [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")] [InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")] + [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")] [InlineData("[tmdbid=618355]", "tmdbid", "618355")] + [InlineData("[tmdb=618355]", "tmdbid", "618355")] [InlineData("{tmdbid=618355}", "tmdbid", "618355")] + [InlineData("{tmdb=618355}", "tmdbid", "618355")] [InlineData("(tmdbid=618355)", "tmdbid", "618355")] + [InlineData("(tmdb=618355)", "tmdbid", "618355")] [InlineData("[tmdbid-618355]", "tmdbid", "618355")] + [InlineData("[tmdb-618355]", "tmdbid", "618355")] [InlineData("{tmdbid-618355)", "tmdbid", null)] + [InlineData("{tmdb-618355)", "tmdbid", null)] [InlineData("[tmdbid-618355}", "tmdbid", null)] + [InlineData("[tmdb-618355}", "tmdbid", null)] [InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")] + [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")] [InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")] + [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")] [InlineData("tmdbid=618355]", "tmdbid", null)] + [InlineData("tmdb=618355]", "tmdbid", null)] [InlineData("[tmdbid=618355", "tmdbid", null)] + [InlineData("[tmdb=618355", "tmdbid", null)] [InlineData("tmdbid=618355", "tmdbid", null)] + [InlineData("tmdb=618355", "tmdbid", null)] [InlineData("tmdbid=", "tmdbid", null)] + [InlineData("tmdb=", "tmdbid", null)] [InlineData("tmdbid", "tmdbid", null)] + [InlineData("tmdb", "tmdbid", null)] + [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")] + [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")] + [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")] + [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")] [InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)] + [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)] [InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)] + [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)] [InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")] [InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)] + [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)] [InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)] + [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)] [InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")] + [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")] + [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")] public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult) { Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute)); 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 133a3f7d47..67d924d152 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs @@ -21,7 +21,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library { var localizationMock = new Mock<ILocalizationManager>(); localizationMock - .Setup(l => l.GetLocalizedString(It.IsAny<string>())) + .Setup(l => l.GetServerLocalizedString(It.IsAny<string>())) .Returns("Season {0}"); _resolver = new SeasonResolver( @@ -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/Library/SqlSearchProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs new file mode 100644 index 0000000000..5aa770b9b3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs @@ -0,0 +1,126 @@ +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.Search; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.Implementations.Tests.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +/// <summary> +/// Covers what <see cref="SqlSearchProvider"/> returns for a version group merged across two +/// libraries: the primary represents the group wherever it is visible, and the version stands in +/// for it for a user who cannot open the library the primary lives in. +/// </summary> +public sealed class SqlSearchProviderTests : SqliteDbTestFixture +{ + private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static readonly Guid _primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + private static readonly Guid _versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + private readonly SqlSearchProvider _provider; + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + public SqlSearchProviderTests() + { + var itemTypeLookup = new ItemTypeLookup(); + var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!; + var folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!; + + using (var context = CreateDbContext()) + { + context.Users.Add(_user); + context.BaseItems.Add(CreateLibrary(_movieLibraryId, folderTypeName, "Movies", "/movies")); + context.BaseItems.Add(CreateLibrary(_movie4KLibraryId, folderTypeName, "Movies-4K", "/movies-4k")); + context.BaseItems.Add(CreateMovie(_primaryId, movieTypeName, _movie4KLibraryId, null)); + context.BaseItems.Add(CreateMovie(_versionId, movieTypeName, _movieLibraryId, _primaryId)); + context.SaveChanges(); + } + + var userManager = new Mock<IUserManager>(); + userManager.Setup(u => u.GetUserById(_user.Id)).Returns(_user); + + _provider = new SqlSearchProvider( + CreateDbContextFactory(), + itemTypeLookup, + _libraryManager.Object, + userManager.Object, + CreateBaseItemRepository(itemTypeLookup)); + } + + [Fact] + public async Task SearchAsync_UserWithoutThePrimarysLibrary_FindsTheVersion() + { + RestrictUserTo(_movieLibraryId); + + var hits = await SearchAsync().ConfigureAwait(true); + + Assert.Equal([_versionId], hits); + } + + [Fact] + public async Task SearchAsync_UserWithBothLibraries_FindsThePrimaryOnce() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var hits = await SearchAsync().ConfigureAwait(true); + + Assert.Equal([_primaryId], hits); + } + + private void RestrictUserTo(params Guid[] libraryIds) + { + _libraryManager + .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>())) + .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds); + } + + private async Task<List<Guid>> SearchAsync() + { + var results = await _provider.SearchAsync( + new SearchProviderQuery { SearchTerm = "coco", UserId = _user.Id, Limit = 10 }, + CancellationToken.None).ConfigureAwait(false); + + return results.Select(r => r.ItemId).ToList(); + } + + private static BaseItemEntity CreateLibrary(Guid id, string typeName, string name, string path) + => new() + { + Id = id, + Type = typeName, + Name = name, + Path = path, + IsFolder = true + }; + + private static BaseItemEntity CreateMovie(Guid id, string typeName, Guid libraryId, Guid? primaryVersionId) + => new() + { + Id = id, + Type = typeName, + Name = "Coco", + CleanName = "coco", + SortName = "Coco", + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false, + ParentId = libraryId, + TopParentId = libraryId, + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"), + PrimaryVersionId = primaryVersionId + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs new file mode 100644 index 0000000000..ba3127bc08 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.Library; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +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 AudioBook = MediaBrowser.Controller.Entities.AudioBook; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public sealed class UserDataManagerTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserDataManager _userDataManager; + private readonly User _user; + + public UserDataManagerTests() + { + _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); + + var config = new Mock<IServerConfigurationManager>(); + config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration()); + + _userDataManager = new UserDataManager(config.Object, factory.Object); + _user = new User("user", "auth-provider", "reset-provider") + { + Id = Guid.NewGuid() + }; + } + + public void Dispose() + { + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + private AudioBook CreateAudioBook() + { + // GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"] + return new AudioBook + { + Id = Guid.NewGuid(), + Name = "Book Title", + Album = "Series", + AlbumArtists = new[] { "Author" }, + IndexNumber = 1 + }; + } + + private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks) + { + return new UserData + { + ItemId = item.Id, + Item = null, + UserId = _user.Id, + User = null, + CustomDataKey = key, + PlaybackPositionTicks = positionTicks + }; + } + + [Fact] + public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow() + { + var item = CreateAudioBook(); + var currentKey = item.GetUserDataKeys()[0]; + + // the retired-key row comes first to ensure selection is by key, not row order + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), + CreateUserDataRow(item, currentKey, 222) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(currentKey, userData.Key); + Assert.Equal(222, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow() + { + var item = CreateAudioBook(); + var idKey = item.GetUserDataKeys()[1]; + + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), + CreateUserDataRow(item, idKey, 333) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(idKey, userData.Key); + Assert.Equal(333, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow() + { + var item = CreateAudioBook(); + + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(111, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey() + { + var item = CreateAudioBook(); + item.UserData = new List<UserData>(); + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(item.GetUserDataKeys()[0], userData.Key); + Assert.Equal(0, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_RowsForOtherUsers_AreIgnored() + { + var item = CreateAudioBook(); + var currentKey = item.GetUserDataKeys()[0]; + + var otherUserRow = CreateUserDataRow(item, currentKey, 999); + otherUserRow.UserId = Guid.NewGuid(); + + item.UserData = new List<UserData> + { + otherUserRow, + CreateUserDataRow(item, currentKey, 222) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(222, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder() + { + // no preloaded navigation data, so the batch takes the database fallback + var fossilItem = CreateAudioBook(); + var retiredItem = CreateAudioBook(); + + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(_user); + ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! }); + ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! }); + + // the stale id-key row is inserted first so selection by row order would return it + ctx.UserData.AddRange( + CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111), + CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222), + CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333)); + ctx.SaveChanges(); + } + + var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user); + + Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks); + Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NullUser_ThrowsArgumentNullException() + { + var item = CreateAudioBook(); + Assert.Throws<ArgumentNullException>(() => _userDataManager.GetUserData(null!, item)); + } +} |
