aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs137
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs7
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs11
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs60
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs10
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs136
-rw-r--r--tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs35
-rw-r--r--tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs107
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs25
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs26
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs134
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs74
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs173
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs158
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs157
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs68
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs89
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs100
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs2
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs118
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&apos;Onofrio\"", "Vincent D'Onofrio")]
+ [InlineData("\"Alan Moore &#39;Swamp Thing&#39;\"", "Alan Moore 'Swamp Thing'")]
+ [InlineData("\"Tom &amp; Jerry\"", "Tom & Jerry")]
+ [InlineData("\"Mail &lt;a@b.com&gt; 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&apos;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&apos;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);
+ }
+}