diff options
Diffstat (limited to 'tests')
21 files changed, 1614 insertions, 15 deletions
diff --git a/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs new file mode 100644 index 0000000000..77321f0b05 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Controllers; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +public class SubtitleControllerTests +{ + private const int SubtitleIndex = 2; + + private static readonly Guid _itemId = new("6e1f0a3b7c8d4e5f9a0b1c2d3e4f5a6b"); + + [Theory] + // Graphical subtitle tracks are negotiated as SubtitleDeliveryMethod.External and must be handed + // to the client untouched, because neither format can be parsed or rewritten. + [InlineData("pgssub", "pgssub", "/cache/subs/sub.sup")] + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub is exposed as .mks + // while the negotiated profile format stays "vobsub". + [InlineData("vobsub", "dvdsub", "/cache/subs/sub.mks")] + public async Task GetSubtitle_GraphicalStream_ReturnsRawFileWithRangeProcessing(string format, string codec, string path) + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController(codec, path, encoder, out var fileSystem); + fileSystem.Setup(f => f.FileExists(path)).Returns(true); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, format, null, null, null, null, null); + + var fileResult = Assert.IsType<PhysicalFileResult>(result); + Assert.Equal(path, fileResult.FileName); + Assert.True(fileResult.EnableRangeProcessing); + encoder.Verify( + e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<long>(), It.IsAny<long>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + [Fact] + public async Task GetSubtitle_RemotelyHostedGraphicalStream_FallsBackToEncoder() + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("pgssub", "https://example.com/sub.sup", encoder, out var fileSystem); + fileSystem.Setup(f => f.FileExists(It.IsAny<string>())).Returns(false); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "pgssub", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "pgssub", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + [Fact] + public async Task GetSubtitle_VobSubIdxPair_FallsBackToEncoder() + { + // A .idx/.sub pair has no raw payload the client could render: the encoder converts it. + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("dvdsub", "/media/sub.idx", encoder, out _); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "vobsub", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "vobsub", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + [Fact] + public async Task GetSubtitle_TextStream_IsEncoded() + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("subrip", "/media/sub.srt", encoder, out _); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "vtt", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "vtt", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + private static SubtitleController CreateController( + string codec, + string subtitlePath, + Mock<ISubtitleEncoder> subtitleEncoder, + out Mock<IFileSystem> fileSystem) + { + var item = new Movie { Id = _itemId }; + var subtitleStream = new MediaStream + { + Type = MediaStreamType.Subtitle, + Index = SubtitleIndex, + Codec = codec, + Path = subtitlePath, + IsExternal = true + }; + + var mediaSource = new MediaSourceInfo + { + Id = _itemId.ToString("N"), + MediaStreams = new List<MediaStream> { subtitleStream } + }; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.GetItemById<BaseItem>(_itemId)).Returns(item); + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(m => m.GetStaticMediaSources(item, false, null)).Returns([mediaSource]); + + subtitleEncoder.Setup(e => e.GetSubtitleFilePath(subtitleStream, mediaSource, It.IsAny<CancellationToken>())) + .ReturnsAsync(subtitlePath); + + fileSystem = new Mock<IFileSystem>(); + + return new SubtitleController( + Mock.Of<IServerConfigurationManager>(), + libraryManager.Object, + Mock.Of<ISubtitleManager>(), + subtitleEncoder.Object, + mediaSourceManager.Object, + Mock.Of<IProviderManager>(), + fileSystem.Object, + NullLogger<SubtitleController>.Instance); + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index e072bccb82..c67717e2fa 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -40,15 +40,21 @@ public class BaseItemTests 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(), @@ -64,6 +70,7 @@ public class BaseItemTests BaseItem.LibraryManager = previousLibrary; BaseItem.ItemRepository = previousRepository; BaseItem.Logger = previousLogger; + BaseItem.MediaSourceManager = previousMediaSourceManager; } } diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs index 70da5eafe5..1794469359 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs @@ -9,14 +9,21 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; -using MediaBrowser.Model.Querying; using Moq; using Xunit; namespace Jellyfin.Controller.Tests.Entities; -public class PlaylistTests +[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() { 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.Naming.Tests/TV/SeriesPathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs index e6b0409db7..4e1de22790 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs @@ -25,5 +25,15 @@ namespace Jellyfin.Naming.Tests.TV Assert.Equal(name, res.SeriesName); Assert.True(res.Success); } + + [Theory] + [InlineData("/media/Jujutsu Kaisen (BD_1280x720)")] + [InlineData("/media/Show.1920x1080.BluRay")] + public void SeriesPathParser_ResolutionPatternIsNotASeries(string path) + { + var res = SeriesPathParser.Parse(_namingOptions, path); + + Assert.False(res.Success); + } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs index 023c6cb2fa..0c44595441 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs @@ -25,6 +25,8 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Marvel's.Agents.of.S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")] [InlineData("The.Show.S.H.O.W", "The Show S.H.O.W")] [InlineData("/some/path/Dawson's Creek", "Dawson's Creek")] + [InlineData("/media/Bunker.S03.1080p.PULSAR.WEB-DL.DDP5.1.Atmos.H.264-showWEB", "Bunker")] + [InlineData("/media/Outer.Colony.S01.1080p.NOVA.WEB-DL.DDP5.1.H.264.HUN.ENG-QUASAR", "Outer Colony")] public void SeriesResolverResolveTest(string path, string name) { var res = SeriesResolver.Resolve(_namingOptions, path); diff --git a/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs b/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs new file mode 100644 index 0000000000..6b0d3a2ab2 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Providers.Plugins.MusicBrainz; +using MetaBrainz.Common; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Providers.Tests.Music; + +public static class MusicBrainzQueryExtensionsTests +{ + [Fact] + public static async Task RetryOnTransientErrorAsync_ServerBusy_RetriesAndSucceeds() + { + var attempts = 0; + + var result = await MusicBrainzQueryExtensions.RetryOnTransientErrorAsync( + async _ => + { + attempts++; + if (attempts == 1) + { + throw await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + } + + return "found"; + }, + "test", + NullLogger.Instance, + CancellationToken.None); + + Assert.Equal("found", result); + Assert.Equal(2, attempts); + } + + [Fact] + public static async Task RetryOnTransientErrorAsync_ServerStaysBusy_GivesUp() + { + var attempts = 0; + + var error = await Assert.ThrowsAsync<HttpError>(() => MusicBrainzQueryExtensions.RetryOnTransientErrorAsync<string>( + async _ => + { + attempts++; + throw await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + }, + "test", + NullLogger.Instance, + CancellationToken.None)); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, error.Status); + Assert.Equal(3, attempts); + } + + [Fact] + public static async Task RetryOnTransientErrorAsync_NotFound_DoesNotRetry() + { + var attempts = 0; + + await Assert.ThrowsAsync<HttpError>(() => MusicBrainzQueryExtensions.RetryOnTransientErrorAsync<string>( + async _ => + { + attempts++; + throw await CreateErrorAsync(HttpStatusCode.NotFound, null); + }, + "test", + NullLogger.Instance, + CancellationToken.None)); + + Assert.Equal(1, attempts); + } + + [Fact] + public static async Task GetRetryDelay_NoHint_BacksOffExponentially() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, null); + + Assert.Equal(TimeSpan.FromSeconds(1), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + Assert.Equal(TimeSpan.FromSeconds(2), MusicBrainzQueryExtensions.GetRetryDelay(error, 2)); + } + + [Fact] + public static async Task GetRetryDelay_RetryAfterZero_WaitsMinimum() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + + Assert.Equal(TimeSpan.FromSeconds(1), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_RetryAfterLongerThanBackoff_UsesRetryAfter() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.FromSeconds(10)); + + Assert.Equal(TimeSpan.FromSeconds(10), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_LongRetryAfter_IsCapped() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.FromHours(1)); + + Assert.Equal(TimeSpan.FromSeconds(15), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_RateLimitWindow_WaitsForReset() + { + var error = await CreateErrorAsync( + HttpStatusCode.ServiceUnavailable, + null, + headers => headers.TryAddWithoutValidation("X-RateLimit-Reset", DateTimeOffset.UtcNow.AddSeconds(8).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))); + + var delay = MusicBrainzQueryExtensions.GetRetryDelay(error, 1); + + Assert.InRange(delay, TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(8)); + } + + private static async Task<HttpError> CreateErrorAsync(HttpStatusCode status, TimeSpan? retryAfter, Action<HttpResponseHeaders>? configureHeaders = null) + { + using var response = new HttpResponseMessage(status); + if (retryAfter is not null) + { + response.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter.Value); + } + + configureHeaders?.Invoke(response.Headers); + + return await HttpError.FromResponseAsync(response); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs index 3062cb7b42..0275d13242 100644 --- a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs +++ b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs @@ -63,6 +63,41 @@ namespace Jellyfin.Providers.Tests.Omdb Assert.Equal(expected, result); } + [Theory] + [InlineData("\"Vincent D'Onofrio\"", "Vincent D'Onofrio")] + [InlineData("\"Alan Moore 'Swamp Thing'\"", "Alan Moore 'Swamp Thing'")] + [InlineData("\"Tom & Jerry\"", "Tom & Jerry")] + [InlineData("\"Mail <a@b.com> for a translation\"", "Mail <a@b.com> for a translation")] + [InlineData("\"Elisa Mont\\u00e9s\"", "Elisa Montés")] + [InlineData("\"R&D and 100% & more\"", "R&D and 100% & more")] + public void Deserialize_Html_Encoded_String_Is_Decoded(string input, string expected) + { + var result = JsonSerializer.Deserialize<string?>(input, _options); + Assert.Equal(expected, result); + } + + [Fact] + public void Deserialize_Html_Encoded_Credits_Are_Decoded() + { + const string Input = "{\"Title\":\"Full Metal Jacket\",\"Actors\":\"Matthew Modine, R. Lee Ermey, Vincent D'Onofrio\",\"Response\":\"True\"}"; + var rootObject = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, _options); + Assert.NotNull(rootObject); + Assert.Equal("Matthew Modine, R. Lee Ermey, Vincent D'Onofrio", rootObject!.Actors); + } + + [Fact] + public void ProviderJsonOptions_Take_Priority_Over_The_Defaults() + { + // The default string converter handles string as well, so appending the OMDb ones would never run them + var options = OmdbProvider.CreateJsonOptions(); + + const string Input = "{\"Title\":\"Full Metal Jacket\",\"Actors\":\"Vincent D'Onofrio\",\"Awards\":\"N/A\",\"Response\":\"True\"}"; + var rootObject = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, options); + Assert.NotNull(rootObject); + Assert.Equal("Vincent D'Onofrio", rootObject!.Actors); + Assert.Null(rootObject.Awards); + } + [Fact] public void Roundtrip_Valid_Success() { diff --git a/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs new file mode 100644 index 0000000000..3f6a7fb0b0 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.TV; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.TV; + +/// <summary> +/// Covers reconciling missing episodes against the episodes the user has, which cannot be done on episode +/// numbers alone: aired, DVD and absolute orders number the same episodes differently, so the tail of the +/// longer order is the episodes the user owns rather than the ones they lack. +/// </summary> +public class SeriesMetadataServiceTests +{ + public SeriesMetadataServiceTests() + { + // An episode with a path asks the file system what kind of location it is. Only set when nothing else + // in the assembly has, since it is process-wide. + BaseItem.FileSystem ??= Mock.Of<IFileSystem>(); + } + + [Fact] + public void IsAlreadyPresent_VirtualEpisodeCarriesTheIdOfAPresentOne_ReturnsTrue() + { + // The Six Million Dollar Man S3: 21 files numbered in DVD order, 23 episodes in aired order, so + // aired 22 and 23 are the files stored as 20 and 21. + var present = SeriesMetadataService.GetPresentEpisodeIds( + [ + Physical(20, (MetadataProvider.Tvdb, "236715")), + Physical(21, (MetadataProvider.Tvdb, "236716")) + ]); + + Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(22, (MetadataProvider.Tvdb, "236715")), present)); + Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(23, (MetadataProvider.Tvdb, "236716")), present)); + } + + [Fact] + public void IsAlreadyPresent_GenuinelyMissingEpisode_ReturnsFalse() + { + // The aired-only two-parter the user really does not have keeps its missing episodes. + var present = SeriesMetadataService.GetPresentEpisodeIds( + [ + Physical(20, (MetadataProvider.Tvdb, "236715")), + Physical(21, (MetadataProvider.Tvdb, "236716")) + ]); + + Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(15, (MetadataProvider.Tvdb, "4273533")), present)); + } + + [Fact] + public void GetPresentEpisodeIds_IdSharedBySeveralEpisodes_IsNotAnIdentity() + { + // A series' own IMDb id lands on every episode, and TMDb reports an unknown TVRage id as 0. Taking + // either for an episode identity would let one file delete every missing episode of the series. + var present = SeriesMetadataService.GetPresentEpisodeIds( + [ + Physical(1, (MetadataProvider.Imdb, "tt0836592"), (MetadataProvider.TvRage, "0"), (MetadataProvider.Tvdb, "1001")), + Physical(2, (MetadataProvider.Imdb, "tt0836592"), (MetadataProvider.TvRage, "0"), (MetadataProvider.Tvdb, "1002")) + ]); + + Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(3, (MetadataProvider.Imdb, "tt0836592")), present)); + Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(4, (MetadataProvider.TvRage, "0")), present)); + Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(5, (MetadataProvider.Tvdb, "1001")), present)); + } + + [Fact] + public void GetPresentEpisodeIds_VirtualEpisode_DoesNotCountAsPresent() + { + // Two virtual episodes for the same episode must not cancel each other out into "present". + var present = SeriesMetadataService.GetPresentEpisodeIds( + [ + Virtual(22, (MetadataProvider.Tvdb, "236715")) + ]); + + Assert.Empty(present); + } + + [Fact] + public void GetPresentEpisodeIds_BlankStoredId_IsIgnored() + { + // SetProviderId refuses these, but a row written by an older version can still hold one. + var episode = Physical(1); + episode.ProviderIds["Tvdb"] = " "; + + Assert.Empty(SeriesMetadataService.GetPresentEpisodeIds([episode])); + } + + private static Episode Physical(int indexNumber, params (MetadataProvider Provider, string Id)[] providerIds) + => WithIds(new Episode { IndexNumber = indexNumber, Path = $"/media/show/Season 03/E{indexNumber}.mkv" }, providerIds); + + private static Episode Virtual(int indexNumber, params (MetadataProvider Provider, string Id)[] providerIds) + => WithIds(new Episode { IndexNumber = indexNumber, IsVirtualItem = true }, providerIds); + + private static Episode WithIds(Episode episode, IEnumerable<(MetadataProvider Provider, string Id)> providerIds) + { + foreach (var (provider, id) in providerIds) + { + episode.SetProviderId(provider, id); + } + + return episode; + } +} diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs index 03bad3555e..8342621d97 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -30,14 +30,25 @@ namespace Jellyfin.Providers.Tests.Tmdb } [Theory] - [InlineData("en", "en-US", "en-US")] - [InlineData("fr-CA", "fr-BE", "fr-CA")] - [InlineData("fr-CA", "fr", "fr-CA")] - [InlineData("de", "en-US", "de")] - [InlineData("", "en-US", "")] - public static void AdjustImageLanguage_Valid_Success(string imageLanguage, string requestLanguage, string? expected) + // A regional request is only answered with a region when the image contradicts it. + [InlineData("pt", "BR", "pt-BR", "pt-BR")] + [InlineData("pt", "PT", "pt-BR", "pt-PT")] + [InlineData("en", "GB", "en-US", "en-GB")] + [InlineData("en", "US", "en-US", "en-US")] + [InlineData("en", null, "en-US", "en-US")] + // TMDb files nearly every image under a region, which must not stop a plain request matching. + [InlineData("en", "US", "en", "en")] + [InlineData("pt", "PT", "pt", "pt")] + // An image in another language keeps its bare code, so the English fallback still resolves. + [InlineData("en", "US", "pt-BR", "en")] + [InlineData("de", "DE", "en-US", "de")] + // TMDb returns xx rather than an empty string for an image with no text. + [InlineData("xx", "US", "en-US", "")] + [InlineData("", "US", "en-US", "")] + [InlineData(null, null, "en-US", "")] + public static void GetImageLanguage_Valid_Success(string? imageLanguage, string? imageRegion, string? requestLanguage, string expected) { - Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage)); + Assert.Equal(expected, TmdbUtils.GetImageLanguage(imageLanguage, imageRegion, requestLanguage)); } [Theory] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs new file mode 100644 index 0000000000..a60622902b --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Data; + +public class ItemTypeLookupTests +{ + public static TheoryData<BaseItemKind> MappedKinds() + => new(new ItemTypeLookup().BaseItemKindNames.Keys); + + [Theory] + [MemberData(nameof(MappedKinds))] + public void BaseItemKindNames_Kind_NamesAnItemType(BaseItemKind kind) + { + var name = new ItemTypeLookup().BaseItemKindNames[kind]; + + var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType(name)).FirstOrDefault(t => t is not null); + + Assert.NotNull(type); + Assert.True(typeof(BaseItem).IsAssignableFrom(type), $"{name} is not an item type."); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs new file mode 100644 index 0000000000..153e1dc154 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Tests.Item; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Data; + +/// <summary> +/// Statistics taken on a freshly created database describe every table as a single row, and SQLite then plans +/// the user data and series queries of a filled library as full scans (#17886). +/// </summary> +public sealed class SqliteDatabaseStatisticsTests : SqliteDbTestFixture +{ + private readonly SqliteDatabaseProvider _provider; + + public SqliteDatabaseStatisticsTests() + { + _provider = new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance) + { + DbContextFactory = CreateDbContextFactory() + }; + } + + [Fact] + public async Task RunScheduledOptimisation_EmptyLibrary_RecordsNoStatistics() + { + SeedFolders(3); + + await _provider.RunScheduledOptimisation(CancellationToken.None); + + Assert.Null(ReadAnalyzedItemCount()); + } + + [Fact] + public async Task RunScheduledOptimisation_LibraryWithItems_RecordsStatistics() + { + SeedFolders(1); + SeedEpisodes(4); + + await _provider.RunScheduledOptimisation(CancellationToken.None); + + Assert.Equal(CountItems(), ReadAnalyzedItemCount()); + } + + [Fact] + public async Task RefreshStatistics_NoStatistics_Analyzes() + { + SeedEpisodes(5); + + await _provider.RefreshStatistics(CancellationToken.None); + + Assert.Equal(CountItems(), ReadAnalyzedItemCount()); + } + + [Fact] + public async Task RefreshStatistics_LibraryChanged_Reanalyzes() + { + SeedEpisodes(10); + Analyze(); + SeedEpisodes(5); + + await _provider.RefreshStatistics(CancellationToken.None); + + Assert.Equal(CountItems(), ReadAnalyzedItemCount()); + } + + [Fact] + public async Task RefreshStatistics_EmptyLibrary_RecordsNoStatistics() + { + SeedFolders(2); + + await _provider.RefreshStatistics(CancellationToken.None); + + Assert.Null(ReadAnalyzedItemCount()); + } + + private void SeedFolders(int count) + { + using var context = CreateDbContext(); + context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "MediaBrowser.Controller.Entities.Folder", + IsFolder = true + })); + context.SaveChanges(); + } + + private void SeedEpisodes(int count) + { + using var context = CreateDbContext(); + context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "MediaBrowser.Controller.Entities.TV.Episode", + IsFolder = false + })); + context.SaveChanges(); + } + + private void Analyze() + { + using var context = CreateDbContext(); + context.Database.ExecuteSqlRaw("ANALYZE"); + } + + private long CountItems() + { + using var context = CreateDbContext(); + return context.BaseItems.LongCount(); + } + + private long? ReadAnalyzedItemCount() + { + using var context = CreateDbContext(); + var hasStatistics = context.Database + .SqlQueryRaw<long>("SELECT count(*) AS \"Value\" FROM sqlite_schema WHERE type = 'table' AND name = 'sqlite_stat1'") + .Single(); + if (hasStatistics == 0) + { + return null; + } + + return context.Database + .SqlQueryRaw<long?>("SELECT max(CAST(stat AS INTEGER)) AS \"Value\" FROM sqlite_stat1 WHERE tbl = 'BaseItems'") + .Single(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs new file mode 100644 index 0000000000..b88296a06a --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.Images; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Images; + +/// <summary> +/// A music library is collaged from its artists' backdrops. Artists are by-name items with no +/// library of their own, so they have to be asked for through the by-name listing, which reaches +/// them through the tracks that credit them; an item query for them ignores the library scope and +/// hands back the artists of every music library. +/// </summary> +public sealed class CollectionFolderImageProviderTests +{ + [Fact] + public void GetItemsWithImages_MusicLibrary_AsksForTheArtistsOfThatLibraryOnly() + { + var view = new CollectionFolder { Id = Guid.NewGuid(), CollectionType = CollectionType.music }; + var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "Artist" }; + + InternalItemsQuery? query = null; + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(l => l.GetAllArtists(It.IsAny<InternalItemsQuery>())) + .Callback<InternalItemsQuery>(q => query = q) + .Returns(new QueryResult<(BaseItem Item, ItemCounts ItemCounts)>([(artist, new ItemCounts())])); + + var items = CreateProvider(libraryManager.Object).GetItems(view); + + Assert.Equal([artist], items); + Assert.NotNull(query); + Assert.Equal([view.Id], query.AncestorIds); + Assert.Equal([ImageType.Primary], query.ImageTypes); + Assert.Equal(8, query.Limit); + } + + private static TestableCollectionFolderImageProvider CreateProvider(ILibraryManager libraryManager) + { + return new TestableCollectionFolderImageProvider( + Mock.Of<IFileSystem>(), + Mock.Of<IProviderManager>(), + Mock.Of<IApplicationPaths>(), + Mock.Of<IImageProcessor>(), + libraryManager); + } + + private sealed class TestableCollectionFolderImageProvider : CollectionFolderImageProvider + { + public TestableCollectionFolderImageProvider( + IFileSystem fileSystem, + IProviderManager providerManager, + IApplicationPaths applicationPaths, + IImageProcessor imageProcessor, + ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + + public IReadOnlyList<BaseItem> GetItems(BaseItem item) => GetItemsWithImages(item); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs new file mode 100644 index 0000000000..0c1d453293 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ArtistNameLookupTests : SqliteDbTestFixture +{ + private readonly CommandRecorder _recorder; + + public ArtistNameLookupTests() + : this(new CommandRecorder()) + { + } + + private ArtistNameLookupTests(CommandRecorder recorder) + : base(recorder) + { + _recorder = recorder; + } + + [Theory] + [InlineData("Björk", "bjork")] + [InlineData("AC/DC", "ac dc")] + [InlineData("An Artist", " AN ARTIST ")] + [InlineData("Artist", "ARTIST")] + public void GetArtist_UsesSameNormalizedNameAsFindArtists(string storedName, string requestedName) + { + var lookup = new ItemTypeLookup(); + var artistId = Guid.NewGuid(); + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + new BaseItemEntity + { + Id = artistId, + Name = storedName, + CleanName = storedName.GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }, + new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = storedName, + CleanName = storedName.GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.Person] + }); + context.SaveChanges(); + } + + var manager = CreateLibraryManager(lookup); + _recorder.Commands.Clear(); + + Assert.Equal(artistId, manager.GetArtist(requestedName).Id); + var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("\"CleanName\" =", StringComparison.Ordinal)); + Assert.Contains(Explain(query), line => line.Contains("IX_BaseItems_Type_CleanName (Type=? AND CleanName=?)", StringComparison.Ordinal)); + Assert.Equal(artistId, Assert.Single(manager.GetArtists([requestedName])[requestedName]).Id); + } + + [Fact] + public void GetArtist_PrefersFilesystemArtistWhenNormalizedNamesMatch() + { + var lookup = new ItemTypeLookup(); + var parentId = Guid.NewGuid(); + var artistId = Guid.NewGuid(); + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + new BaseItemEntity + { + Id = parentId, + Name = "Music", + Type = lookup.BaseItemKindNames[BaseItemKind.Folder] + }, + new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = "Bjork", + CleanName = "Bjork".GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }, + new BaseItemEntity + { + Id = artistId, + ParentId = parentId, + Name = "Björk", + CleanName = "Björk".GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }); + context.SaveChanges(); + } + + Assert.Equal(artistId, CreateLibraryManager(lookup).GetArtist("Bjork").Id); + } + + private ServerLibraryManager CreateLibraryManager(ItemTypeLookup lookup) + { + var repository = CreateBaseItemRepository(lookup); + 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"); + fixture.Inject<IItemRepository>(repository); + fixture.Inject<ILinkedChildrenService>(new LinkedChildrenService(CreateDbContextFactory(), lookup, repository)); + return fixture.Create<ServerLibraryManager>(); + } + + private string[] Explain(RecordedCommand query) + { + using var context = CreateDbContext(); + using var command = context.Database.GetDbConnection().CreateCommand(); +#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters. + command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql; +#pragma warning restore CA2100 + foreach (var value in query.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = value.Name; + parameter.Value = value.Value; + command.Parameters.Add(parameter); + } + + using var reader = command.ExecuteReader(); + var plan = new List<string>(); + while (reader.Read()) + { + plan.Add(reader.GetString(3)); + } + + return plan.ToArray(); + } + + private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters); + + private sealed class CommandRecorder : DbCommandInterceptor + { + public List<RecordedCommand> Commands { get; } = []; + + public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) + { + Record(command); + return result; + } + + public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result) + { + Record(command); + return result; + } + + private void Record(DbCommand command) => Commands.Add(new RecordedCommand( + command.CommandText, + command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray())); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs new file mode 100644 index 0000000000..3c8bd9fd50 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// A user restricted to a set of allowed tags still has to see the by-name items - genres, studios, +/// artists - of the media those tags let through. The by-name item carries none of the media's tags, +/// so the allowed-tags filter would otherwise hide every one of them. +/// </summary> +public sealed class BaseItemRepositoryAllowedTagsByNameTests : SqliteDbTestFixture +{ + private const string AllowedTag = "kids"; + + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryAllowedTagsByNameTests() + { + _itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(_itemTypeLookup); + Seed(); + } + + [Fact] + public void GetGenres_WithAllowedTags_ReturnsGenresOfAllowedItems() + { + var result = _repository.GetGenres(CreateQuery(AllowedTag)); + + Assert.Equal(["Comedy"], Names(result)); + } + + [Fact] + public void GetStudios_WithAllowedTags_ReturnsStudiosOfAllowedItems() + { + var result = _repository.GetStudios(CreateQuery(AllowedTag)); + + Assert.Equal(["Pixar"], Names(result)); + } + + [Fact] + public void GetAllArtists_WithAllowedTags_ReturnsArtistsOfAllowedItems() + { + var result = _repository.GetAllArtists(CreateQuery(AllowedTag)); + + Assert.Equal(["Raffi"], Names(result)); + } + + [Fact] + public void GetGenres_WithoutAllowedTags_ReturnsEveryGenre() + { + var result = _repository.GetGenres(CreateQuery()); + + Assert.Equal(["Comedy", "Horror", "Jazz"], Names(result)); + } + + private static string[] Names(MediaBrowser.Model.Querying.QueryResult<(BaseItem Item, MediaBrowser.Model.Dto.ItemCounts? ItemCounts)> result) + => result.Items.Select(i => i.Item.Name!).Order(StringComparer.Ordinal).ToArray(); + + private static InternalItemsQuery CreateQuery(params string[] allowedTags) + { + var user = new User("restricted", "auth", "reset"); + if (allowedTags.Length > 0) + { + user.SetPreference(PreferenceKind.AllowedTags, allowedTags); + } + + return new InternalItemsQuery(user); + } + + private void Seed() + { + using var context = CreateDbContext(); + + // Tagged media, plus the by-name items describing it. + var movie = CreateItem(BaseItemKind.Movie, "Allowed Movie", mediaType: "Video"); + var song = CreateItem(BaseItemKind.Audio, "Allowed Song", mediaType: "Audio"); + var genre = CreateItem(BaseItemKind.Genre, "Comedy"); + var studio = CreateItem(BaseItemKind.Studio, "Pixar"); + var artist = CreateItem(BaseItemKind.MusicArtist, "Raffi"); + + // Media the allow list keeps out, plus the by-name items only it is described by. + var blockedMovie = CreateItem(BaseItemKind.Movie, "Untagged Movie", mediaType: "Video"); + var blockedGenre = CreateItem(BaseItemKind.Genre, "Horror"); + + // A genre written on nothing but a by-name item: that item is no more visible than the + // media behind it, so the genre must not leak into a restricted user's list. + var artistOnlyGenre = CreateItem(BaseItemKind.Genre, "Jazz"); + var blockedArtist = CreateItem(BaseItemKind.MusicArtist, "Mingus"); + + var tag = CreateItemValue(ItemValueType.Tags, AllowedTag); + var comedy = CreateItemValue(ItemValueType.Genre, "Comedy"); + var horror = CreateItemValue(ItemValueType.Genre, "Horror"); + var jazz = CreateItemValue(ItemValueType.Genre, "Jazz"); + var pixar = CreateItemValue(ItemValueType.Studios, "Pixar"); + var raffi = CreateItemValue(ItemValueType.Artist, "Raffi"); + + context.BaseItems.AddRange(movie, song, genre, studio, artist, blockedMovie, blockedGenre, artistOnlyGenre, blockedArtist); + context.ItemValues.AddRange(tag, comedy, horror, jazz, pixar, raffi); + context.ItemValuesMap.AddRange( + CreateMap(movie, tag), + CreateMap(movie, comedy), + CreateMap(movie, pixar), + CreateMap(song, tag), + CreateMap(song, raffi), + CreateMap(blockedMovie, horror), + CreateMap(blockedArtist, jazz)); + context.SaveChanges(); + } + + private BaseItemEntity CreateItem(BaseItemKind kind, string name, string? mediaType = null) + { + var id = Guid.NewGuid(); + + return new BaseItemEntity + { + Id = id, + Type = _itemTypeLookup.BaseItemKindNames[kind], + Name = name, + CleanName = name.ToLowerInvariant(), + PresentationUniqueKey = id.ToString("N"), + MediaType = mediaType, + IsMovie = kind == BaseItemKind.Movie, + IsFolder = false, + IsVirtualItem = false + }; + } + + private static ItemValue CreateItemValue(ItemValueType type, string value) + { + return new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = value, + CleanValue = value.ToLowerInvariant() + }; + } + + private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue) + { + return new ItemValueMap + { + ItemId = item.Id, + ItemValueId = itemValue.ItemValueId, + Item = item, + ItemValue = itemValue + }; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs new file mode 100644 index 0000000000..1f8d9820d6 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Artists are by-name items: they live outside any library and carry no TopParentId, so a plain +/// item query for them is exempt from the library filter and spans every music library. Only the +/// by-name listings, which reach the artist through the tracks that credit it, can be scoped to +/// one library. +/// </summary> +public sealed class BaseItemRepositoryArtistLibraryScopeTests : SqliteDbTestFixture +{ + private static readonly Guid _firstLibrary = Guid.Parse("11111111-0000-0000-0000-000000000001"); + private static readonly Guid _secondLibrary = Guid.Parse("22222222-0000-0000-0000-000000000001"); + + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryArtistLibraryScopeTests() + { + _itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(_itemTypeLookup); + + Seed("First Artist", "first artist", _firstLibrary, hasImage: true); + Seed("Second Artist", "second artist", _secondLibrary, hasImage: true); + } + + [Fact] + public void GetItemList_MusicArtistsScopedToOneLibrary_ReturnsEveryLibrarysArtists() + { + // The shape the library cover image used to be built from. By-name types are exempt from + // the TopParentId filter, so the scope is silently dropped. + var result = _repository.GetItemList(new InternalItemsQuery + { + DtoOptions = new DtoOptions(false), + IncludeItemTypes = [BaseItemKind.MusicArtist], + TopParentIds = [_firstLibrary] + }); + + Assert.Equal(["First Artist", "Second Artist"], result.Select(i => i.Name).OrderBy(n => n)); + } + + [Fact] + public void GetAllArtists_ScopedToOneLibrary_ReturnsOnlyThatLibrarysArtists() + { + var result = _repository.GetAllArtists(new InternalItemsQuery + { + DtoOptions = new DtoOptions(false), + TopParentIds = [_firstLibrary] + }); + + var (artist, _) = Assert.Single(result.Items); + Assert.Equal("First Artist", artist.Name); + } + + [Fact] + public void GetAllArtists_ImageTypes_DropsArtistsWithoutThatImage() + { + // The collage has nothing to draw with an artist that has no image, so the listing has to + // honour the image filter the caller asked for. + Seed("Third Artist", "third artist", _firstLibrary, hasImage: false); + + var result = _repository.GetAllArtists(new InternalItemsQuery + { + DtoOptions = new DtoOptions(false), + ImageTypes = [ImageType.Primary], + TopParentIds = [_firstLibrary] + }); + + var (artist, _) = Assert.Single(result.Items); + Assert.Equal("First Artist", artist.Name); + } + + /// <summary> + /// Seeds one by-name artist row and a track in the given library crediting it. + /// </summary> + /// <param name="name">The artist name.</param> + /// <param name="cleanName">The cleaned artist name, which is what links the two rows.</param> + /// <param name="topParentId">The library the track belongs to.</param> + /// <param name="hasImage">Whether the artist row carries a primary image.</param> + private void Seed(string name, string cleanName, Guid topParentId, bool hasImage) + { + using var ctx = CreateDbContext(); + + var artistId = Guid.NewGuid(); + var artist = new BaseItemEntity + { + Id = artistId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], + Name = name, + CleanName = cleanName, + PresentationUniqueKey = artistId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }; + + if (hasImage) + { + artist.Images = + [ + new BaseItemImageInfo + { + Id = Guid.NewGuid(), + ItemId = artistId, + Item = artist, + ImageType = ImageInfoImageType.Primary, + Path = $"/metadata/artists/{cleanName}/folder.jpg" + } + ]; + } + + ctx.BaseItems.Add(artist); + + var trackId = Guid.NewGuid(); + var track = new BaseItemEntity + { + Id = trackId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio], + Name = $"{name} - Track", + CleanName = $"{cleanName} - track", + PresentationUniqueKey = trackId.ToString("N"), + MediaType = "Audio", + TopParentId = topParentId, + IsFolder = false, + IsVirtualItem = false + }; + ctx.BaseItems.Add(track); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.AlbumArtist, + Value = name, + CleanValue = cleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = trackId, + ItemValueId = itemValue.ItemValueId, + Item = track, + ItemValue = itemValue + }); + + ctx.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index a28c1d6dfb..f717d9b665 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -29,6 +29,7 @@ public class FindExtrasTests { private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; private readonly Mock<IFileSystem> _fileSystemMock; + private readonly Mock<IItemRepository> _itemRepositoryMock; public FindExtrasTests() { @@ -36,8 +37,8 @@ public class FindExtrasTests fixture.Register(() => new NamingOptions()); var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>(); configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); - var itemRepository = fixture.Freeze<Mock<IItemRepository>>(); - itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null); + _itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>(); + _itemRepositoryMock.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 }); @@ -482,6 +483,69 @@ public class FindExtrasTests } [Fact] + public void FindExtras_ExtraNamedByLocalMetadata_KeepsItsNameOnRescan() + { + 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" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var directoryService = new DirectoryService(_fileSystemMock.Object); + var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Trailer", extra.Name); + + // A local metadata file gives the extra a title of its own, which the refresh persists + extra.Name = "Cannes Teaser"; + _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra); + + var rescanned = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Cannes Teaser", rescanned.Name); + } + + [Fact] + public void FindExtras_ExtraKeptItsGeneratedName_IsRenumberedOnRescan() + { + 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)-trailer2.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var directoryService = new DirectoryService(_fileSystemMock.Object); + var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Trailer", extra.Name); + _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra); + + // A trailer sorting before the known one takes the first number, so the known one moves on + files.Add(new FileSystemMetadata + { + FullName = "/movies/Up (2009)/Up (2009)-trailer1.mkv", + IsDirectory = false + }); + + var rescanned = _libraryManager.FindExtras(owner, files, directoryService) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal("Trailer", rescanned["/movies/Up (2009)/Up (2009)-trailer1.mkv"]); + Assert.Equal("Trailer 2", rescanned["/movies/Up (2009)/Up (2009)-trailer2.mkv"]); + } + + [Fact] public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle() { var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs index e1346a8436..3aac3cdc43 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs @@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Moq; @@ -89,4 +90,92 @@ public class MovieResolverTests Assert.All(result.Items, item => Assert.IsType<Movie>(item)); Assert.Single(((Video)result.Items[0]).LocalAlternateVersions); } + + [Fact] + public void AllExtrasTypesFolderNames_ContainsSampleSingularAndPlural() + { + Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("sample")); + Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("Sample")); + Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("samples")); + } + + [Theory] + [InlineData("Sample")] + [InlineData("sample")] + [InlineData("SAMPLE")] + [InlineData("samples")] + public void ResolvePath_MovieFolderWithSampleSubfolder_ResolvesToMovie(string sampleDirName) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false); + + var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + var args = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + libraryManager.Object) + { + Parent = new Folder(), + CollectionType = CollectionType.movies, + FileInfo = new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)", + IsDirectory = true + }, + FileSystemChildren = new[] + { + new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv", + Name = "Outer Colony (2026).mkv" + }, + new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)/" + sampleDirName, + Name = sampleDirName, + IsDirectory = true + } + } + }; + + Assert.IsType<Movie>(resolver.ResolvePath(args)); + } + + [Fact] + public void ResolvePath_MovieFolderWithRealSubfolder_DoesNotResolveToSingleMovie() + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false); + + var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + var args = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + libraryManager.Object) + { + Parent = new Folder(), + CollectionType = CollectionType.movies, + FileInfo = new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)", + IsDirectory = true + }, + FileSystemChildren = new[] + { + new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv", + Name = "Outer Colony (2026).mkv" + }, + new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)/Feature", + Name = "Feature", + IsDirectory = true + } + } + }; + + Assert.Null(resolver.ResolvePath(args)); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 93014e7244..4e57f32b57 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -6,6 +6,7 @@ using BitFaster.Caching; using Emby.Server.Implementations.Localization; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -180,7 +181,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var ratings = localizationManager.GetParentalRatings().ToList(); - Assert.Equal(24, ratings.Count); + Assert.Equal(34, ratings.Count); var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal)); Assert.NotNull(fsk); @@ -209,6 +210,37 @@ namespace Jellyfin.Server.Implementations.Tests.Localization [InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive [InlineData("VM 18", "IT", 18, null)] [InlineData("Vietato ai minori di 18 anni", "IT", 18, null)] + [InlineData("ATP", "AR", 0, null)] + [InlineData("SAM 13", "AR", 13, null)] + [InlineData("SAM 16", "AR", 16, null)] + [InlineData("SAM 18", "AR", 18, null)] + [InlineData("SAM13", "AR", 13, null)] // Written without a space + [InlineData("AR-SAM 16", "AR", 16, null)] // Country prefix stripped against the configured country + [InlineData("AR-SAM 13", "US", 13, null)] // Country prefix resolved via the separator fallback + [InlineData("AR-SAM 18", "US", 18, null)] + [InlineData("AR-SAM13", "US", 13, null)] + [InlineData("SAM 18 C", "AR", 1001, null)] // Condicionada, same as "C" + [InlineData("Interdit aux moins de 12 ans", "FR", 12, null)] + [InlineData("Interdit aux moins de 18 ans", "FR", 18, null)] + [InlineData("X 18+", "AU", 1000, 0)] // Official spelling of the Australian X rating + [InlineData("X18+", "AU", 1000, 0)] + [InlineData("FSK18", "DE", 18, null)] // Written without a space + [InlineData("ab 18", "DE", 18, null)] // Written as the minimum age + [InlineData("DE:ab 6", "DE", 6, null)] + [InlineData("–12", "FR", 12, null)] // The CNC writes its minimum ages with an en dash + [InlineData("–16", "FR", 16, null)] + [InlineData("–18", "FR", 18, null)] + [InlineData("SU", "ID", 0, null)] // Indonesian broadcast classifications (KPI) + [InlineData("P", "ID", 2, null)] + [InlineData("A", "ID", 7, null)] + [InlineData("R", "ID", 13, null)] + [InlineData("D", "ID", 18, null)] + [InlineData("D18+", "ID", 18, null)] // Written with the minimum age, as broadcast since 2016 + [InlineData("R-BO", "ID", 13, null)] // Parental guidance does not change the age group + [InlineData("Dewasa", "ID", 18, null)] + [InlineData("ID-D", "ID", 18, null)] // TMDB style country prefix + [InlineData("ID-D", "US", 18, null)] // Country prefix resolved via the separator fallback + [InlineData("P", "US", 1000, null)] // Ambiguous outside Indonesia, preferred as the Portuguese "Pornográfico" public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration() @@ -253,6 +285,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization [InlineData("12", 12, null)] [InlineData("42", 42, null)] [InlineData("9999", 9999, null)] + // The French CNC writes minimum ages as "-12" ("not for under 12s"). Parsing that as -12 would + // put the item below every MaxParentalRatingScore and bypass parental control entirely. + [InlineData("-10", 10, null)] + [InlineData("-12", 12, null)] + [InlineData("-16", 16, null)] + [InlineData("-18", 18, null)] public async Task GetRatingLevel_GivenValidAge_Success(string value, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" }); @@ -323,6 +361,62 @@ namespace Jellyfin.Server.Implementations.Tests.Localization } [Theory] + // Providers list every spelling of a rating in a single field. Splitting such a list by its country + // prefix pairs the first entry's country with the last entry's rating, so it has to be split by '/' first. + [InlineData("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18", "de", 18, null)] + [InlineData("SE:15 / SE:15+ / SE:Från 15 år", "de", 15, null)] + [InlineData("FR:16 / US:12", "de", 16, null)] // The first entry that resolves wins + public async Task GetRatingScore_CountryPrefixedList_UsesFirstResolvingEntry(string value, string countryCode, int expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = countryCode }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(value); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + + [Fact] + public async Task GetRatingScore_ResolvedCountryPrefixedList_DoesNotWarn() + { + var logger = new Mock<ILogger<LocalizationManager>>(); + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object); + await localizationManager.LoadAll(); + + Assert.NotNull(localizationManager.GetRatingScore("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18")); + + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + [Fact] + public async Task GetRatingScore_ListWithoutKnownRating_WarnsOnce() + { + var logger = new Mock<ILogger<LocalizationManager>>(); + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object); + await localizationManager.LoadAll(); + + Assert.Null(localizationManager.GetRatingScore("DE:Unbekannt / DE:Unsinn")); + + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Once); + } + + [Theory] [InlineData("-NO RATING SHOWN-")] [InlineData(":NO RATING SHOWN:")] public async Task GetRatingLevel_Split_Success(string value) @@ -525,12 +619,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase)); } - private LocalizationManager Setup(ServerConfiguration config) + private LocalizationManager Setup(ServerConfiguration config, ILogger<LocalizationManager>? logger = null) { var mockConfiguration = new Mock<IServerConfigurationManager>(); mockConfiguration.SetupGet(x => x.Configuration).Returns(config); - return new LocalizationManager(mockConfiguration.Object, new NullLogger<LocalizationManager>()); + return new LocalizationManager(mockConfiguration.Object, logger ?? new NullLogger<LocalizationManager>()); } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs index a07e79baa3..13081dcff9 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs @@ -34,7 +34,7 @@ public class PlayCommandQueueTests : IDisposable /// folder, so the queue query cannot exclude it. Such an item has no media sources, and a /// client that reaches it in the queue gets an error instead of the next track. /// </summary> - /// <returns><placeholder>A <see cref="Task"/> representing the asynchronous unit test.</placeholder></returns> + /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> [Fact] public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems() { diff --git a/tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs b/tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs new file mode 100644 index 0000000000..a6ebc82425 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using System.Xml.Serialization; +using Emby.Server.Implementations; +using Jellyfin.Server.Migrations.PreStartupRoutines; +using Jellyfin.Server.Migrations.Routines; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +public sealed class FixNullEncoderPresetTests : IDisposable +{ + private readonly string _configurationDirectory; + private readonly string _encodingConfigurationPath; + private readonly FixNullEncoderPreset _migration; + + public FixNullEncoderPresetTests() + { + _configurationDirectory = Directory.CreateTempSubdirectory("jellyfin-migration-test-").FullName; + _encodingConfigurationPath = Path.Combine(_configurationDirectory, "encoding.xml"); + + var applicationPaths = new Mock<IApplicationPaths>(); + applicationPaths.SetupGet(paths => paths.ConfigurationDirectoryPath).Returns(_configurationDirectory); + _migration = new FixNullEncoderPreset(applicationPaths.Object, NullLogger<FixNullEncoderPreset>.Instance); + } + + [Fact] + public async Task PerformAsync_ReplacesNullEncoderPresetWithAuto() + { + const string Configuration = """ + <?xml version="1.0" encoding="utf-8"?> + <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <HardwareAccelerationType>qsv</HardwareAccelerationType> + <EncoderPreset xsi:nil="true" /> + <EnableHardwareEncoding>true</EnableHardwareEncoding> + </EncodingOptions> + """; + await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken); + + await _migration.PerformAsync(TestContext.Current.CancellationToken); + + var serializer = new XmlSerializer(typeof(EncodingOptions)); + using var reader = File.OpenRead(_encodingConfigurationPath); + var encodingOptions = Assert.IsType<EncodingOptions>(serializer.Deserialize(reader)); + Assert.Equal(EncoderPreset.auto, encodingOptions.EncoderPreset); + Assert.Equal(HardwareAccelerationType.qsv, encodingOptions.HardwareAccelerationType); + Assert.True(encodingOptions.EnableHardwareEncoding); + } + + [Fact] + public async Task PerformAsync_LeavesValidEncoderPresetUnchanged() + { + const string Configuration = """ + <?xml version="1.0" encoding="utf-8"?> + <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <EncoderPreset>fast</EncoderPreset> + </EncodingOptions> + """; + await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken); + + await _migration.PerformAsync(TestContext.Current.CancellationToken); + + Assert.Equal(Configuration, await File.ReadAllTextAsync(_encodingConfigurationPath, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task PerformAsync_LeavesMalformedConfigurationUnchanged() + { + const string Configuration = "<EncodingOptions><EncoderPreset xsi:nil=\"true\" />"; + await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken); + + await _migration.PerformAsync(TestContext.Current.CancellationToken); + + Assert.Equal(Configuration, await File.ReadAllTextAsync(_encodingConfigurationPath, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task MigrateEncodingOptions_UsesAutoForNullEncoderPreset() + { + const string Configuration = """ + <?xml version="1.0" encoding="utf-8"?> + <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <HardwareAccelerationType>qsv</HardwareAccelerationType> + <TonemappingRange>tv</TonemappingRange> + <EncoderPreset xsi:nil="true" /> + <EnableHardwareEncoding>true</EnableHardwareEncoding> + </EncodingOptions> + """; + await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken); + var applicationPaths = new ServerApplicationPaths( + _configurationDirectory, + _configurationDirectory, + _configurationDirectory, + _configurationDirectory, + _configurationDirectory); + + var migration = new MigrateEncodingOptions(applicationPaths, NullLoggerFactory.Instance); + migration.Perform(); + + var serializer = new XmlSerializer(typeof(EncodingOptions)); + using var reader = File.OpenRead(_encodingConfigurationPath); + var encodingOptions = Assert.IsType<EncodingOptions>(serializer.Deserialize(reader)); + Assert.Equal(EncoderPreset.auto, encodingOptions.EncoderPreset); + Assert.Equal(HardwareAccelerationType.qsv, encodingOptions.HardwareAccelerationType); + Assert.True(encodingOptions.EnableHardwareEncoding); + } + + public void Dispose() + { + Directory.Delete(_configurationDirectory, true); + } +} |
