aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs99
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs258
-rw-r--r--tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs397
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs64
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs282
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs57
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json162
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json56
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs22
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs843
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs106
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkParseTests.cs97
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs3
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs18
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs131
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs59
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs93
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs240
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs1
21 files changed, 2543 insertions, 449 deletions
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs
new file mode 100644
index 0000000000..2dcb898051
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Globalization;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Configuration;
+using Moq;
+using Xunit;
+using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding
+{
+ public class EncodingHelperAudioBitStreamTests
+ {
+ private const string BothFilters = " -bsf:a noise=drop='lt(pts*tb\\,63.063)',aac_adtstoasc";
+ private const string NoiseOnly = " -bsf:a noise=drop='lt(pts*tb\\,63.063)'";
+ private const string AdtsOnly = " -bsf:a aac_adtstoasc";
+ private const long DefaultSeekTicks = 630_630_000L;
+ private const string DefaultFfmpegVersion = "5.0";
+
+ private static EncodingHelper CreateHelper(string ffmpegVersion)
+ {
+ var mediaEncoder = new Mock<IMediaEncoder>();
+ mediaEncoder
+ .Setup(e => e.GetTimeParameter(It.IsAny<long>()))
+ .Returns((long ticks) => TimeSpan.FromTicks(ticks).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture));
+ mediaEncoder
+ .SetupGet(e => e.EncoderVersion)
+ .Returns(Version.Parse(ffmpegVersion));
+
+ return new EncodingHelper(
+ Mock.Of<IApplicationPaths>(),
+ mediaEncoder.Object,
+ Mock.Of<ISubtitleEncoder>(),
+ Mock.Of<IConfiguration>(),
+ Mock.Of<IConfigurationManager>(),
+ Mock.Of<IPathManager>());
+ }
+
+ private static EncodingJobInfo CreateState(
+ TranscodingJobType jobType,
+ string outputVideoCodec,
+ string outputAudioCodec,
+ string audioStreamCodec,
+ string inputContainer,
+ long startTimeTicks)
+ {
+ return new EncodingJobInfo(jobType)
+ {
+ IsVideoRequest = true,
+ OutputVideoCodec = outputVideoCodec,
+ OutputAudioCodec = outputAudioCodec,
+ InputContainer = inputContainer,
+ RunTimeTicks = TimeSpan.FromMinutes(10).Ticks,
+ AudioStream = new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ Codec = audioStreamCodec
+ },
+ BaseRequest = new BaseEncodingJobOptions
+ {
+ StartTimeTicks = startTimeTicks
+ }
+ };
+ }
+
+ [Theory]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", BothFilters)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "aac", BothFilters)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "hls", BothFilters)]
+ [InlineData(TranscodingJobType.Progressive, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "copy", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "aac", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "wtv", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", 0L, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, "4.4.6", "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "ts", "ts", NoiseOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "mkv", NoiseOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "ac3", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", NoiseOnly)]
+ public void AudioBitStreamArguments_AppliesGates(
+ TranscodingJobType jobType,
+ string outputVideoCodec,
+ string outputAudioCodec,
+ string audioStreamCodec,
+ string inputContainer,
+ long startTicks,
+ string ffmpegVersion,
+ string segmentContainer,
+ string mediaSourceContainer,
+ string expected)
+ {
+ var state = CreateState(jobType, outputVideoCodec, outputAudioCodec, audioStreamCodec, inputContainer, startTicks);
+ var result = CreateHelper(ffmpegVersion).GetAudioBitStreamArguments(state, segmentContainer, mediaSourceContainer);
+ Assert.Equal(expected, result);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
new file mode 100644
index 0000000000..d7ae6a8a18
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
@@ -0,0 +1,258 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Streaming;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using Moq;
+using Xunit;
+
+using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding;
+
+public class EncodingHelperTests
+{
+ [Fact]
+ public void GetMapArgs_NoSubtitle_ExcludesAllSubs()
+ {
+ var state = BuildState(subtitle: null, deliveryMethod: null);
+ var args = CreateHelper().GetMapArgs(state);
+
+ Assert.Contains("-map -0:s", args, StringComparison.Ordinal);
+ Assert.DoesNotContain("-map 1:", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetMapArgs_InternalSrt_MapsFromPrimaryInput()
+ {
+ var sub = new MediaStream { Index = 2, Type = MediaStreamType.Subtitle, Codec = "srt" };
+ var state = BuildState(sub, SubtitleDeliveryMethod.Embed);
+ var args = CreateHelper().GetMapArgs(state);
+
+ Assert.Contains("-map 0:2", args, StringComparison.Ordinal);
+ Assert.DoesNotContain("-map 1:", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetMapArgs_InternalSubAtHigherIndex_MapsCorrectIndex()
+ {
+ var sub0 = new MediaStream { Index = 2, Type = MediaStreamType.Subtitle, Codec = "srt" };
+ var sub1 = new MediaStream { Index = 3, Type = MediaStreamType.Subtitle, Codec = "ass" };
+ var state = BuildState(sub1, SubtitleDeliveryMethod.Embed, additionalStreams: [sub0, sub1]);
+ var args = CreateHelper().GetMapArgs(state);
+
+ Assert.Contains("-map 0:3", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetMapArgs_ExternalSrt_MapsFirstStreamFromInput1()
+ {
+ var sub = new MediaStream
+ {
+ Index = 2,
+ Type = MediaStreamType.Subtitle,
+ Codec = "srt",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = "/media/movie.en.srt"
+ };
+ var state = BuildState(sub, SubtitleDeliveryMethod.Embed);
+ var args = CreateHelper().GetMapArgs(state);
+
+ Assert.Contains("-map 1:0", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetMapArgs_SecondExternalSrt_StillMaps1Colon0()
+ {
+ // Two separate .srt files — selecting the second one still maps 1:0
+ // because Jellyfin feeds only the selected file as ffmpeg input 1.
+ var ext1 = new MediaStream
+ {
+ Index = 2,
+ Type = MediaStreamType.Subtitle,
+ Codec = "srt",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = "/media/movie.en.srt"
+ };
+ var ext2 = new MediaStream
+ {
+ Index = 3,
+ Type = MediaStreamType.Subtitle,
+ Codec = "srt",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = "/media/movie.fr.srt"
+ };
+ var state = BuildState(ext2, SubtitleDeliveryMethod.Embed, additionalStreams: [ext1, ext2]);
+ var args = CreateHelper().GetMapArgs(state);
+
+ Assert.Contains("-map 1:0", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetMapArgs_MksFirstTrack_MapsInFileIndex0()
+ {
+ var mks0 = new MediaStream
+ {
+ Index = 2,
+ Type = MediaStreamType.Subtitle,
+ Codec = "subrip",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = "/media/movie.mks"
+ };
+ var mks1 = new MediaStream
+ {
+ Index = 3,
+ Type = MediaStreamType.Subtitle,
+ Codec = "ass",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = "/media/movie.mks"
+ };
+ var state = BuildState(mks0, SubtitleDeliveryMethod.Embed, additionalStreams: [mks0, mks1]);
+ var args = CreateHelper().GetMapArgs(state);
+
+ Assert.Contains("-map 1:0", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetMapArgs_MksSecondTrack_MapsInFileIndex1()
+ {
+ var mks0 = new MediaStream
+ {
+ Index = 2,
+ Type = MediaStreamType.Subtitle,
+ Codec = "subrip",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = "/media/movie.mks"
+ };
+ var mks1 = new MediaStream
+ {
+ Index = 3,
+ Type = MediaStreamType.Subtitle,
+ Codec = "ass",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = "/media/movie.mks"
+ };
+ var mks2 = new MediaStream
+ {
+ Index = 4,
+ Type = MediaStreamType.Subtitle,
+ Codec = "subrip",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = "/media/movie.mks"
+ };
+ var state = BuildState(mks1, SubtitleDeliveryMethod.Embed, additionalStreams: [mks0, mks1, mks2]);
+ var args = CreateHelper().GetMapArgs(state);
+
+ Assert.Contains("-map 1:1", args, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(SubtitleDeliveryMethod.Embed, true, "movie.idx")]
+ [InlineData(SubtitleDeliveryMethod.Encode, true, "movie.idx")]
+ [InlineData(SubtitleDeliveryMethod.Embed, false, "movie.sub")]
+ [InlineData(SubtitleDeliveryMethod.Encode, false, "movie.sub")]
+ public void GetInputArgument_VobSub_UsesCorrectPath(
+ SubtitleDeliveryMethod deliveryMethod,
+ bool createIdxFile,
+ string expectedFilename)
+ {
+ var tempDir = Directory.CreateTempSubdirectory("jellyfin-test-");
+ try
+ {
+ var subFile = Path.Combine(tempDir.FullName, "movie.sub");
+ File.WriteAllText(subFile, "dummy");
+
+ if (createIdxFile)
+ {
+ File.WriteAllText(Path.Combine(tempDir.FullName, "movie.idx"), "dummy");
+ }
+
+ var sub = new MediaStream
+ {
+ Index = 2,
+ Type = MediaStreamType.Subtitle,
+ Codec = "dvdsub",
+ IsExternal = true,
+ SupportsExternalStream = true,
+ Path = subFile
+ };
+ var state = BuildState(sub, deliveryMethod);
+ var inputArgs = CreateHelper().GetInputArgument(state, new EncodingOptions(), null);
+
+ Assert.Contains(expectedFilename, inputArgs, StringComparison.Ordinal);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ private static EncodingJobInfo BuildState(
+ MediaStream? subtitle,
+ SubtitleDeliveryMethod? deliveryMethod,
+ MediaStream[]? additionalStreams = null)
+ {
+ var video = new MediaStream { Index = 0, Type = MediaStreamType.Video, Codec = "h264" };
+ var audio = new MediaStream { Index = 1, Type = MediaStreamType.Audio, Codec = "aac" };
+ var streams = new List<MediaStream> { video, audio };
+
+ if (additionalStreams is not null)
+ {
+ streams.AddRange(additionalStreams);
+ }
+ else if (subtitle is not null)
+ {
+ streams.Add(subtitle);
+ }
+
+ return new EncodingJobInfo(TranscodingJobType.Progressive)
+ {
+ MediaSource = new MediaSourceInfo
+ {
+ Container = "mkv",
+ MediaStreams = streams,
+ },
+ VideoStream = video,
+ AudioStream = audio,
+ SubtitleStream = subtitle,
+ SubtitleDeliveryMethod = deliveryMethod ?? SubtitleDeliveryMethod.Drop,
+ BaseRequest = new VideoRequestDto(),
+ IsVideoRequest = true,
+ IsInputVideo = true,
+ };
+ }
+
+ private static EncodingHelper CreateHelper()
+ {
+ var appPaths = Mock.Of<IApplicationPaths>();
+ var mediaEncoder = new Mock<IMediaEncoder>();
+ var subtitleEncoder = new Mock<ISubtitleEncoder>();
+ var config = new Mock<IConfiguration>();
+ var configurationManager = new Mock<IConfigurationManager>();
+ var pathManager = new Mock<IPathManager>();
+
+ return new EncodingHelper(
+ appPaths,
+ mediaEncoder.Object,
+ subtitleEncoder.Object,
+ config.Object,
+ configurationManager.Object,
+ pathManager.Object);
+ }
+}
diff --git a/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs
new file mode 100644
index 0000000000..cdbf2f8b1d
--- /dev/null
+++ b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs
@@ -0,0 +1,397 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Jellyfin.Extensions.Tests;
+
+public class StreamExtensionsTests
+{
+ [Fact]
+ public async Task IsStreamIdenticalAsync_SeekableDifferentLengths_ReturnsFalse()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new MemoryStream(new byte[] { 1, 2, 3 });
+ await using var b = new MemoryStream(new byte[] { 1, 2, 3, 4 });
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_NonSeekableIdenticalStreams_ReturnsTrue()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+ await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_NonSeekableDifferentStreams_ReturnsFalse()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+ await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 9, 4 });
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task IsFileIdenticalAsync_NonSeekableStream_ThrowsArgumentException()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
+ await File.WriteAllBytesAsync(path, new byte[] { 1, 2, 3, 4 }, cancellationToken);
+
+ try
+ {
+ await using var stream = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+
+ await Assert.ThrowsAsync<ArgumentException>(async () =>
+ await stream.IsFileIdenticalAsync(path, cancellationToken));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ // Both publiclyVisible values are exercised so the test runs once under the fast path
+ // (TryGetBuffer succeeds) and once under the slow path (TryGetBuffer returns false).
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsFileIdenticalAsync_UsesStartOfStreamAndRestoresPosition_OnMatch(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
+ var bytes = new byte[] { 10, 20, 30, 40, 50 };
+ await File.WriteAllBytesAsync(path, bytes, cancellationToken);
+
+ try
+ {
+ await using var stream = CreateMemoryStream(bytes, publiclyVisible);
+ stream.Position = 3;
+
+ var result = await stream.IsFileIdenticalAsync(path, cancellationToken);
+
+ Assert.True(result);
+ Assert.Equal(3, stream.Position);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsFileIdenticalAsync_RestoresPosition_OnMismatch(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
+ await File.WriteAllBytesAsync(path, new byte[] { 10, 20, 30, 40, 99 }, cancellationToken);
+
+ try
+ {
+ await using var stream = CreateMemoryStream(new byte[] { 10, 20, 30, 40, 50 }, publiclyVisible);
+ stream.Position = 2;
+
+ var result = await stream.IsFileIdenticalAsync(path, cancellationToken);
+
+ Assert.False(result);
+ Assert.Equal(2, stream.Position);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsStreamIdenticalAsync_BothMemoryStreams_NonZeroPositions_SeeksToStart(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible);
+ await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible);
+ a.Position = 3;
+ b.Position = 1;
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsStreamIdenticalAsync_MemoryStreamPairedWithSeekableNonMemoryStream_NonZeroPositions_SeeksToStart(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible);
+ await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
+ a.Position = 2;
+ b.Position = 3;
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsStreamIdenticalAsync_NonMemoryStreamPairedWithMemoryStream_Swaps_ReturnsTrue(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
+ await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible);
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_BothSeekableNonMemoryStreams_NonZeroPositions_SeeksToStart()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
+ await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
+ a.Position = 1;
+ b.Position = 2;
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_NonSeekableShortReads_Identical_ReturnsTrue()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
+ await using var a = new ShortReadingNonSeekableStream(data, maxReadSize: 3);
+ await using var b = new ShortReadingNonSeekableStream(data, maxReadSize: 5);
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_NonSeekableShortReads_DifferentLengths_ReturnsFalse()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4 }, maxReadSize: 3);
+ await using var b = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4, 5 }, maxReadSize: 5);
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.False(result);
+ }
+
+ private static MemoryStream CreateMemoryStream(byte[] data, bool publiclyVisible)
+ => publiclyVisible
+ ? new MemoryStream(data, 0, data.Length, writable: false, publiclyVisible: true)
+ : new MemoryStream(data);
+
+ private sealed class NonSeekableReadStream : Stream
+ {
+ private readonly Stream _inner;
+
+ public NonSeekableReadStream(byte[] data)
+ {
+ _inner = new MemoryStream(data, writable: false);
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ => _inner.Read(buffer, offset, count);
+
+ public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
+ => _inner.ReadAsync(buffer, cancellationToken);
+
+ public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+
+ public override long Seek(long offset, SeekOrigin origin)
+ => throw new NotSupportedException();
+
+ public override void SetLength(long value)
+ => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await _inner.DisposeAsync();
+ await base.DisposeAsync();
+ }
+ }
+
+ private sealed class SeekableNonMemoryStream : Stream
+ {
+ private readonly MemoryStream _inner;
+
+ public SeekableNonMemoryStream(byte[] data)
+ {
+ _inner = new MemoryStream(data, writable: false);
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => true;
+
+ public override bool CanWrite => false;
+
+ public override long Length => _inner.Length;
+
+ public override long Position
+ {
+ get => _inner.Position;
+ set => _inner.Position = value;
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ => _inner.Read(buffer, offset, count);
+
+ public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
+ => _inner.ReadAsync(buffer, cancellationToken);
+
+ public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+
+ public override long Seek(long offset, SeekOrigin origin)
+ => _inner.Seek(offset, origin);
+
+ public override void SetLength(long value)
+ => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await _inner.DisposeAsync();
+ await base.DisposeAsync();
+ }
+ }
+
+ private sealed class ShortReadingNonSeekableStream : Stream
+ {
+ private readonly Stream _inner;
+ private readonly int _maxReadSize;
+
+ public ShortReadingNonSeekableStream(byte[] data, int maxReadSize)
+ {
+ _inner = new MemoryStream(data, writable: false);
+ _maxReadSize = maxReadSize;
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ => _inner.Read(buffer, offset, Math.Min(count, _maxReadSize));
+
+ public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
+ => _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maxReadSize)], cancellationToken);
+
+ public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => _inner.ReadAsync(buffer.AsMemory(offset, Math.Min(count, _maxReadSize)), cancellationToken).AsTask();
+
+ public override long Seek(long offset, SeekOrigin origin)
+ => throw new NotSupportedException();
+
+ public override void SetLength(long value)
+ => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await _inner.DisposeAsync();
+ await base.DisposeAsync();
+ }
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs b/tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs
new file mode 100644
index 0000000000..14ce470fb4
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Threading.Tasks;
+using System.Xml;
+using Jellyfin.Extensions;
+using Jellyfin.LiveTv.Recordings;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests.Recordings;
+
+public sealed class RecordingsMetadataManagerTests
+{
+ private readonly string _tempDir =
+ Path.Combine(Path.GetTempPath(), "jellyfin-test-" + Guid.NewGuid());
+
+ [Fact]
+ public async Task SaveRecordingMetadata_DateAddedIsUtc()
+ {
+ Directory.CreateDirectory(_tempDir);
+ var recordingPath = Path.Combine(_tempDir, "test-recording.ts");
+ FileHelper.CreateEmpty(recordingPath);
+
+ var config = new Mock<IConfigurationManager>();
+ config.Setup(c => c.GetConfiguration("livetv"))
+ .Returns(new LiveTvOptions { SaveRecordingNFO = true, SaveRecordingImages = false });
+ config.Setup(c => c.GetConfiguration("xbmcmetadata"))
+ .Returns(new XbmcMetadataOptions());
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager
+ .Setup(l => l.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(Array.Empty<BaseItem>());
+
+ var manager = new RecordingsMetadataManager(
+ NullLogger<RecordingsMetadataManager>.Instance,
+ config.Object,
+ libraryManager.Object);
+
+ var timer = new TimerInfo { Name = "Test Recording", ProgramId = null };
+
+ var beforeUtc = DateTime.UtcNow.AddSeconds(-2);
+ await manager.SaveRecordingMetadata(timer, recordingPath, null);
+ var afterUtc = DateTime.UtcNow.AddSeconds(2);
+
+ var doc = new XmlDocument();
+ doc.Load(Path.ChangeExtension(recordingPath, ".nfo"));
+ var dateAddedText = doc.SelectSingleNode("//dateadded")?.InnerText ?? string.Empty;
+ var parsed = DateTime.ParseExact(
+ dateAddedText,
+ "yyyy-MM-dd HH:mm:ss",
+ CultureInfo.InvariantCulture);
+
+ Assert.InRange(parsed, beforeUtc, afterUtc);
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs
deleted file mode 100644
index 5f84e85592..0000000000
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs
+++ /dev/null
@@ -1,282 +0,0 @@
-using System;
-using AutoFixture;
-using AutoFixture.AutoMoq;
-using MediaBrowser.MediaEncoding.Subtitles;
-using MediaBrowser.Model.MediaInfo;
-using Xunit;
-
-namespace Jellyfin.MediaEncoding.Subtitles.Tests
-{
- public class FilterEventsTests
- {
- private readonly SubtitleEncoder _encoder;
-
- public FilterEventsTests()
- {
- var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
- _encoder = fixture.Create<SubtitleEncoder>();
- }
-
- [Fact]
- public void FilterEvents_SubtitleSpanningSegmentBoundary_IsRetained()
- {
- // Subtitle starts at 5s, ends at 15s.
- // Segment requested from 10s to 20s.
- // The subtitle is still on screen at 10s and should NOT be dropped.
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Still on screen")
- {
- StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- },
- new SubtitleTrackEvent("2", "Next subtitle")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: true);
-
- Assert.Equal(2, track.TrackEvents.Count);
- Assert.Equal("1", track.TrackEvents[0].Id);
- Assert.Equal("2", track.TrackEvents[1].Id);
- }
-
- [Fact]
- public void FilterEvents_SubtitleFullyBeforeSegment_IsDropped()
- {
- // Subtitle starts at 2s, ends at 5s.
- // Segment requested from 10s.
- // The subtitle ended before the segment — should be dropped.
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Already gone")
- {
- StartPositionTicks = TimeSpan.FromSeconds(2).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(5).Ticks
- },
- new SubtitleTrackEvent("2", "Visible")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: true);
-
- Assert.Single(track.TrackEvents);
- Assert.Equal("2", track.TrackEvents[0].Id);
- }
-
- [Fact]
- public void FilterEvents_SubtitleAfterSegment_IsDropped()
- {
- // Segment is 10s-20s, subtitle starts at 25s.
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "In range")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- },
- new SubtitleTrackEvent("2", "After segment")
- {
- StartPositionTicks = TimeSpan.FromSeconds(25).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(30).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: true);
-
- Assert.Single(track.TrackEvents);
- Assert.Equal("1", track.TrackEvents[0].Id);
- }
-
- [Fact]
- public void FilterEvents_PreserveTimestampsFalse_AdjustsTimestamps()
- {
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Subtitle")
- {
- StartPositionTicks = TimeSpan.FromSeconds(15).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(20).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(30).Ticks,
- preserveTimestamps: false);
-
- Assert.Single(track.TrackEvents);
- // Timestamps should be shifted back by 10s
- Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].StartPositionTicks);
- Assert.Equal(TimeSpan.FromSeconds(10).Ticks, track.TrackEvents[0].EndPositionTicks);
- }
-
- [Fact]
- public void FilterEvents_PreserveTimestampsTrue_KeepsOriginalTimestamps()
- {
- var startTicks = TimeSpan.FromSeconds(15).Ticks;
- var endTicks = TimeSpan.FromSeconds(20).Ticks;
-
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Subtitle")
- {
- StartPositionTicks = startTicks,
- EndPositionTicks = endTicks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(30).Ticks,
- preserveTimestamps: true);
-
- Assert.Single(track.TrackEvents);
- Assert.Equal(startTicks, track.TrackEvents[0].StartPositionTicks);
- Assert.Equal(endTicks, track.TrackEvents[0].EndPositionTicks);
- }
-
- [Fact]
- public void FilterEvents_SubtitleEndingExactlyAtSegmentStart_IsRetained()
- {
- // Subtitle ends exactly when the segment begins.
- // EndPositionTicks == startPositionTicks means (end - start) == 0, not < 0,
- // so SkipWhile stops and the subtitle is retained.
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Boundary subtitle")
- {
- StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(10).Ticks
- },
- new SubtitleTrackEvent("2", "In range")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: true);
-
- Assert.Equal(2, track.TrackEvents.Count);
- Assert.Equal("1", track.TrackEvents[0].Id);
- }
-
- [Fact]
- public void FilterEvents_SpanningBoundaryWithTimestampAdjustment_DoesNotProduceNegativeTimestamps()
- {
- // Subtitle starts at 5s, ends at 15s.
- // Segment requested from 10s to 20s, preserveTimestamps = false.
- // The subtitle spans the boundary and is retained, but shifting
- // StartPositionTicks by -10s would produce -5s (negative).
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Spans boundary")
- {
- StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- },
- new SubtitleTrackEvent("2", "Fully in range")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: false);
-
- Assert.Equal(2, track.TrackEvents.Count);
- // Subtitle 1: start should be clamped to 0, not -5s
- Assert.True(track.TrackEvents[0].StartPositionTicks >= 0, "StartPositionTicks must not be negative");
- Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].EndPositionTicks);
- // Subtitle 2: normal shift (12s - 10s = 2s, 17s - 10s = 7s)
- Assert.Equal(TimeSpan.FromSeconds(2).Ticks, track.TrackEvents[1].StartPositionTicks);
- Assert.Equal(TimeSpan.FromSeconds(7).Ticks, track.TrackEvents[1].EndPositionTicks);
- }
-
- [Fact]
- public void FilterEvents_NoEndTimeTicks_ReturnsAllFromStartPosition()
- {
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Before")
- {
- StartPositionTicks = TimeSpan.FromSeconds(2).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(4).Ticks
- },
- new SubtitleTrackEvent("2", "After")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- },
- new SubtitleTrackEvent("3", "Much later")
- {
- StartPositionTicks = TimeSpan.FromSeconds(500).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(505).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: 0,
- preserveTimestamps: true);
-
- Assert.Equal(2, track.TrackEvents.Count);
- Assert.Equal("2", track.TrackEvents[0].Id);
- Assert.Equal("3", track.TrackEvents[1].Id);
- }
- }
-}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 8269ae58cd..16c586bcda 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -171,6 +171,9 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9
+ [InlineData("AndroidTVExoPlayer", "mp4-hevc-aac-4000k-r180", PlayMethod.DirectPlay)] // #13712
+ // AndroidTV NoHevcRotation
+ [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712
// Tizen 3 Stereo
[InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)]
[InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)]
@@ -672,5 +675,59 @@ namespace Jellyfin.Model.Tests
Assert.Equal(expectedMethod, result.Method);
}
+
+ [Theory]
+ // External text subs embedded into MKV when transcoding (#16403)
+ [InlineData("srt", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ [InlineData("ass", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ // External graphical subs embedded into MKV when transcoding
+ [InlineData("pgssub", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ [InlineData("dvdsub", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ // External subs remain external when transcoding to non-MKV containers
+ [InlineData("srt", true, PlayMethod.Transcode, "mp4", MediaStreamProtocol.hls, SubtitleDeliveryMethod.External)]
+ [InlineData("srt", true, PlayMethod.Transcode, "ts", MediaStreamProtocol.hls, SubtitleDeliveryMethod.External)]
+ // External subs remain external during DirectPlay even with MKV
+ [InlineData("srt", true, PlayMethod.DirectPlay, "mkv", null, SubtitleDeliveryMethod.External)]
+ // Internal subs still embedded into MKV when transcoding (existing behavior)
+ [InlineData("srt", false, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ [InlineData("pgssub", false, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ public void GetSubtitleProfile_ReturnsExpectedDeliveryMethod(
+ string codec,
+ bool isExternal,
+ PlayMethod playMethod,
+ string outputContainer,
+ MediaStreamProtocol? transcodingSubProtocol,
+ SubtitleDeliveryMethod expectedMethod)
+ {
+ var mediaSource = new MediaSourceInfo();
+ var subtitleStream = new MediaStream
+ {
+ Codec = codec,
+ Language = "eng",
+ IsExternal = isExternal,
+ Type = MediaStreamType.Subtitle,
+ SupportsExternalStream = true
+ };
+
+ var subtitleProfiles = new[]
+ {
+ new SubtitleProfile { Format = codec, Method = SubtitleDeliveryMethod.Embed },
+ new SubtitleProfile { Format = codec, Method = SubtitleDeliveryMethod.External }
+ };
+
+ var transcoderSupport = new Mock<ITranscoderSupport>();
+ transcoderSupport.Setup(x => x.CanExtractSubtitles(It.IsAny<string>())).Returns(true);
+
+ var result = StreamBuilder.GetSubtitleProfile(
+ mediaSource,
+ subtitleStream,
+ subtitleProfiles,
+ playMethod,
+ transcoderSupport.Object,
+ outputContainer,
+ transcodingSubProtocol);
+
+ Assert.Equal(expectedMethod, result.Method);
+ }
}
}
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json
new file mode 100644
index 0000000000..341638bc52
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json
@@ -0,0 +1,162 @@
+{
+ "Name": "Jellyfin AndroidTV-ExoPlayer",
+ "EnableAlbumArtInDidl": false,
+ "EnableSingleAlbumArtLimit": false,
+ "EnableSingleSubtitleLimit": false,
+ "SupportedMediaTypes": "Audio,Photo,Video",
+ "MaxAlbumArtWidth": 0,
+ "MaxAlbumArtHeight": 0,
+ "MaxStreamingBitrate": 120000000,
+ "MaxStaticBitrate": 100000000,
+ "MusicStreamingTranscodingBitrate": 192000,
+ "TimelineOffsetSeconds": 0,
+ "RequiresPlainVideoItems": false,
+ "RequiresPlainFolders": false,
+ "EnableMSMediaReceiverRegistrar": false,
+ "IgnoreTranscodeByteRangeRequests": false,
+ "DirectPlayProfiles": [
+ {
+ "Container": "m4v,mov,xvid,vob,mkv,wmv,asf,ogm,ogv,mp4,webm",
+ "AudioCodec": "aac,mp3,mp2,aac_latm,alac,ac3,eac3,dca,dts,mlp,truehd,pcm_alaw,pcm_mulaw",
+ "VideoCodec": "h264,hevc,vp8,vp9,mpeg,mpeg2video",
+ "Type": "Video",
+ "$type": "DirectPlayProfile"
+ },
+ {
+ "Container": "aac,mp3,mp2,aac_latm,alac,ac3,eac3,dca,dts,mlp,truehd,pcm_alaw,pcm_mulaw,,pa,flac,wav,wma,ogg,oga,webma,ape,opus",
+ "Type": "Audio",
+ "$type": "DirectPlayProfile"
+ },
+ {
+ "Container": "jpg,jpeg,png,gif,web",
+ "Type": "Photo",
+ "$type": "DirectPlayProfile"
+ }
+ ],
+ "CodecProfiles": [
+ {
+ "Type": "Video",
+ "Conditions": [
+ {
+ "Condition": "EqualsAny",
+ "Property": "VideoProfile",
+ "Value": "main|main 10",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ },
+ {
+ "Condition": "LessThanEqual",
+ "Property": "VideoLevel",
+ "Value": "51",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ },
+ {
+ "Condition": "Equals",
+ "Property": "VideoRotation",
+ "Value": "0",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ }
+ ],
+ "Codec": "hevc",
+ "$type": "CodecProfile"
+ }
+ ],
+ "TranscodingProfiles": [
+ {
+ "Container": "ts",
+ "Type": "Video",
+ "VideoCodec": "h264",
+ "AudioCodec": "aac,mp3",
+ "Protocol": "hls",
+ "EstimateContentLength": false,
+ "EnableMpegtsM2TsMode": false,
+ "TranscodeSeekInfo": "Auto",
+ "CopyTimestamps": false,
+ "Context": "Streaming",
+ "EnableSubtitlesInManifest": false,
+ "MinSegments": 0,
+ "SegmentLength": 0,
+ "$type": "TranscodingProfile"
+ },
+ {
+ "Container": "mp3",
+ "Type": "Audio",
+ "AudioCodec": "mp3",
+ "Protocol": "http",
+ "EstimateContentLength": false,
+ "EnableMpegtsM2TsMode": false,
+ "TranscodeSeekInfo": "Auto",
+ "CopyTimestamps": false,
+ "Context": "Streaming",
+ "EnableSubtitlesInManifest": false,
+ "MinSegments": 0,
+ "SegmentLength": 0,
+ "$type": "TranscodingProfile"
+ }
+ ],
+ "SubtitleProfiles": [
+ {
+ "Format": "srt",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "srt",
+ "Method": "External",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "subrip",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "subrip",
+ "Method": "External",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "ass",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "ssa",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "pgs",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "pgssub",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "dvdsub",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "vtt",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "sub",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "idx",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ }
+ ],
+ "$type": "DeviceProfile"
+}
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json
new file mode 100644
index 0000000000..393b10171d
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json
@@ -0,0 +1,56 @@
+{
+ "Id": "b7a9e2d4c815f36b0d9241a7e58c3f42",
+ "Path": "/Media/MyVideo-1080p.mp4",
+ "Container": "mov,mp4,m4a,3gp,3g2,mj2",
+ "Size": 1421636271,
+ "Name": "MyVideo-1080p",
+ "ETag": "d8e2a1b5c4f907e8a1d2b3c4e5f6a7b8",
+ "RunTimeTicks": 25801230336,
+ "SupportsTranscoding": true,
+ "SupportsDirectStream": true,
+ "SupportsDirectPlay": true,
+ "SupportsProbing": true,
+ "MediaStreams": [
+ {
+ "Codec": "hevc",
+ "CodecTag": "hvc1",
+ "Language": "eng",
+ "TimeBase": "1/11988",
+ "VideoRange": "SDR",
+ "DisplayTitle": "1080p HEVC SDR",
+ "NalLengthSize": "0",
+ "BitRate": 4014613,
+ "BitDepth": 8,
+ "RefFrames": 1,
+ "IsDefault": true,
+ "Height": 1080,
+ "Width": 1920,
+ "AverageFrameRate": 23.976,
+ "RealFrameRate": 23.976,
+ "Profile": "Main",
+ "Type": 1,
+ "AspectRatio": "16:9",
+ "PixelFormat": "yuv420p",
+ "Level": 50,
+ "Rotation": 180
+ },
+ {
+ "Codec": "aac",
+ "CodecTag": "mp4a",
+ "Language": "eng",
+ "TimeBase": "1/48000",
+ "DisplayTitle": "En - AAC - Stereo - Default",
+ "ChannelLayout": "stereo",
+ "BitRate": 125427,
+ "Channels": 2,
+ "SampleRate": 48000,
+ "IsDefault": true,
+ "Profile": "LC",
+ "Index": 1,
+ "Score": 203
+ }
+ ],
+ "Bitrate": 4331578,
+ "DefaultAudioStreamIndex": 1,
+ "DefaultSubtitleStreamIndex": 2
+}
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
index 4dbe769bf4..2035140f00 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
@@ -83,4 +83,26 @@ public class SeasonPathParserTests
Assert.Equal(seasonNumber, result.SeasonNumber);
Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
}
+
+ [Theory]
+ [InlineData("/Drive/300 Collection/300 (2006)", "/Drive/300 Collection", null, false)]
+ [InlineData("/Drive/300 Collection/300 Rise of an Empire", "/Drive/300 Collection", null, false)]
+ [InlineData("/Drive/300 Collection/1", "/Drive/300 Collection", null, false)]
+ [InlineData("/Drive/300 Collection/300 Disc 1", "/Drive/300 Collection", null, false)]
+ [InlineData("/Drive/28 Years Later Collection/28 Days Later", "/Drive/28 Years Later Collection", null, false)]
+ [InlineData("/Drive/28 Years Later Collection/28 Weeks Later (2007)", "/Drive/28 Years Later Collection", null, false)]
+ [InlineData("/Drive/28 Years Later Collection/28 Years Later 2025", "/Drive/28 Years Later Collection", null, false)]
+ [InlineData("/Drive/300 Collection/Season 1", "/Drive/300 Collection", 1, true)]
+ [InlineData("/Drive/28 Years Later Collection/Season 01", "/Drive/28 Years Later Collection", 1, true)]
+ [InlineData("/Drive/300 Collection/S01", "/Drive/300 Collection", 1, true)]
+ [InlineData("/Drive/300 Collection/S1", "/Drive/300 Collection", 1, true)]
+
+ public void GetSeasonNumberFromPathMixedLibraryTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory)
+ {
+ var result = SeasonPathParser.Parse(path, parentPath, false, false);
+
+ Assert.Equal(result.SeasonNumber is not null, result.Success);
+ Assert.Equal(seasonNumber, result.SeasonNumber);
+ Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
+ }
}
diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
index 2fb45600b1..b29c64f50d 100644
--- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
@@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.Linq;
using Emby.Naming.Common;
using Emby.Naming.Video;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Entities;
using Xunit;
namespace Jellyfin.Naming.Tests.Video
@@ -10,6 +12,12 @@ namespace Jellyfin.Naming.Tests.Video
public class MultiVersionTests
{
private readonly NamingOptions _namingOptions = new NamingOptions();
+ private readonly VideoListResolver _videoListResolver;
+
+ public MultiVersionTests()
+ {
+ _videoListResolver = new VideoListResolver(_namingOptions);
+ }
[Fact]
public void TestMultiEdition1()
@@ -22,9 +30,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result, v => v.ExtraType is null);
Assert.Single(result, v => v.ExtraType is not null);
@@ -41,9 +48,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result, v => v.ExtraType is null);
Assert.Single(result, v => v.ExtraType is not null);
@@ -59,9 +65,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Single(result[0].AlternateVersions);
@@ -81,9 +86,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/M/Movie 7.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(7, result.Count);
Assert.Empty(result[0].AlternateVersions);
@@ -104,9 +108,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Movie/Movie-8.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Equal(7, result[0].AlternateVersions.Count);
@@ -128,9 +131,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Mo/Movie 9.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(9, result.Count);
Assert.Empty(result[0].AlternateVersions);
@@ -148,9 +150,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Movie/Movie 5.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(5, result.Count);
Assert.Empty(result[0].AlternateVersions);
@@ -170,9 +171,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Iron Man/Iron Man (2011).mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(5, result.Count);
Assert.Empty(result[0].AlternateVersions);
@@ -192,19 +192,18 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Iron Man/Iron Man[test].mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Equal("/movies/Iron Man/Iron Man.mkv", result[0].Files[0].Path);
Assert.Equal(6, result[0].AlternateVersions.Count);
- Assert.Equal("/movies/Iron Man/Iron Man-720p.mkv", result[0].AlternateVersions[0].Path);
- Assert.Equal("/movies/Iron Man/Iron Man-3d.mkv", result[0].AlternateVersions[1].Path);
- Assert.Equal("/movies/Iron Man/Iron Man-3d-hsbs.mkv", result[0].AlternateVersions[2].Path);
- Assert.Equal("/movies/Iron Man/Iron Man-bluray.mkv", result[0].AlternateVersions[3].Path);
- Assert.Equal("/movies/Iron Man/Iron Man-test.mkv", result[0].AlternateVersions[4].Path);
- Assert.Equal("/movies/Iron Man/Iron Man[test].mkv", result[0].AlternateVersions[5].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man-720p.mkv", result[0].AlternateVersions[0].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man-3d.mkv", result[0].AlternateVersions[1].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man-3d-hsbs.mkv", result[0].AlternateVersions[2].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man-bluray.mkv", result[0].AlternateVersions[3].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man-test.mkv", result[0].AlternateVersions[4].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man[test].mkv", result[0].AlternateVersions[5].Files[0].Path);
}
[Fact]
@@ -221,19 +220,18 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Iron Man/Iron Man [test].mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Equal("/movies/Iron Man/Iron Man.mkv", result[0].Files[0].Path);
Assert.Equal(6, result[0].AlternateVersions.Count);
- Assert.Equal("/movies/Iron Man/Iron Man - 720p.mkv", result[0].AlternateVersions[0].Path);
- Assert.Equal("/movies/Iron Man/Iron Man - 3d.mkv", result[0].AlternateVersions[1].Path);
- Assert.Equal("/movies/Iron Man/Iron Man - 3d-hsbs.mkv", result[0].AlternateVersions[2].Path);
- Assert.Equal("/movies/Iron Man/Iron Man - bluray.mkv", result[0].AlternateVersions[3].Path);
- Assert.Equal("/movies/Iron Man/Iron Man - test.mkv", result[0].AlternateVersions[4].Path);
- Assert.Equal("/movies/Iron Man/Iron Man [test].mkv", result[0].AlternateVersions[5].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man - 720p.mkv", result[0].AlternateVersions[0].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man - 3d.mkv", result[0].AlternateVersions[1].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man - 3d-hsbs.mkv", result[0].AlternateVersions[2].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man - bluray.mkv", result[0].AlternateVersions[3].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man - test.mkv", result[0].AlternateVersions[4].Files[0].Path);
+ Assert.Equal("/movies/Iron Man/Iron Man [test].mkv", result[0].AlternateVersions[5].Files[0].Path);
}
[Fact]
@@ -245,9 +243,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Iron Man/Iron Man - C (2007).mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
}
@@ -266,17 +263,16 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Iron Man/Iron Man_3d.hsbs.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Equal(6, result[0].AlternateVersions.Count);
// Verify 3D recognition is preserved on alternate versions
- var hsbs = result[0].AlternateVersions.First(v => v.Path.Contains("3d-hsbs", StringComparison.Ordinal));
- Assert.True(hsbs.Is3D);
- Assert.Equal("hsbs", hsbs.Format3D);
+ var hsbs = result[0].AlternateVersions.First(v => v.Files[0].Path.Contains("3d-hsbs", StringComparison.Ordinal));
+ Assert.True(hsbs.Files[0].Is3D);
+ Assert.Equal("hsbs", hsbs.Files[0].Format3D);
}
[Fact]
@@ -293,9 +289,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Iron Man/Iron Man (2011).mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(5, result.Count);
Assert.Empty(result[0].AlternateVersions);
@@ -310,9 +305,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Single(result[0].AlternateVersions);
@@ -327,9 +321,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Single(result[0].AlternateVersions);
@@ -348,18 +341,17 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv",
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path);
Assert.Equal(5, result[0].AlternateVersions.Count);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[1].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[2].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[3].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[4].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[1].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[2].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[3].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[4].Files[0].Path);
}
[Fact]
@@ -381,24 +373,23 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv",
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path);
Assert.Equal(11, result[0].AlternateVersions.Count);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p Remux.mkv", result[0].AlternateVersions[1].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[2].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Directors Cut.mkv", result[0].AlternateVersions[3].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p High Bitrate.mkv", result[0].AlternateVersions[4].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Remux.mkv", result[0].AlternateVersions[5].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Theatrical Release.mkv", result[0].AlternateVersions[6].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[7].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p Directors Cut.mkv", result[0].AlternateVersions[8].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[9].Path);
- Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[10].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p Remux.mkv", result[0].AlternateVersions[1].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[2].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Directors Cut.mkv", result[0].AlternateVersions[3].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p High Bitrate.mkv", result[0].AlternateVersions[4].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Remux.mkv", result[0].AlternateVersions[5].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Theatrical Release.mkv", result[0].AlternateVersions[6].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[7].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p Directors Cut.mkv", result[0].AlternateVersions[8].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[9].Files[0].Path);
+ Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[10].Files[0].Path);
}
[Fact]
@@ -410,9 +401,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Single(result[0].AlternateVersions);
@@ -427,9 +417,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
}
@@ -437,7 +426,7 @@ namespace Jellyfin.Naming.Tests.Video
[Fact]
public void TestEmptyList()
{
- var result = VideoListResolver.Resolve(new List<VideoFileInfo>(), _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(new List<VideoFileInfo>()).ToList();
Assert.Empty(result);
}
@@ -451,9 +440,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Movie (2020)/Movie (2020)_1080p.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
Assert.Single(result[0].AlternateVersions);
@@ -468,11 +456,678 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Movie (2020)/Movie (2020).1080p.mkv"
};
- var result = VideoListResolver.Resolve(
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
+
+ Assert.Single(result);
+ Assert.Single(result[0].AlternateVersions);
+ }
+
+ // Episode multi-version tests
+
+ [Fact]
+ public void TestMultiVersionEpisodeInOwnFolder()
+ {
+ // Two versions of S01E01 in their own subfolder should merge
+ var files = new[]
+ {
+ "/TV/Dexter/Dexter - S01E01/Dexter - S01E01 - 1080p.mkv",
+ "/TV/Dexter/Dexter - S01E01/Dexter - S01E01 - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Single(result[0].AlternateVersions);
+ // 1080p should be primary (higher resolution)
+ Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal);
+ Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeMixedSeasonFolder()
+ {
+ // Multiple episodes in season folder, some with versions
+ var files = new[]
+ {
+ "/TV/Dexter/Season 1/Dexter - S01E01 - 1080p.mkv",
+ "/TV/Dexter/Season 1/Dexter - S01E01 - 720p.mkv",
+ "/TV/Dexter/Season 1/Dexter - S01E02.mkv",
+ "/TV/Dexter/Season 1/Dexter - S01E03 - 1080p.mkv",
+ "/TV/Dexter/Season 1/Dexter - S01E03 - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(3, result.Count);
+
+ // S01E01 - should have one alternate version
+ var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal));
+ Assert.NotNull(e01);
+ Assert.Single(e01!.AlternateVersions);
+ Assert.Contains("1080p", e01.Files[0].Path, StringComparison.Ordinal);
+
+ // S01E02 - standalone, no alternates
+ var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal));
+ Assert.NotNull(e02);
+ Assert.Empty(e02!.AlternateVersions);
+
+ // S01E03 - should have one alternate version
+ var e03 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E03", StringComparison.Ordinal));
+ Assert.NotNull(e03);
+ Assert.Single(e03!.AlternateVersions);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeDontCollapse()
+ {
+ // Different episodes should NOT collapse into versions
+ var files = new[]
+ {
+ "/TV/Dexter/Season 1/Dexter - S01E01.mkv",
+ "/TV/Dexter/Season 1/Dexter - S01E02.mkv",
+ "/TV/Dexter/Season 1/Dexter - S01E03.mkv",
+ "/TV/Dexter/Season 1/Dexter - S01E04.mkv",
+ "/TV/Dexter/Season 1/Dexter - S01E05.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(5, result.Count);
+ Assert.All(result, r => Assert.Empty(r.AlternateVersions));
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithVersionSuffix()
+ {
+ // Episodes with named versions (like Aired/Uncensored)
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - Aired.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Uncensored.mkv",
+ "/TV/Show/Season 1/Show - S01E02 - Aired.mkv",
+ "/TV/Show/Season 1/Show - S01E02 - Uncensored.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(2, result.Count);
+ Assert.All(result, r => Assert.Single(r.AlternateVersions));
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeFourVersions()
+ {
+ // Four versions of the same episode
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - VersionA.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - VersionB.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - VersionC.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - VersionD.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(3, result[0].AlternateVersions.Count);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithResolutions()
+ {
+ // Resolution sorting should work for episodes too
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - 720p.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 2160p.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].AlternateVersions.Count);
+ // Primary should be 2160p (highest resolution)
+ Assert.Contains("2160p", result[0].Files[0].Path, StringComparison.Ordinal);
+ // Next should be 1080p, then 720p
+ Assert.Contains("1080p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ Assert.Contains("720p", result[0].AlternateVersions[1].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeDifferentSeasons()
+ {
+ // Same episode number but different seasons should NOT group
+ var files = new[]
+ {
+ "/TV/Show/Show - S01E01.mkv",
+ "/TV/Show/Show - S02E01.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(2, result.Count);
+ Assert.All(result, r => Assert.Empty(r.AlternateVersions));
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeDisabledByDefault()
+ {
+ // Without collectionType: CollectionType.tvshows, episodes should NOT group
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
+
+ // Without the tvshows collection type, these fall through the movie path
+ // (folder-name eligibility fails) and are treated as separate items.
+ Assert.Equal(2, result.Count);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeSameNumberDifferentTitle()
+ {
+ // Two files parse to the same S01E01 but carry distinct episode titles.
+ // Current behavior: they are grouped as alternate versions because
+ // grouping keys only on season + episode number, not on episode title.
+ // This documents the trade-off: users with mis-numbered episodes will
+ // see one of the files collapsed into AlternateVersions of the other.
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - Pilot.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Completely Different Title.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Single(result[0].AlternateVersions);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithTitle()
+ {
+ // Episodes with an episode title AND a version suffix should group
+ var files = new[]
+ {
+ "/TV/Show/Show - S01E01/Show - S01E01 - Episode Title - 1080p.mkv",
+ "/TV/Show/Show - S01E01/Show - S01E01 - Episode Title - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Single(result[0].AlternateVersions);
+ Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal);
+ Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithTitleMixedFolder()
+ {
+ // Multiple different episodes with titles and resolution variants in a season folder
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv",
+ "/TV/Show/Season 1/Show - S01E02 - Second Episode - 1080p.mkv",
+ "/TV/Show/Season 1/Show - S01E02 - Second Episode - 720p.mkv",
+ "/TV/Show/Season 1/Show - S01E03 - Third Episode.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(3, result.Count);
+
+ var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal));
+ Assert.NotNull(e01);
+ Assert.Single(e01!.AlternateVersions);
+
+ var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal));
+ Assert.NotNull(e02);
+ Assert.Single(e02!.AlternateVersions);
+
+ var e03 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E03", StringComparison.Ordinal));
+ Assert.NotNull(e03);
+ Assert.Empty(e03!.AlternateVersions);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeInSeasonSubfolder()
+ {
+ // Two versions of S01E01 in their own subfolder under a season folder
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01/Show - S01E01 - 1080p.mkv",
+ "/TV/Show/Season 1/Show - S01E01/Show - S01E01 - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Single(result[0].AlternateVersions);
+ Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal);
+ Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithTitleAndVersionSuffix()
+ {
+ // Episodes with episode title AND a named version suffix
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - Aired.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - Uncensored.mkv",
+ "/TV/Show/Season 1/Show - S01E02 - The Getaway - Aired.mkv",
+ "/TV/Show/Season 1/Show - S01E02 - The Getaway - Uncensored.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(2, result.Count);
+ Assert.All(result, r => Assert.Single(r.AlternateVersions));
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithAdditionalPartsCd()
+ {
+ // Stacked episode (cd1/cd2) with higher resolution alongside a single-file lower-res version
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - 1080p cd1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 1080p cd2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].Files.Count);
+ Assert.Single(result[0].AlternateVersions);
+ Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithAdditionalPartsDashPart()
+ {
+ // Stacked episode using "- part1" / "- part2" separator
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - 1080p - part1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 1080p - part2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].Files.Count);
+ Assert.Single(result[0].AlternateVersions);
+ Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithAdditionalPartsPt()
+ {
+ // Stacked episode using "pt1" / "pt2" short form
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - 1080p.pt1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 1080p.pt2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].Files.Count);
+ Assert.Single(result[0].AlternateVersions);
+ Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithAdditionalPartsAndTitle()
+ {
+ // Stacked episode with episode title in filename
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p part1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p part2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ // Primary should be the stacked 1080p version with 2 files
+ Assert.Equal(2, result[0].Files.Count);
+ Assert.Single(result[0].AlternateVersions);
+ Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithAdditionalPartsAndTitleDashSeparator()
+ {
+ // Stacked episode with episode title using "- part1" separator
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p - part1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p - part2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ // Primary should be the stacked 1080p version with 2 files
+ Assert.Equal(2, result[0].Files.Count);
+ Assert.Single(result[0].AlternateVersions);
+ Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeWithAdditionalPartsAndMultipleEpisodes()
+ {
+ // Stacked episode alongside single-file version, plus a different episode
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - 1080p cd1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 1080p cd2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 720p.mkv",
+ "/TV/Show/Season 1/Show - S01E02 - Other.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(2, result.Count);
+
+ // S01E01: stacked (cd1+cd2) primary with 720p alternate
+ var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal));
+ Assert.NotNull(e01);
+ Assert.Equal(2, e01!.Files.Count);
+ Assert.Single(e01.AlternateVersions);
+
+ // S01E02: standalone
+ var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal));
+ Assert.NotNull(e02);
+ Assert.Empty(e02!.AlternateVersions);
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodePartStackAlongsideSingleFileResolutions()
+ {
+ // A part-stacked episode (3 parts, no resolution suffix) alongside single-file 720p and 1080p versions.
+ // The multi-part stack is preferred as primary.
+ var files = new[]
+ {
+ "/TV/Show/Season 1/S01E01 - 720p.mkv",
+ "/TV/Show/Season 1/S01E01 - 1080p.mkv",
+ "/TV/Show/Season 1/S01E01 - Part 1.mkv",
+ "/TV/Show/Season 1/S01E01 - Part 2.mkv",
+ "/TV/Show/Season 1/S01E01 - Part 3.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(3, result[0].Files.Count);
+ Assert.All(result[0].Files, f => Assert.Contains("Part", f.Path, StringComparison.Ordinal));
+ Assert.Equal(2, result[0].AlternateVersions.Count);
+ Assert.Contains(result[0].AlternateVersions, f => f.Files[0].Path.Contains("1080p", StringComparison.Ordinal));
+ Assert.Contains(result[0].AlternateVersions, f => f.Files[0].Path.Contains("720p", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeTwoPartStacks()
+ {
+ // Two part-suffixed stacks of the same episode at different resolutions.
+ // The 1080p stack is primary, the 720p stack is preserved as a multi-file alternate.
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - 1080p - part1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 1080p - part2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 720p - part1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 720p - part2.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].Files.Count);
+ Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal);
+
+ Assert.Single(result[0].AlternateVersions);
+ var alt = result[0].AlternateVersions[0];
+ Assert.Equal(2, alt.Files.Count);
+ Assert.All(alt.Files, f => Assert.Contains("720p", f.Path, StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodePartStackWithTrailer()
+ {
+ // A part-stacked multi-version episode alongside a trailer must not pull the trailer into the version group
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - 1080p part1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 1080p part2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - 720p.mkv",
+ "/TV/Show/Season 1/Show - S01E01-trailer.mp4"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(2, result.Count);
+
+ var episode = result.FirstOrDefault(r => r.ExtraType is null);
+ Assert.NotNull(episode);
+ Assert.Equal(2, episode!.Files.Count);
+ Assert.Single(episode.AlternateVersions);
+ Assert.Contains("720p", episode.AlternateVersions[0].Files[0].Path, StringComparison.Ordinal);
+
+ var trailer = result.FirstOrDefault(r => r.ExtraType is not null);
+ Assert.NotNull(trailer);
+ Assert.Equal(ExtraType.Trailer, trailer!.ExtraType);
+ }
+
+ [Fact]
+ public void TestMovieStackingWithPartNaming()
+ {
+ // Movie stacking with "part1"/"part2" naming
+ var files = new[]
+ {
+ "/movies/Movie/Movie part1.mkv",
+ "/movies/Movie/Movie part2.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].Files.Count);
+ }
+
+ [Fact]
+ public void TestMovieStackingWithDashPartNaming()
+ {
+ // Movie stacking with "- part1" / "- part2" dash separator
+ var files = new[]
+ {
+ "/movies/Movie/Movie - part1.mkv",
+ "/movies/Movie/Movie - part2.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].Files.Count);
+ }
+
+ [Fact]
+ public void TestMovieStackingWithPtNaming()
+ {
+ // Movie stacking with "pt1"/"pt2" short form
+ var files = new[]
+ {
+ "/movies/Movie/Movie.pt1.mkv",
+ "/movies/Movie/Movie.pt2.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].Files.Count);
+ }
+
+ [Fact]
+ public void TestMovieStackingWithHyphenNoSpaces()
+ {
+ // Movie stacking with hyphen directly adjacent to "part" (no spaces)
+ var files = new[]
+ {
+ "/movies/Movie/Movie-part1.mkv",
+ "/movies/Movie/Movie-part2.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(2, result[0].Files.Count);
+ }
+
+ [Fact]
+ public void TestMovieStackingWithHyphenNoSpacesAndVersion()
+ {
+ // Movie stacking with hyphen-no-space separators plus a version alternate
+ var files = new[]
+ {
+ "/movies/Movie/Movie-1080p-part1.mkv",
+ "/movies/Movie/Movie-1080p-part2.mkv",
+ "/movies/Movie/Movie-720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
+
+ Assert.Single(result);
+ // Stacked 1080p (2 files) should be primary, 720p is alternate
+ Assert.Equal(2, result[0].Files.Count);
+ Assert.Single(result[0].AlternateVersions);
+ }
+
+ [Fact]
+ public void TestMovieMultiVersionWithStackedAlternate()
+ {
+ // Movie folder where the folder-named file is the primary (single file via primaryOverride)
+ // and an alternate version is itself a stack. The stacked alternate must keep all its files.
+ var files = new[]
+ {
+ "/movies/Inception (2010)/Inception (2010).mkv",
+ "/movies/Inception (2010)/Inception (2010) - 4k part1.mkv",
+ "/movies/Inception (2010)/Inception (2010) - 4k part2.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
+
+ Assert.Single(result);
+ Assert.Single(result[0].Files);
+ Assert.Equal("/movies/Inception (2010)/Inception (2010).mkv", result[0].Files[0].Path);
+
+ Assert.Single(result[0].AlternateVersions);
+ var stackedAlternate = result[0].AlternateVersions[0];
+ Assert.Equal(2, stackedAlternate.Files.Count);
+ Assert.All(stackedAlternate.Files, f => Assert.Contains("4k part", f.Path, StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void TestEpisodeStackingWithHyphenNoSpaces()
+ {
+ // Episode stacking with hyphen-no-space separators plus version alternate
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01-1080p-cd1.mkv",
+ "/TV/Show/Season 1/Show - S01E01-1080p-cd2.mkv",
+ "/TV/Show/Season 1/Show - S01E01-720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Single(result);
+ // Stacked 1080p (2 files) should be primary, 720p is alternate
+ Assert.Equal(2, result[0].Files.Count);
+ Assert.Single(result[0].AlternateVersions);
+ }
+
+ [Fact]
+ public void TestEpisodeStackingWithHyphenNoSpacesAndTitle()
+ {
+ // Episode stacking with title and hyphen-no-space separators
+ var files = new[]
+ {
+ "/TV/Show/Season 1/Show - S01E01 - Pilot-1080p-part1.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Pilot-1080p-part2.mkv",
+ "/TV/Show/Season 1/Show - S01E01 - Pilot-720p.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ collectionType: CollectionType.tvshows).ToList();
Assert.Single(result);
+ // Stacked 1080p (2 files) should be primary, 720p is alternate
+ Assert.Equal(2, result[0].Files.Count);
Assert.Single(result[0].AlternateVersions);
}
}
diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
index d3164ba9c9..53f16b92d6 100644
--- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
@@ -10,6 +10,12 @@ namespace Jellyfin.Naming.Tests.Video
public class VideoListResolverTests
{
private readonly NamingOptions _namingOptions = new NamingOptions();
+ private readonly VideoListResolver _videoListResolver;
+
+ public VideoListResolverTests()
+ {
+ _videoListResolver = new VideoListResolver(_namingOptions);
+ }
[Fact]
public void TestStackAndExtras()
@@ -40,9 +46,8 @@ namespace Jellyfin.Naming.Tests.Video
"WillyWonka-trailer.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(11, result.Count);
var batman = result.FirstOrDefault(x => string.Equals(x.Name, "Batman", StringComparison.Ordinal));
@@ -74,9 +79,8 @@ namespace Jellyfin.Naming.Tests.Video
"300.nfo"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
}
@@ -90,9 +94,8 @@ namespace Jellyfin.Naming.Tests.Video
"300 - trailer.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -108,9 +111,8 @@ namespace Jellyfin.Naming.Tests.Video
"X-Men Days of Future Past-trailer.mp4"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -127,9 +129,8 @@ namespace Jellyfin.Naming.Tests.Video
"X-Men Days of Future Past-trailer2.mp4"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(3, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -147,9 +148,8 @@ namespace Jellyfin.Naming.Tests.Video
"Looper.2012.bluray.720p.x264.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(3, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -166,9 +166,8 @@ namespace Jellyfin.Naming.Tests.Video
"/movies/Looper (2012)/Looper.bluray.720p.x264.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -188,9 +187,8 @@ namespace Jellyfin.Naming.Tests.Video
"My video 5.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(5, result.Count);
}
@@ -204,9 +202,8 @@ namespace Jellyfin.Naming.Tests.Video
"M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
}
@@ -221,9 +218,8 @@ namespace Jellyfin.Naming.Tests.Video
"My movie #2.mp4"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
}
@@ -239,9 +235,8 @@ namespace Jellyfin.Naming.Tests.Video
"No (2012)-trailer.mp4"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(3, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -260,9 +255,8 @@ namespace Jellyfin.Naming.Tests.Video
"/Movies/trailer.mp4"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(4, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -282,9 +276,8 @@ namespace Jellyfin.Naming.Tests.Video
"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd2.avi"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
}
@@ -297,9 +290,8 @@ namespace Jellyfin.Naming.Tests.Video
"/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
}
@@ -312,9 +304,8 @@ namespace Jellyfin.Naming.Tests.Video
"The Colony.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Single(result);
}
@@ -328,9 +319,8 @@ namespace Jellyfin.Naming.Tests.Video
"Four Sisters and a Wedding - B.avi"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
// The result should contain two individual movies
// Version grouping should not work here, because the files are not in a directory with the name 'Four Sisters and a Wedding'
@@ -346,9 +336,8 @@ namespace Jellyfin.Naming.Tests.Video
"Four Rooms - A.mp4"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
}
@@ -362,9 +351,8 @@ namespace Jellyfin.Naming.Tests.Video
"/Server/Despicable Me/trailer.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -380,9 +368,8 @@ namespace Jellyfin.Naming.Tests.Video
"/Server/Despicable Me/trailers/some title.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
Assert.False(result[0].ExtraType.HasValue);
@@ -398,9 +385,8 @@ namespace Jellyfin.Naming.Tests.Video
"/Movies/Despicable Me/trailers/trailer.mkv"
};
- var result = VideoListResolver.Resolve(
- files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
- _namingOptions).ToList();
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList();
Assert.Equal(2, result.Count);
Assert.False(result[0].ExtraType.HasValue);
diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
index b63009d6a5..1f523f7f21 100644
--- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
+++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
@@ -7,6 +7,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Model.Net;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -94,10 +95,106 @@ namespace Jellyfin.Networking.Tests
[InlineData("256.128.0.0.0.1")]
[InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")]
[InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")]
+ [InlineData("fd23:184f:2029:0100/56")]
public static void TryParseInvalidIPStringsFalse(string address)
=> Assert.False(NetworkUtils.TryParseToSubnet(address, out _));
/// <summary>
+ /// Verifies that <see cref="NetworkUtils.TryParseToSubnets"/> emits a targeted warning
+ /// for IPv6 prefix-only notation and a generic warning for other malformed entries.
+ /// </summary>
+ [Fact]
+ public static void TryParseToSubnets_InvalidEntries_LogsWarnings()
+ {
+ var logger = new Mock<ILogger>();
+
+ var values = new[] { "10.0.0.0/8", "fd23:184f:2029:0100/56", "not-an-address" };
+ Assert.True(NetworkUtils.TryParseToSubnets(values, out var result, false, logger.Object));
+ Assert.NotNull(result);
+ Assert.Single(result);
+
+ // IPv6 prefix-only notation should produce a specific, actionable warning.
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("IPv6 prefix-only", StringComparison.Ordinal)
+ && state.ToString()!.Contains("fd23:184f:2029:0100/56", StringComparison.Ordinal)),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+
+ // Other malformed entries should still produce a generic warning.
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("not-an-address", StringComparison.Ordinal)),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ /// <summary>
+ /// Verifies that IPv4 entries whose '!' polarity doesn't match the requested pass are skipped silently,
+ /// not logged as invalid. Callers parse the same list twice (LAN and excluded) so the off-polarity
+ /// entries are expected, not erroneous.
+ /// </summary>
+ [Fact]
+ public static void TryParseToSubnets_PolarityMismatchIPv4_DoesNotWarn()
+ {
+ var logger = new Mock<ILogger>();
+ var values = new[] { "127.0.0.0/8", "192.168.178.0/24", "!10.0.0.0/8" };
+
+ // Non-negated pass picks up the two non-'!' entries and ignores '!10.0.0.0/8' silently.
+ Assert.True(NetworkUtils.TryParseToSubnets(values, out var lanResult, false, logger.Object));
+ Assert.NotNull(lanResult);
+ Assert.Equal(2, lanResult.Count);
+
+ // Negated pass picks up the single '!' entry and ignores the others silently.
+ Assert.True(NetworkUtils.TryParseToSubnets(values, out var excludedResult, true, logger.Object));
+ Assert.NotNull(excludedResult);
+ Assert.Single(excludedResult);
+
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never);
+ }
+
+ /// <summary>
+ /// Same as the IPv4 case but for IPv6 entries — makes sure the polarity pre-check works
+ /// for IPv6 CIDR notation (with '::') as well.
+ /// </summary>
+ [Fact]
+ public static void TryParseToSubnets_PolarityMismatchIPv6_DoesNotWarn()
+ {
+ var logger = new Mock<ILogger>();
+ var values = new[] { "fd00::/8", "fe80::/10", "!fd12:3456:789a::/48" };
+
+ Assert.True(NetworkUtils.TryParseToSubnets(values, out var lanResult, false, logger.Object));
+ Assert.NotNull(lanResult);
+ Assert.Equal(2, lanResult.Count);
+
+ Assert.True(NetworkUtils.TryParseToSubnets(values, out var excludedResult, true, logger.Object));
+ Assert.NotNull(excludedResult);
+ Assert.Single(excludedResult);
+
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never);
+ }
+
+ /// <summary>
/// Checks if IPv4 address is within a defined subnet.
/// </summary>
/// <param name="netMask">Network mask.</param>
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs
index 99604e0933..aaa500b762 100644
--- a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs
@@ -1,7 +1,7 @@
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Model.Entities;
-using MediaBrowser.Providers.Plugins.ComicVine;
+using MediaBrowser.Providers.Books.ComicVine;
using Xunit;
namespace Jellyfin.Providers.Tests.ExternalId
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs
index eec64ac53f..b9ce895dbc 100644
--- a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs
@@ -1,7 +1,7 @@
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Model.Entities;
-using MediaBrowser.Providers.Plugins.GoogleBooks;
+using MediaBrowser.Providers.Books.GoogleBooks;
using Xunit;
namespace Jellyfin.Providers.Tests.ExternalId
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 87e7a4b564..5749944fcd 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -576,7 +576,8 @@ namespace Jellyfin.Providers.Tests.Manager
baseItemManager!,
Mock.Of<ILyricManager>(),
Mock.Of<IMemoryCache>(),
- Mock.Of<IMediaSegmentManager>());
+ Mock.Of<IMediaSegmentManager>(),
+ Mock.Of<ISimilarItemsManager>());
return providerManager;
}
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs
index a7491f42e9..2438ef06d1 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs
@@ -37,9 +37,9 @@ public class FFProbeVideoInfoTests
{
Assert.Throws<ArgumentException>(
() => _fFProbeVideoInfo.CreateDummyChapters(new Video()
- {
- RunTimeTicks = runtime
- }));
+ {
+ RunTimeTicks = runtime
+ }));
}
[Theory]
@@ -53,9 +53,9 @@ public class FFProbeVideoInfoTests
public void CreateDummyChapters_ValidRuntime_CorrectChaptersCount(long? runtime, int chaptersCount)
{
var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video()
- {
- RunTimeTicks = runtime
- });
+ {
+ RunTimeTicks = runtime
+ });
Assert.Equal(chaptersCount, chapters.Length);
}
@@ -69,9 +69,9 @@ public class FFProbeVideoInfoTests
public void CreateDummyChapters_PositiveRuntime_NoChapterBeyondRuntime(long runtime)
{
var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video()
- {
- RunTimeTicks = runtime
- });
+ {
+ RunTimeTicks = runtime
+ });
Assert.All(chapters, chapter => Assert.True(chapter.StartPositionTicks < runtime));
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
new file mode 100644
index 0000000000..a5de0a4416
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -0,0 +1,131 @@
+using System;
+using Emby.Server.Implementations.Dto;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Dto;
+
+public class DtoServiceTests
+{
+ private readonly Mock<ILibraryManager> _libraryManagerMock;
+ private readonly DtoService _dtoService;
+
+ public DtoServiceTests()
+ {
+ _libraryManagerMock = new Mock<ILibraryManager>();
+
+ var imageProcessor = new Mock<IImageProcessor>();
+ // Deterministic tag derived from the image so each item gets a distinct, assertable tag.
+ imageProcessor
+ .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
+ .Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path);
+
+ var appHost = new Mock<IApplicationHost>();
+ appHost.Setup(x => x.SystemId).Returns("test-server");
+
+ // Video.SourceType probes the active-recording manager; provide one so it doesn't NRE.
+ Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
+
+ _dtoService = new DtoService(
+ NullLogger<DtoService>.Instance,
+ _libraryManagerMock.Object,
+ new Mock<IUserDataManager>().Object,
+ imageProcessor.Object,
+ new Mock<IProviderManager>().Object,
+ new Mock<IRecordingsManager>().Object,
+ appHost.Object,
+ new Mock<IMediaSourceManager>().Object,
+ new Lazy<ILiveTvManager>(() => new Mock<ILiveTvManager>().Object),
+ new Mock<ITrickplayManager>().Object,
+ new Mock<IChapterManager>().Object);
+
+ // Episode.Series / Episode.Season resolve through the static BaseItem.LibraryManager.
+ BaseItem.LibraryManager = _libraryManagerMock.Object;
+ }
+
+ [Fact]
+ public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries()
+ {
+ var (episode, season, series) = BuildEpisode(seasonHasPoster: true);
+ var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // The episode's own 16:9 primary is dropped in favor of the season's portrait poster.
+ Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Null(dto.SeriesPrimaryImageTag);
+ Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
+ // Aspect ratio follows the (portrait) poster, not the episode's 16:9 image.
+ Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
+ }
+
+ [Fact]
+ public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster()
+ {
+ var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
+ var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Null(dto.SeriesPrimaryImageTag);
+ Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
+ }
+
+ [Fact]
+ public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary()
+ {
+ var (episode, _, _) = BuildEpisode(seasonHasPoster: true);
+ var options = new DtoOptions(false);
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // Default behavior: the episode keeps its own primary and exposes the series poster as a tag.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
+ Assert.Null(dto.ParentPrimaryImageItemId);
+ }
+
+ private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster)
+ {
+ // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
+ // item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
+ var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
+ series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+
+ var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
+ if (seasonHasPoster)
+ {
+ season.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/season.jpg" }, 0);
+ }
+
+ var episode = new Episode
+ {
+ Id = Guid.NewGuid(),
+ Name = "Episode",
+ SeasonId = season.Id,
+ SeriesId = series.Id
+ };
+ episode.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/episode.jpg" }, 0);
+
+ _libraryManagerMock.Setup(x => x.GetItemById(season.Id)).Returns(season);
+ _libraryManagerMock.Setup(x => x.GetItemById(series.Id)).Returns(series);
+
+ return (episode, season, series);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
index aed584355c..e1346a8436 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
@@ -1,7 +1,13 @@
+using System.Collections.Generic;
using Emby.Naming.Common;
+using Emby.Naming.Video;
using Emby.Server.Implementations.Library.Resolvers.Movies;
+using Jellyfin.Data.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
@@ -14,11 +20,12 @@ namespace Jellyfin.Server.Implementations.Tests.Library;
public class MovieResolverTests
{
private static readonly NamingOptions _namingOptions = new();
+ private static readonly VideoListResolver _videoListResolver = new(_namingOptions);
[Fact]
public void Resolve_GivenLocalAlternateVersion_ResolvesToVideo()
{
- var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
+ var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
var itemResolveArgs = new ItemResolveArgs(
Mock.Of<IServerApplicationPaths>(),
null)
@@ -32,4 +39,54 @@ public class MovieResolverTests
Assert.NotNull(movieResolver.Resolve(itemResolveArgs));
}
+
+ [Fact]
+ public void ResolveMultiple_GivenTvShowsCollection_CreatesEpisodeItems()
+ {
+ // For a tvshows collection, the multi-version grouping must still produce
+ // Episode BaseItems (not generic Video) so downstream metadata fetching
+ // and series-aware logic apply.
+ var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
+
+ var parent = new Folder { Path = "/TV/Show/Season 1" };
+ var files = new List<FileSystemMetadata>
+ {
+ new() { FullName = "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv", Name = "Show - S01E01 - 1080p.mkv", IsDirectory = false },
+ new() { FullName = "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", Name = "Show - S01E01 - 720p.mkv", IsDirectory = false },
+ new() { FullName = "/TV/Show/Season 1/Show - S01E02.mkv", Name = "Show - S01E02.mkv", IsDirectory = false }
+ };
+
+ var result = movieResolver.ResolveMultiple(parent, files, CollectionType.tvshows, Mock.Of<IDirectoryService>());
+
+ Assert.NotNull(result);
+ Assert.Equal(2, result.Items.Count);
+ Assert.All(result.Items, item => Assert.IsType<Episode>(item));
+
+ // The S01E01 item should have one alternate version
+ var s01e01 = result.Items.Find(i => i.Path.Contains("S01E01", System.StringComparison.Ordinal));
+ Assert.NotNull(s01e01);
+ Assert.Single(((Video)s01e01).LocalAlternateVersions);
+ }
+
+ [Fact]
+ public void ResolveMultiple_GivenMoviesCollection_CreatesMovieItems()
+ {
+ // For a movies collection, the multi-version grouping must produce Movie
+ // BaseItems (not generic Video) so downstream movie-specific logic applies.
+ var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
+
+ var parent = new Folder { Path = "/movies/Inception (2010)" };
+ var files = new List<FileSystemMetadata>
+ {
+ new() { FullName = "/movies/Inception (2010)/Inception (2010) - 1080p.mkv", Name = "Inception (2010) - 1080p.mkv", IsDirectory = false },
+ new() { FullName = "/movies/Inception (2010)/Inception (2010) - 720p.mkv", Name = "Inception (2010) - 720p.mkv", IsDirectory = false }
+ };
+
+ var result = movieResolver.ResolveMultiple(parent, files, CollectionType.movies, Mock.Of<IDirectoryService>());
+
+ Assert.NotNull(result);
+ Assert.Single(result.Items);
+ Assert.All(result.Items, item => Assert.IsType<Movie>(item));
+ Assert.Single(((Video)result.Items[0]).LocalAlternateVersions);
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index acabaf3acb..3b8fe5ca60 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BitFaster.Caching;
@@ -305,6 +306,98 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Equal(key, translated);
}
+ [Fact]
+ public void GetLocalizedString_WithCulture_ReturnsTranslation()
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+
+ var translated = localizationManager.GetLocalizedString("Artists", "de");
+ Assert.Equal("Interpreten", translated);
+ }
+
+ [Fact]
+ public void GetLocalizedString_WithCulture_FallsBackToEnUs()
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+
+ // A culture with no translation file should fall back to en-US
+ var translated = localizationManager.GetLocalizedString("Artists", "zz");
+ Assert.Equal("Artists", translated);
+ }
+
+ [Fact]
+ public void GetLocalizedString_WithBcp47Normalization_ReturnsTranslation()
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+
+ // es-419 is stored as es_419 in Jellyfin
+ var translated = localizationManager.GetLocalizedString("Default", "es-419");
+ Assert.NotEqual("Default", translated);
+ }
+
+ [Fact]
+ public void GetServerLocalizedString_UsesServerCulture()
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "de"
+ });
+
+ // Even if CurrentUICulture is fr, GetServerLocalizedString should use the server's "de"
+ var previousCulture = CultureInfo.CurrentUICulture;
+ try
+ {
+ CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr");
+ var translated = localizationManager.GetServerLocalizedString("Artists");
+ Assert.Equal("Interpreten", translated);
+ }
+ finally
+ {
+ CultureInfo.CurrentUICulture = previousCulture;
+ }
+ }
+
+ [Fact]
+ public void GetLocalizedString_UsesCurrentUICulture()
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+
+ var previousCulture = CultureInfo.CurrentUICulture;
+ try
+ {
+ CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
+ var translated = localizationManager.GetLocalizedString("Artists");
+ Assert.Equal("Interpreten", translated);
+ }
+ finally
+ {
+ CultureInfo.CurrentUICulture = previousCulture;
+ }
+ }
+
+ [Fact]
+ public void GetSupportedUICultures_IncludesCommonCultures()
+ {
+ var supported = LocalizationManager.GetSupportedUICultures();
+ Assert.Contains(supported, c => c.Name.Equals("de", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(supported, c => c.Name.Equals("en-US", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(supported, c => c.Name.Equals("fr", StringComparison.OrdinalIgnoreCase));
+ // Underscore variants get normalized to BCP-47 hyphen form for CultureInfo compatibility.
+ Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase));
+ }
+
private LocalizationManager Setup(ServerConfiguration config)
{
var mockConfiguration = new Mock<IServerConfigurationManager>();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs
new file mode 100644
index 0000000000..596bf58fb1
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs
@@ -0,0 +1,240 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Cryptography;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public sealed class UserManagerNormalizedUsernameTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserManager _userManager;
+
+ public UserManagerNormalizedUsernameTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Create the schema
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var cryptoProvider = new Mock<ICryptoProvider>();
+ var configManager = new Mock<IServerConfigurationManager>();
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
+ configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
+
+ var appHost = new Mock<IApplicationHost>();
+
+ var defaultAuthProvider = new DefaultAuthenticationProvider(
+ NullLogger<DefaultAuthenticationProvider>.Instance,
+ cryptoProvider.Object);
+ var invalidAuthProvider = new InvalidAuthProvider();
+ var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
+ configManager.Object,
+ appHost.Object);
+
+ _userManager = new UserManager(
+ factory.Object,
+ new NoopEventManager(),
+ new Mock<INetworkManager>().Object,
+ appHost.Object,
+ new Mock<IImageProcessor>().Object,
+ NullLogger<UserManager>.Instance,
+ configManager.Object,
+ new IPasswordResetProvider[] { defaultPasswordResetProvider },
+ new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
+ }
+
+ public void Dispose()
+ {
+ _userManager.Dispose();
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ // ----- GetUserByName tests -----
+
+ [Theory]
+ // German umlauts
+ [InlineData("münchen", "MÜNCHEN")]
+ // Spanish tilde-n
+ [InlineData("Ñoño", "ÑOÑO")]
+ // ASCII, invariant uppercase lookup
+ [InlineData("jellyfin", "JELLYFIN")]
+ // Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
+ [InlineData("Çelebi", "ÇELEBI")]
+ public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
+ string username, string normalizedLookup)
+ {
+ await _userManager.CreateUserAsync(username);
+
+ var found = _userManager.GetUserByName(normalizedLookup);
+
+ Assert.NotNull(found);
+ Assert.Equal(username, found.Username);
+ }
+
+ [Theory]
+ // German umlaut, look up by both upper and lower case
+ [InlineData("münchen")]
+ // Spanish tilde-n
+ [InlineData("Ñoño")]
+ // lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
+ [InlineData("ali")]
+ // mixed ASCII + umlaut
+ [InlineData("testüser")]
+ public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
+ {
+ await _userManager.CreateUserAsync(username);
+
+ var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
+ var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
+ var exactFound = _userManager.GetUserByName(username);
+
+ Assert.NotNull(upperFound);
+ Assert.NotNull(lowerFound);
+ Assert.NotNull(exactFound);
+ }
+
+ [Theory]
+ [InlineData("nonexistent")]
+ // No user with NormalizedUsername = "MÜNCHEN" has been created
+ [InlineData("MÜNCHEN")]
+ public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
+ {
+ var result = _userManager.GetUserByName(lookupName);
+
+ Assert.Null(result);
+ }
+
+ // ----- CreateUserAsync duplicate detection tests -----
+
+ [Theory]
+ // German umlaut, case-swapped duplicate
+ [InlineData("münchen", "MÜNCHEN")]
+ // Spanish tilde-n, lowercase duplicate
+ [InlineData("Ñoño", "ñoño")]
+ // ASCII, uppercase duplicate
+ [InlineData("alice", "ALICE")]
+ // Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
+ [InlineData("çelebi", "ÇELEBI")]
+ public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
+ string existingUsername, string duplicateUsername)
+ {
+ await _userManager.CreateUserAsync(existingUsername);
+
+ await Assert.ThrowsAsync<ArgumentException>(
+ () => _userManager.CreateUserAsync(duplicateUsername));
+ }
+
+ [Theory]
+ // Different non-ASCII names that do not collide after normalization
+ [InlineData("münchen", "münchen2")]
+ [InlineData("ali", "ali2")]
+ // Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
+ [InlineData("noño", "nono")]
+ public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
+ string firstUsername, string secondUsername)
+ {
+ var first = await _userManager.CreateUserAsync(firstUsername);
+ var second = await _userManager.CreateUserAsync(secondUsername);
+
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.NotEqual(first.Id, second.Id);
+ }
+
+ // ----- RenameUser tests -----
+
+ [Theory]
+ // Rename to non-ASCII name
+ [InlineData("alice", "münchen")]
+ // Rename between similar non-ASCII and ASCII
+ [InlineData("müller", "mueller")]
+ // Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
+ [InlineData("ali", "ALI2")]
+ // Rename to Spanish tilde-n name
+ [InlineData("testuser", "Ñoño")]
+ public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
+ string originalName, string newName)
+ {
+ var user = await _userManager.CreateUserAsync(originalName);
+
+ await _userManager.RenameUser(user.Id, originalName, newName);
+
+ var renamed = _userManager.GetUserById(user.Id);
+ Assert.NotNull(renamed);
+ Assert.Equal(newName, renamed.Username);
+ Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
+ }
+
+ [Theory]
+ // Same name different case: NormalizedUsername already taken
+ [InlineData("münchen", "MÜNCHEN")]
+ // Spanish, lowercase conflicts with existing uppercase-normalised entry
+ [InlineData("Ñoño", "ñoño")]
+ // ASCII, capitalised conflict
+ [InlineData("alice", "Alice")]
+ // Mixed ASCII + umlaut
+ [InlineData("testüser", "TESTÜSER")]
+ public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
+ string existingUsername, string conflictingNewName)
+ {
+ var targetUser = await _userManager.CreateUserAsync("renametarget");
+ await _userManager.CreateUserAsync(existingUsername);
+
+ await Assert.ThrowsAsync<ArgumentException>(
+ () => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
+ }
+
+ private sealed class NoopEventManager : IEventManager
+ {
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ }
+
+ public Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ => Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs
index edbb46b34c..b9b2862c65 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs
@@ -23,6 +23,7 @@ public sealed class LibraryControllerTests : IClassFixture<JellyfinApplicationFa
[InlineData("Items/{0}/ThemeMedia")]
[InlineData("Items/{0}/Ancestors")]
[InlineData("Items/{0}/Download")]
+ [InlineData("Items/{0}/Collections")]
[InlineData("Artists/{0}/Similar")]
[InlineData("Items/{0}/Similar")]
[InlineData("Albums/{0}/Similar")]