diff options
Diffstat (limited to 'tests/Jellyfin.Controller.Tests')
6 files changed, 477 insertions, 0 deletions
diff --git a/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs new file mode 100644 index 0000000000..272c434fe9 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +[Collection("LibraryManagerTests")] +public class AggregateFolderTests +{ + [Fact] + public void Children_ClearedAfterALibraryWasAdded_ListsTheNewLibrary() + { + var existing = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" }; + var added = new Folder { Id = Guid.NewGuid(), Path = "/libraries/collections" }; + + // What the repository holds grows once the new library has been resolved and stored. + var stored = new List<BaseItem> { existing }; + + var itemRepository = new Mock<IItemRepository>(); + itemRepository.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(() => stored.ToList()); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetItemById(It.IsAny<Guid>())) + .Returns((Guid id) => stored.Find(i => i.Id.Equals(id))); + + BaseItem.ItemRepository = itemRepository.Object; + BaseItem.LibraryManager = libraryManager.Object; + + var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" }; + + Assert.Equal([existing.Id], root.Children.Select(i => i.Id)); + + stored.Add(added); + root.Children = null; + + // Null-forgiving: the setter takes null to mean "drop the cache", the getter reloads. + Assert.Equal([existing.Id, added.Id], root.Children!.Select(i => i.Id)); + } + + [Fact] + public void Children_AssignedASet_KeepsThatSet() + { + var itemRepository = new Mock<IItemRepository>(MockBehavior.Strict); + BaseItem.ItemRepository = itemRepository.Object; + + var assigned = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" }; + var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" }; + + root.Children = [assigned]; + + // Never goes to the repository, so the strict mock stays unused. + Assert.Equal([assigned.Id], root.Children.Select(i => i.Id)); + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index 86bac4256a..c67717e2fa 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -26,8 +27,70 @@ using Xunit; namespace Jellyfin.Controller.Tests.Entities; +[Collection("LibraryManagerTests")] public class BaseItemTests { + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task ValidateChildren_FailedEnumeration_DoesNotReconcileOrDeleteChildren(bool failAfterFirstChild, bool accessDenied) + { + var previousLibrary = BaseItem.LibraryManager; + var previousRepository = BaseItem.ItemRepository; + var previousLogger = BaseItem.Logger; + var previousMediaSourceManager = BaseItem.MediaSourceManager; + var library = new Mock<ILibraryManager>(MockBehavior.Strict); + var repository = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>(MockBehavior.Strict); + var directory = new Mock<IDirectoryService>(); + directory.Setup(d => d.IsAccessible(It.IsAny<string>())).Returns(true); + + // IsLibraryFolderAccessible reads FileNameWithoutExtension, which resolves the path protocol + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + try + { + BaseItem.LibraryManager = library.Object; + BaseItem.ItemRepository = repository.Object; + BaseItem.Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<BaseItem>.Instance; + BaseItem.MediaSourceManager = mediaSourceManager.Object; + var folder = new FailingEnumerationFolder(failAfterFirstChild, accessDenied) + { + Id = Guid.NewGuid(), + Path = "/media/review-folder" + }; + await folder.ValidateChildren(new Progress<double>(), new MetadataRefreshOptions(directory.Object), recursive: false, cancellationToken: TestContext.Current.CancellationToken).ConfigureAwait(true); + Assert.True(folder.EnumerationAttempted); + repository.VerifyNoOtherCalls(); + library.VerifyNoOtherCalls(); + } + finally + { + BaseItem.LibraryManager = previousLibrary; + BaseItem.ItemRepository = previousRepository; + BaseItem.Logger = previousLogger; + BaseItem.MediaSourceManager = previousMediaSourceManager; + } + } + + [Fact] + public void SetPrimaryVersionId_Null_RestoresTheItemsOwnPresentationKey() + { + var primaryId = Guid.NewGuid(); + var video = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv" }; + + // While it is a version, it presents as the primary so lists collapse the two together. + video.SetPrimaryVersionId(primaryId); + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey); + + // Promoting it back has to restore its own key, or it keeps collapsing onto - and staying + // hidden behind - a primary it no longer belongs to. + video.SetPrimaryVersionId(null); + Assert.Null(video.PrimaryVersionId); + Assert.Equal(video.Id.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey); + } + [Fact] public void GetItemByNameFolderName_ShortName_IsKeptAsIs() { @@ -185,6 +248,17 @@ public class BaseItemTests "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]", "[Final Cut] [1080p HEVC AAC]", "[EE by ADM] [480p HEVC AAC]")] + // Numeric version labels: the dot between the digits is a decimal point, not a delimiter, so the + // prefix retreats past it to the '-' instead of leaving "0" / "11". + [InlineData( + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11", + "1.0", + "1.11")] + // Numeric labels with no structural delimiter at all fall back to the space boundary. + [InlineData("Movie (2007) 1.0", "Movie (2007) 1.11", "1.0", "1.11")] + // A dot followed by a non-digit is still a delimiter, even after a digit. + [InlineData("Movie - Part 1.HDR", "Movie - Part 1.SDR", "HDR", "SDR")] public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt) { var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv"; @@ -216,6 +290,24 @@ public class BaseItemTests } [Fact] + public void GetCommonVersionPrefix_NumericLabels_KeepsWholeNumber() + { + // Three versions labelled "1.0", "1.01" and "1.11": the common prefix stops inside the version + // number, so it must retreat past the decimal point to the '-' delimiter. + string[] fileNames = + [ + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.01", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11" + ]; + + var prefix = BaseItem.GetCommonVersionPrefix(fileNames); + + Assert.Equal("Evangelion 1.0 You Are (Not) Alone (2007) -", prefix); + Assert.Equal(["1.0", "1.01", "1.11"], fileNames.Select(n => n[prefix.Length..].TrimStart(' '))); + } + + [Fact] public void GetAlternateVersion_ReturnsMatchingLocalVersion() { var (primary, alt1, alt2) = SetupVersionGroup(); @@ -644,4 +736,25 @@ public class BaseItemTests Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids); } + + private sealed class FailingEnumerationFolder(bool failAfterFirstChild, bool accessDenied) : Folder + { + public bool EnumerationAttempted { get; private set; } + + protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService) + { + EnumerationAttempted = true; + if (failAfterFirstChild) + { + yield return new Movie { Id = Guid.NewGuid(), Path = "/media/review-folder/movie.mkv" }; + } + + if (accessDenied) + { + throw new System.Security.SecurityException("Simulated access failure"); + } + + throw new IOException("Simulated directory read failure"); + } + } } diff --git a/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs new file mode 100644 index 0000000000..705238317a --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +/// <summary> +/// Covers <see cref="Folder.ReleaseCachedChildren"/>, which a recursive scan calls as it unwinds so +/// the folders it walked do not keep the whole item graph of the library alive behind it. +/// </summary> +public class FolderChildCacheTests +{ + [Fact] + public void ReleaseCachedChildren_MakesTheNextAccessReload() + { + var folder = new TrackingFolder(); + + Assert.Empty(folder.Children); + Assert.Equal(1, folder.LoadCount); + + // Second access is served from the cache on the instance. + Assert.Empty(folder.Children); + Assert.Equal(1, folder.LoadCount); + + folder.ReleaseCachedChildren(); + + Assert.Empty(folder.Children); + Assert.Equal(2, folder.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_ReachesEveryLevelBelow() + { + var leaf = new TrackingFolder(); + var middle = new TrackingFolder { Source = [leaf] }; + var root = new TrackingFolder { Source = [middle] }; + + // Walk the whole tree, as a recursive scan does, so every level holds its children. + Assert.Single(root.Children); + Assert.Single(middle.Children); + Assert.Empty(leaf.Children); + Assert.Equal(1, root.LoadCount); + Assert.Equal(1, middle.LoadCount); + Assert.Equal(1, leaf.LoadCount); + + root.ReleaseCachedChildren(); + + Assert.Single(root.Children); + Assert.Single(middle.Children); + Assert.Empty(leaf.Children); + Assert.Equal(2, root.LoadCount); + Assert.Equal(2, middle.LoadCount); + Assert.Equal(2, leaf.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_LoadsNothingThatIsNotAlreadyHeld() + { + var leaf = new TrackingFolder(); + var root = new TrackingFolder { Source = [leaf] }; + + root.ReleaseCachedChildren(); + + Assert.Equal(0, root.LoadCount); + Assert.Equal(0, leaf.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_TerminatesOnACycle() + { + var first = new TrackingFolder(); + var second = new TrackingFolder { Source = [first] }; + first.Source = [second]; + + Assert.Single(first.Children); + Assert.Single(second.Children); + + // Clearing before descending is what stops this from recursing forever. + first.ReleaseCachedChildren(); + + Assert.Equal(1, first.LoadCount); + Assert.Equal(1, second.LoadCount); + } + + private sealed class TrackingFolder : Folder + { + public int LoadCount { get; private set; } + + public IReadOnlyList<BaseItem> Source { get; set; } = []; + + protected override IReadOnlyList<BaseItem> LoadChildren() + { + LoadCount++; + return Source; + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs new file mode 100644 index 0000000000..1794469359 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Linq; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +[Collection("LibraryManagerTests")] +public sealed class PlaylistTests : IDisposable +{ + private readonly ILibraryManager? _previousLibraryManager = BaseItem.LibraryManager; + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager; + } + + [Fact] + public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden() + { + // The SQL parental filter hides a container whose every member is blocked, so a listing + // built in memory has to reach the same answer. + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + SetupLibrary(blocked); + + Assert.False(BuildPlaylist(blocked).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_PlaylistWithOneAllowedItem_StaysVisible() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var allowed = new Audio { Id = Guid.NewGuid(), Name = "Song" }; + SetupLibrary(blocked, allowed); + + Assert.True(BuildPlaylist(blocked, allowed).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_UnrestrictedUser_LeavesTheItemsUnresolved() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var libraryManager = SetupLibrary(blocked); + var user = new User("user", "auth-provider", "reset-provider"); + + Assert.True(BuildPlaylist(blocked).IsVisible(user)); + + // Resolving a playlist's items is a query per playlist; nothing may run it for a user no + // rating keeps anything from. + libraryManager.Verify(x => x.GetItemList(It.IsAny<InternalItemsQuery>()), Times.Never); + } + + private static Mock<ILibraryManager> SetupLibrary(params BaseItem[] items) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(items); + BaseItem.LibraryManager = libraryManager.Object; + + return libraryManager; + } + + private static Playlist BuildPlaylist(params BaseItem[] items) + { + // An empty path keeps the playlist out of the shared-playlist branch. + return new Playlist + { + Id = Guid.NewGuid(), + Name = "Playlist", + LinkedChildren = items.Select(LinkedChild.Create).ToArray() + }; + } + + private static User BuildRestrictedUser() + { + var user = new User("user", "auth-provider", "reset-provider") { MaxParentalRatingScore = 5 }; + user.SetPreference(PreferenceKind.BlockUnratedItems, new[] { UnratedItem.Movie }); + + return user; + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs index 2b009b4673..aca55fbcc1 100644 --- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs @@ -261,6 +261,66 @@ public class EncodingHelperTests Assert.Contains("-ar 48000", args, StringComparison.Ordinal); } + [Fact] + public void GetProgressiveAudioFullCommandLine_StereoDownmix_AppliesDownMixAlgorithm() + { + // Issue #18194: -ac 2 alone drops the LFE channel, the configured downmix filter must be applied. + var state = BuildAudioState("aac", 48000); + state.AudioStream.Channels = 6; + state.AudioStream.ChannelLayout = "5.1"; + state.OutputAudioChannels = 2; + var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750, DownMixAudioBoost = 1 }; + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, options, "/tmp/out"); + + Assert.Contains( + "-af \"" + DownMixAlgorithmsHelper.AlgorithmFilterStrings[(DownMixStereoAlgorithms.Dave750, "5.1")] + "\"", + args, + StringComparison.Ordinal); + } + + [Fact] + public void GetProgressiveAudioFullCommandLine_NoDownmix_EmitsNoAudioFilter() + { + var state = BuildAudioState("aac", 48000); + state.AudioStream.Channels = 2; + state.OutputAudioChannels = 2; + var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 }; + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, options, "/tmp/out"); + + Assert.DoesNotContain("-af", args, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1)] + [InlineData(6)] + public void GetProgressiveVideoAudioArguments_NonStereoOutput_KeepsChannelCount(int outputChannels) + { + // The downmix filter only applies to stereo output, so -ac must not be dropped otherwise. + var state = BuildAudioState("aac", 48000); + state.AudioStream.Channels = 6; + state.AudioStream.ChannelLayout = "5.1"; + state.OutputAudioChannels = outputChannels; + var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 }; + var args = CreateHelper().GetProgressiveVideoAudioArguments(state, options); + + Assert.Contains("-ac " + outputChannels, args, StringComparison.Ordinal); + Assert.DoesNotContain("pan=", args, StringComparison.Ordinal); + } + + [Fact] + public void GetProgressiveVideoAudioArguments_StereoDownmix_UsesFilterInsteadOfChannelCount() + { + var state = BuildAudioState("aac", 48000); + state.AudioStream.Channels = 6; + state.AudioStream.ChannelLayout = "5.1"; + state.OutputAudioChannels = 2; + var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 }; + var args = CreateHelper().GetProgressiveVideoAudioArguments(state, options); + + Assert.DoesNotContain("-ac ", args, StringComparison.Ordinal); + Assert.Contains("pan=stereo", args, StringComparison.Ordinal); + } + private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate, string? outputContainer = null) { var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 }; diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs new file mode 100644 index 0000000000..9dc279a5b2 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs @@ -0,0 +1,56 @@ +using System; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Controller.Tests.MediaEncoding; + +public class TranscodingJobTests +{ + private static TranscodingJob CreateJob() + => new TranscodingJob(NullLogger<TranscodingJob>.Instance); + + [Fact] + public void GetHighestServedSegmentIndexEndingAtOrBefore_NoSegmentsServed_ReturnsNull() + { + using var job = CreateJob(); + + Assert.Null(job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromHours(1).Ticks)); + } + + [Fact] + public void GetHighestServedSegmentIndexEndingAtOrBefore_InitSegment_IsIgnored() + { + using var job = CreateJob(); + job.ReportSegmentDownloaded(-1, 0); + + Assert.Null(job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromHours(1).Ticks)); + } + + [Fact] + public void GetHighestServedSegmentIndexEndingAtOrBefore_SegmentEndingExactlyAtPosition_IsIncluded() + { + using var job = CreateJob(); + job.ReportSegmentDownloaded(0, TimeSpan.FromSeconds(6).Ticks); + job.ReportSegmentDownloaded(1, TimeSpan.FromSeconds(12).Ticks); + + Assert.Equal(1, job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromSeconds(12).Ticks)); + Assert.Equal(0, job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromSeconds(11.9).Ticks)); + } + + [Fact] + public void GetHighestServedSegmentIndexEndingAtOrBefore_SegmentsLongerThanDesired_ReturnsServedIndex() + { + // Keyframe-based playlists average longer segments than the desired 6s. + const double SegmentSeconds = 6.6; + using var job = CreateJob(); + for (var i = 0; i < 520; i++) + { + job.ReportSegmentDownloaded(i, TimeSpan.FromSeconds((i + 1) * SegmentSeconds).Ticks); + } + + var keepFrom = TimeSpan.FromSeconds((520 * SegmentSeconds) - 120); + + Assert.Equal(500, job.GetHighestServedSegmentIndexEndingAtOrBefore(keepFrom.Ticks)); + } +} |
