aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs161
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs129
-rw-r--r--tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs99
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs289
-rw-r--r--tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs89
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs99
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs303
-rw-r--r--tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs397
-rw-r--r--tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs51
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs64
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs22
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs113
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs282
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json113
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json110
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json49
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs105
-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/Book/BookResolverTests.cs58
-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/ExternalId/MusicBrainzExternalUrlProviderTests.cs3
-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/DtoServiceImageInheritanceTests.cs137
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs138
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs6
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs233
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs144
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj1
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs220
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs59
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs107
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs80
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs24
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs93
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs240
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs1
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs29
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs140
45 files changed, 5020 insertions, 479 deletions
diff --git a/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs
new file mode 100644
index 0000000000..a248664928
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs
@@ -0,0 +1,161 @@
+using System;
+using System.IO;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+// The legacy HLS endpoints build a file path from caller-supplied route values, and the audio
+// and video segment endpoints are not authenticated. These tests pin down that requests escaping
+// the transcode directory are rejected while legitimate ones still serve a file.
+public sealed class HlsSegmentControllerTests
+{
+ private readonly Mock<IFileSystem> _fileSystem = new();
+ private readonly Mock<IServerConfigurationManager> _config = new();
+ private readonly Mock<ITranscodeManager> _transcodeManager = new();
+ private readonly string _transcodePath;
+
+ public HlsSegmentControllerTests()
+ {
+ _transcodePath = Path.Combine(Path.GetTempPath(), "jellyfin-hls-segment-tests");
+ Directory.CreateDirectory(_transcodePath);
+
+ _config.Setup(c => c.GetConfiguration("encoding"))
+ .Returns(new EncodingOptions { TranscodingTempPath = _transcodePath });
+ _config.SetupGet(c => c.CommonApplicationPaths).Returns(Mock.Of<IApplicationPaths>());
+ }
+
+ private HlsSegmentController CreateController(string requestPath)
+ {
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Path = requestPath;
+
+ return new HlsSegmentController(_fileSystem.Object, _config.Object, _transcodeManager.Object)
+ {
+ ControllerContext = new ControllerContext { HttpContext = httpContext }
+ };
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ var result = controller.GetHlsAudioSegmentLegacy("abc", "segment");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ [InlineData("subdir/../../../../etc/passwd")]
+ public void GetHlsAudioSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ var result = controller.GetHlsAudioSegmentLegacy("abc", segmentId);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_AbsoluteRootedPath_ReturnsBadRequest()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ // A rooted segment id makes Path.GetFullPath discard the transcode base.
+ var rooted = OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd";
+ var result = controller.GetHlsAudioSegmentLegacy("abc", rooted);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_SiblingPrefixDirectory_ReturnsBadRequest()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ // Resolves to "<transcodePath>-evil/passwd", which shares the transcode path as a string prefix.
+ var result = controller.GetHlsAudioSegmentLegacy("abc", "../jellyfin-hls-segment-tests-evil/passwd");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsPlaylistLegacy_M3u8InsideTranscodePath_ReturnsFile()
+ {
+ var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", "list");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsPlaylistLegacy_NonPlaylistExtension_ReturnsBadRequest()
+ {
+ // Playlist endpoint serves only .m3u8, even for a path inside the transcode dir.
+ var controller = CreateController("/Videos/abc/hls/list/stream.mp4");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", "list");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ public void GetHlsPlaylistLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string playlistId)
+ {
+ var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", playlistId);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsVideoSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
+ {
+ _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
+ .Returns(new[] { Path.Combine(_transcodePath, "playlist123.ts") });
+
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsVideoSegmentLegacy_NoMatchingPlaylist_ReturnsNotFound()
+ {
+ _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
+ .Returns(Array.Empty<string>());
+
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
+
+ Assert.IsType<NotFoundObjectResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ public void GetHlsVideoSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
+ {
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", segmentId, "ts");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ _fileSystem.Verify(f => f.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
new file mode 100644
index 0000000000..f040a328bb
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
@@ -0,0 +1,129 @@
+using System;
+using System.IO;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+// Covers the path-traversal validation in GetPluginImage: a plugin's manifest ImagePath
+// must resolve to a file inside the plugin's own directory.
+public sealed class PluginsControllerTests
+{
+ private readonly Mock<IPluginManager> _pluginManager = new();
+ private readonly string _pluginPath;
+
+ public PluginsControllerTests()
+ {
+ _pluginPath = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-image-tests");
+ Directory.CreateDirectory(_pluginPath);
+ }
+
+ private PluginsController CreateController() =>
+ new PluginsController(Mock.Of<IInstallationManager>(), _pluginManager.Object)
+ {
+ ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
+ };
+
+ private void SetupPlugin(Guid id, Version version, string? imagePath)
+ {
+ var manifest = new PluginManifest { Id = id, Name = "Test", Version = version.ToString(), ImagePath = imagePath };
+ _pluginManager.Setup(p => p.GetPlugin(id, version))
+ .Returns(new LocalPlugin(_pluginPath, true, manifest));
+ }
+
+ [Fact]
+ public void GetPluginImage_UnknownPlugin_ReturnsNotFound()
+ {
+ var result = CreateController().GetPluginImage(Guid.NewGuid(), new Version(1, 0));
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_ImageInsidePluginPath_ReturnsFile()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ File.WriteAllBytes(Path.Combine(_pluginPath, "logo.png"), Array.Empty<byte>());
+ SetupPlugin(id, version, "logo.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_ImageInsidePluginPathButMissing_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, "does-not-exist.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ [InlineData("subdir/../../../../etc/passwd")]
+ public void GetPluginImage_TraversalOutsidePluginPath_ReturnsNotFound(string imagePath)
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, imagePath);
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_SiblingPrefixDirectory_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ // Resolves to "<pluginPath>-evil/logo.png", which shares the plugin path as a string prefix.
+ // The file is created so the check fails on the boundary, not on File.Exists.
+ var siblingDir = _pluginPath + "-evil";
+ Directory.CreateDirectory(siblingDir);
+ File.WriteAllBytes(Path.Combine(siblingDir, "logo.png"), Array.Empty<byte>());
+ SetupPlugin(id, version, "../jellyfin-plugin-image-tests-evil/logo.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_AbsoluteImagePath_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void GetPluginImage_NoImagePathOrResource_ReturnsNotFound(string? imagePath)
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, imagePath);
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
new file mode 100644
index 0000000000..a003be4d96
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Globalization;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Helpers
+{
+ public class MediaInfoHelperTests
+ {
+ private static MediaInfoHelper CreateHelper()
+ {
+ return new MediaInfoHelper(
+ Mock.Of<IUserManager>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILogger<MediaInfoHelper>>(),
+ Mock.Of<INetworkManager>(),
+ Mock.Of<IDeviceManager>());
+ }
+
+ private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true)
+ {
+ return new MediaSourceInfo
+ {
+ Id = itemId.ToString("N", CultureInfo.InvariantCulture),
+ Protocol = MediaProtocol.File,
+ Bitrate = bitrate,
+ SupportsDirectPlay = supportsDirectPlay,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true
+ };
+ }
+
+ [Fact]
+ public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault()
+ {
+ // The version the user was watching (the queried item) must stay the default
+ // even when a sibling version fits the bitrate limit better, since the resume
+ // position belongs to that exact version.
+ var preferredItemId = Guid.NewGuid();
+ var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false);
+ var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [siblingSource, preferredSource]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId);
+
+ Assert.Equal(preferredSource.Id, result.MediaSources[0].Id);
+ }
+
+ [Fact]
+ public void SortMediaSources_NoPreferredItem_OrdersByPlayability()
+ {
+ var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+ var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
+ transcodeOnly.SupportsDirectStream = false;
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [transcodeOnly, directPlay]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000);
+
+ Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
+ }
+
+ [Fact]
+ public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder()
+ {
+ var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+ var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
+ transcodeOnly.SupportsDirectStream = false;
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [transcodeOnly, directPlay]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid());
+
+ Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 5c187da413..c0a2b0ecca 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -1,6 +1,15 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Moq;
using Xunit;
@@ -46,4 +55,284 @@ public class BaseItemTests
Assert.Equal(name, video.GetMediaSourceName(video));
Assert.Equal(altName, video.GetMediaSourceName(videoAlt));
}
+
+ [Theory]
+ // Episode versions share a season folder; the common prefix (not the folder name) yields the label.
+ // Both files carry a suffix (no bare base name), so the shared "- " must be stripped too.
+ [InlineData(
+ "Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
+ "Spider-Noir - S01E02 - Wo ist Flint - Colorized",
+ "Greyscale",
+ "Colorized")]
+ // One version is the bare base name; the other is suffixed.
+ [InlineData(
+ "Spider-Noir - S01E02 - Wo ist Flint",
+ "Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
+ "Spider-Noir - S01E02 - Wo ist Flint",
+ "Greyscale")]
+ // Suffixes share a leading word ("Grey"); the prefix must retreat to the separator, not split it.
+ [InlineData(
+ "Demo - S01E01 - Greyscale",
+ "Demo - S01E01 - Greyish",
+ "Greyscale",
+ "Greyish")]
+ // Underscore separator.
+ [InlineData("Movie (2020)_4K", "Movie (2020)_1080p", "4K", "1080p")]
+ // Dot separator.
+ [InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")]
+ // Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i".
+ [InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")]
+ // A token shared by the descriptors but separated only by spaces (the resolution) must stay in the
+ // label: retreat to the '-' delimiter, not the interior space, so the resolution is kept.
+ [InlineData(
+ "movie (2020) - 2160p Extended",
+ "movie (2020) - 2160p Original",
+ "2160p Extended",
+ "2160p Original")]
+ // Bracketed version labels: the opening bracket is kept in the label.
+ [InlineData(
+ "Blade Runner (1982) [Final Cut] [1080p HEVC AAC]",
+ "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]",
+ "[Final Cut] [1080p HEVC AAC]",
+ "[EE by ADM] [480p HEVC AAC]")]
+ public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt)
+ {
+ var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv";
+ var altPath = "/Shows/Demo/Season 01/" + altName + ".mkv";
+ var commonPrefix = BaseItem.GetCommonVersionPrefix([primaryName, altName]);
+
+ var video = new Video()
+ {
+ Path = primaryPath
+ };
+
+ var videoAlt = new Video()
+ {
+ Path = altPath,
+ };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>()))
+ .Returns((string x) => MediaProtocol.File);
+ var libraryManager = new Mock<ILibraryManager>();
+ // No local alternate versions: these are linked (separate items), so the folder fallback is unavailable.
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()))
+ .Returns(Array.Empty<Guid>());
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix));
+ Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix));
+ }
+
+ [Fact]
+ public void GetAlternateVersion_ReturnsMatchingLocalVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ Assert.Same(alt1, primary.GetAlternateVersion(alt1.Id));
+ Assert.Same(alt2, primary.GetAlternateVersion(alt2.Id));
+ Assert.Same(primary, primary.GetAlternateVersion(primary.Id));
+ Assert.Null(primary.GetAlternateVersion(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void GetAllVersions_FromAnyVersion_ReturnsEveryVersionOnce()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ foreach (var source in new[] { primary, alt1, alt2 })
+ {
+ var versions = source.GetAllVersions();
+
+ Assert.Equal(3, versions.Count);
+ Assert.Contains(versions, v => v.Id.Equals(primary.Id));
+ Assert.Contains(versions, v => v.Id.Equals(alt1.Id));
+ Assert.Contains(versions, v => v.Id.Equals(alt2.Id));
+ }
+ }
+
+ [Fact]
+ public void PropagatePlayedState_MarksAlternateVersions_AndResetsPositionByDefault()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var saved = CaptureSaves();
+
+ var user = new User("test", "default", "default");
+ primary.PropagatePlayedState(user, true);
+
+ // Both alternate versions are marked played, the primary (self) is not, and the position is
+ // reset so a watched version does not linger in "Continue Watching".
+ Assert.Equal(2, saved.Count);
+ Assert.DoesNotContain(saved, e => e.ItemId.Equals(primary.Id));
+ Assert.Contains(saved, e => e.ItemId.Equals(alt1.Id));
+ Assert.Contains(saved, e => e.ItemId.Equals(alt2.Id));
+ Assert.All(saved, e =>
+ {
+ Assert.True(e.Dto.Played.GetValueOrDefault());
+ Assert.Equal(0, e.Dto.PlaybackPositionTicks);
+ });
+ }
+
+ [Fact]
+ public void PropagatePlayedState_WithoutReset_LeavesPositionUntouched()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+
+ var saved = CaptureSaves();
+
+ primary.PropagatePlayedState(new User("test", "default", "default"), true, resetPosition: false);
+
+ Assert.Equal(2, saved.Count);
+ Assert.All(saved, e =>
+ {
+ Assert.True(e.Dto.Played.GetValueOrDefault());
+ Assert.Null(e.Dto.PlaybackPositionTicks);
+ });
+ }
+
+ [Fact]
+ public void PropagatePlayedState_Unwatched_ClearsAllWatchedStateOnVersions()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ // Each alternate starts out watched, with a play count, resume point and last-played date.
+ var existing = new Dictionary<Guid, UserItemData>
+ {
+ [alt1.Id] = new UserItemData { Key = "alt1", Played = true, PlayCount = 3, PlaybackPositionTicks = 1000, LastPlayedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
+ [alt2.Id] = new UserItemData { Key = "alt2", Played = true, PlayCount = 1, PlaybackPositionTicks = 500, LastPlayedDate = new DateTime(2021, 2, 2, 0, 0, 0, DateTimeKind.Utc) },
+ };
+
+ var saved = new List<UserItemData>();
+ var userDataManager = new Mock<IUserDataManager>();
+ userDataManager.Setup(x => x.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
+ .Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id));
+ userDataManager
+ .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<UserDataSaveReason>(), It.IsAny<CancellationToken>()))
+ .Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>((_, _, data, _, _) => saved.Add(data));
+ BaseItem.UserDataManager = userDataManager.Object;
+
+ primary.PropagatePlayedState(new User("test", "default", "default"), false);
+
+ // Every alternate is fully reset to an unwatched state, mirroring MarkUnplayed: the played flag,
+ // play count, resume point and last-played date are all cleared so no watched state lingers.
+ Assert.Equal(2, saved.Count);
+ Assert.All(saved, d =>
+ {
+ Assert.False(d.Played);
+ Assert.Equal(0, d.PlayCount);
+ Assert.Equal(0, d.PlaybackPositionTicks);
+ Assert.Null(d.LastPlayedDate);
+ });
+ }
+
+ private static List<(Guid ItemId, UpdateUserItemDataDto Dto)> CaptureSaves()
+ {
+ var saved = new List<(Guid ItemId, UpdateUserItemDataDto Dto)>();
+ var userDataManager = new Mock<IUserDataManager>();
+ userDataManager
+ .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()))
+ .Callback<User, BaseItem, UpdateUserItemDataDto, UserDataSaveReason>((_, item, dto, _) => saved.Add((item.Id, dto)));
+ BaseItem.UserDataManager = userDataManager.Object;
+ return saved;
+ }
+
+ [Fact]
+ public void PropagatePlayedState_SingleVersion_DoesNothing()
+ {
+ var solo = new Video { Id = Guid.NewGuid(), Path = "/Movies/Solo/Solo.mkv" };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var userDataManager = new Mock<IUserDataManager>();
+ BaseItem.UserDataManager = userDataManager.Object;
+
+ solo.PropagatePlayedState(new User("test", "default", "default"), true);
+
+ userDataManager.Verify(
+ x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()),
+ Times.Never);
+ }
+
+ private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
+ {
+ var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
+ var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
+ var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
+
+ // 2160p primary, 1080p alternates: width is only the ordering tiebreaker, set so it would place
+ // the primary first — letting the tests confirm the queried version's own source still wins.
+ var widths = new Dictionary<Guid, int> { [primary.Id] = 3840, [alt1.Id] = 1920, [alt2.Id] = 1920 };
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>()))
+ .Returns((Guid id) => new List<MediaStream> { new MediaStream { Type = MediaStreamType.Video, Width = widths.GetValueOrDefault(id) } });
+ mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>());
+
+ var segmentManager = new Mock<IMediaSegmentManager>();
+ segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false);
+ BaseItem.MediaSegmentManager = segmentManager.Object;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
+ libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
+ libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
+
+ var recordingsManager = new Mock<IRecordingsManager>();
+ recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null);
+ Video.RecordingsManager = recordingsManager.Object;
+
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ return (primary, alt1, alt2);
+ }
+
+ [Fact]
+ public void GetMediaSources_DefaultsToTheQueriedVersionsOwnSource()
+ {
+ var (primary, alt1, _) = SetupVersionGroup();
+
+ // Resuming the 1080p alternate must default to the 1080p source, not the higher-resolution
+ // 2160p primary that the width ordering would otherwise place first.
+ Assert.Equal(alt1.Id.ToString("N"), alt1.GetMediaSources(false)[0].Id);
+
+ // Opening the primary still defaults to the primary's own (here highest-resolution) source.
+ Assert.Equal(primary.Id.ToString("N"), primary.GetMediaSources(false)[0].Id);
+ }
+
+ [Fact]
+ public void GetAllItemsForMediaSources_FromAnyVersion_HasNoDuplicates()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var method = typeof(Video).GetMethod("GetAllItemsForMediaSources", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ // Each version must surface exactly once, regardless of which member the list is built from.
+ // Building from an alternate previously re-added that alternate as a "local alternate" of the
+ // primary, producing a duplicate entry in the version dropdown.
+ foreach (var source in new[] { primary, alt1, alt2 })
+ {
+ var items = (IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)>)method!.Invoke(source, null)!;
+ var ids = items.Select(i => i.Item.Id).ToList();
+
+ Assert.Equal(3, ids.Count);
+ Assert.Equal(ids.Count, ids.Distinct().Count());
+ Assert.Contains(primary.Id, ids);
+ Assert.Contains(alt1.Id, ids);
+ Assert.Contains(alt2.Id, ids);
+ }
+ }
}
diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
new file mode 100644
index 0000000000..7d87d5ee92
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
@@ -0,0 +1,89 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Library;
+
+public class VersionResumeDataTests
+{
+ [Fact]
+ public void ApplyTo_CompletedOtherVersion_PropagatesCompletionAndClearsStaleResume()
+ {
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ var resume = new VersionResumeData(
+ Guid.NewGuid(),
+ new UserItemData { Key = "version", PlaybackPositionTicks = 0, Played = true, LastPlayedDate = lastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 };
+
+ resume.ApplyTo(dto);
+
+ // Completion state propagates to the primary...
+ Assert.True(dto.Played);
+ Assert.Equal(lastPlayed, dto.LastPlayedDate);
+
+ // ...and because the movie was finished on a different version, the primary's own stale resume bar is cleared.
+ Assert.Equal(0, dto.PlaybackPositionTicks);
+ Assert.Null(dto.PlayedPercentage);
+ }
+
+ [Fact]
+ public void ApplyTo_PrimaryOwnProgress_KeepsResumePosition()
+ {
+ var primaryId = Guid.NewGuid();
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+
+ // The winning version is the primary itself (e.g. rewatching): its resume bar must survive.
+ var resume = new VersionResumeData(
+ primaryId,
+ new UserItemData { Key = "primary", PlaybackPositionTicks = 5, Played = true, LastPlayedDate = lastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = primaryId, Key = "primary", PlaybackPositionTicks = 5, Played = true, PlayedPercentage = 20 };
+
+ resume.ApplyTo(dto);
+
+ Assert.True(dto.Played);
+ Assert.Equal(5, dto.PlaybackPositionTicks);
+ Assert.Equal(20, dto.PlayedPercentage);
+ }
+
+ [Fact]
+ public void ApplyTo_InProgressOtherVersion_KeepsPrimaryResumePosition()
+ {
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+
+ // A different version that is in-progress (not finished) must not clear the primary's position.
+ var resume = new VersionResumeData(
+ Guid.NewGuid(),
+ new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = false, LastPlayedDate = lastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 };
+
+ resume.ApplyTo(dto);
+
+ Assert.False(dto.Played);
+ Assert.Equal(1, dto.PlaybackPositionTicks);
+ Assert.Equal(50, dto.PlayedPercentage);
+ }
+
+ [Fact]
+ public void ApplyTo_DoesNotUnsetExistingPlayedOrRegressLastPlayed()
+ {
+ var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
+ var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ var resume = new VersionResumeData(
+ Guid.NewGuid(),
+ new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed };
+
+ resume.ApplyTo(dto);
+
+ // A not-yet-completed version must not clear the primary's own completion, and the more recent
+ // LastPlayedDate is kept.
+ Assert.True(dto.Played);
+ Assert.Equal(primaryLastPlayed, dto.LastPlayedDate);
+ }
+}
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..71b6551d0f
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
@@ -0,0 +1,303 @@
+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 MediaBrowser.Model.MediaInfo;
+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);
+ }
+ }
+
+ [Theory]
+ [InlineData("aac", 44100, 44100)] // non-opus: requested rate must be preserved (issue #17026)
+ [InlineData("aac", 48000, 48000)]
+ [InlineData("mp3", 22050, 22050)]
+ [InlineData("flac", 96000, 96000)]
+ [InlineData("opus", 44100, 48000)] // opus: must snap to a libopus-supported rate
+ [InlineData("opus", 22050, 24000)]
+ [InlineData("opus", 8000, 8000)]
+ public void GetProgressiveAudioFullCommandLine_SampleRate_OnlyClampedForOpus(
+ string audioCodec,
+ int requestedSampleRate,
+ int expectedSampleRate)
+ {
+ var state = BuildAudioState(audioCodec, requestedSampleRate);
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out");
+
+ Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal);
+ }
+
+ private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate)
+ {
+ var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 };
+
+ return new EncodingJobInfo(TranscodingJobType.Progressive)
+ {
+ MediaSource = new MediaSourceInfo
+ {
+ Container = "flac",
+ MediaStreams = new List<MediaStream> { audio },
+ Path = "/media/track.flac",
+ Protocol = MediaProtocol.File,
+ },
+ AudioStream = audio,
+ OutputAudioCodec = audioCodec,
+ BaseRequest = new VideoRequestDto
+ {
+ AudioCodec = audioCodec,
+ AudioSampleRate = requestedSampleRate,
+ },
+ IsVideoRequest = false,
+ IsInputVideo = false,
+ };
+ }
+
+ 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/LiveTvChannelImageHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
new file mode 100644
index 0000000000..f44cb88834
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
@@ -0,0 +1,51 @@
+using Jellyfin.LiveTv;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Entities;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests;
+
+public class LiveTvChannelImageHelperTests
+{
+ [Fact]
+ public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate()
+ {
+ var channel = new LiveTvChannel { Name = "Test Channel" };
+
+ var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null);
+
+ Assert.False(updated);
+ Assert.False(channel.HasImage(ImageType.Primary));
+ }
+
+ [Fact]
+ public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl()
+ {
+ var channel = new LiveTvChannel { Name = "Test Channel" };
+
+ var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
+ channel,
+ null,
+ "https://example.com/icon.png");
+
+ Assert.True(updated);
+ Assert.True(channel.HasImage(ImageType.Primary));
+ Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
+ }
+
+ [Fact]
+ public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates()
+ {
+ var channel = new LiveTvChannel { Name = "Test Channel" };
+ LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png");
+
+ var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
+ channel,
+ null,
+ "https://example.com/icon.png");
+
+ Assert.True(updated);
+ Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
+ }
+}
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/Encoder/ApplePlatformHelperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs
new file mode 100644
index 0000000000..9847acbb0a
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Runtime.Versioning;
+using MediaBrowser.MediaEncoding.Encoder;
+using Xunit;
+
+namespace Jellyfin.MediaEncoding.Tests;
+
+[SupportedOSPlatform("macos")]
+public class ApplePlatformHelperTests
+{
+ [Fact]
+ public void GetSysctlValue_CpuBrand_NotEmpty()
+ {
+ Assert.SkipUnless(OperatingSystem.IsMacOS(), "macOS-only test");
+
+ var value = ApplePlatformHelper.GetSysctlValue("machdep.cpu.brand_string");
+ Assert.NotEmpty(value);
+
+ // Make sure we don't include the null terminator
+ Assert.DoesNotContain("\0", value, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index 3369af0e84..b723fc7208 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Globalization;
using System.IO;
+using System.Linq;
using System.Text.Json;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
@@ -56,6 +57,43 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected)
=> Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar));
+ [Theory]
+ // Lossy codecs, mono/stereo and multichannel.
+ [InlineData("aac", null, 2, 192000)]
+ [InlineData("mp3", null, 2, 192000)]
+ [InlineData("mp2", null, 2, 192000)]
+ [InlineData("aac", null, 6, 320000)]
+ [InlineData("ac3", null, 2, 192000)]
+ [InlineData("eac3", null, 6, 640000)]
+ [InlineData("opus", null, 2, 128000)]
+ [InlineData("vorbis", null, 6, 320000)]
+ [InlineData("wmav2", null, 2, 192000)]
+ // DTS: the lossy core (any non-MA profile, or none) is flat and caps at 5.1...
+ [InlineData("dts", null, 2, 768000)]
+ [InlineData("dts", "DTS", 6, 1509000)]
+ [InlineData("dts", "DTS-HD HRA", 8, 1509000)]
+ // ...while lossless DTS-HD MA scales per channel like other lossless codecs.
+ [InlineData("dts", "DTS-HD MA", 6, 4200000)]
+ [InlineData("dts", "DTS-HD MA + DTS:X", 8, 5600000)]
+ // Lossless codecs scale per channel.
+ [InlineData("flac", null, 2, 960000)]
+ [InlineData("flac", null, 6, 2880000)]
+ [InlineData("flac", null, 8, 3840000)]
+ [InlineData("alac", null, 6, 2880000)]
+ [InlineData("truehd", null, 2, 1400000)]
+ [InlineData("truehd", null, 6, 4200000)]
+ [InlineData("truehd", "Dolby TrueHD + Dolby Atmos", 8, 5600000)]
+ // 3-4 channel audio must use the multichannel estimate, not return null.
+ [InlineData("aac", null, 3, 320000)]
+ [InlineData("ac3", null, 4, 640000)]
+ // Codec matching is case-insensitive.
+ [InlineData("AAC", null, 2, 192000)]
+ // Unknown codec or unknown channel count cannot be estimated.
+ [InlineData("pcm_s16le", null, 2, null)]
+ [InlineData("aac", null, null, null)]
+ public void GetEstimatedAudioBitrate_ReturnsExpected(string codec, string? profile, int? channels, int? expected)
+ => Assert.Equal(expected, ProbeResultNormalizer.GetEstimatedAudioBitrate(codec, profile, channels));
+
[Fact]
public void GetMediaInfo_MetaData_Success()
{
@@ -71,7 +109,10 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal("4:3", res.VideoStream.AspectRatio);
Assert.Equal(25f, res.VideoStream.AverageFrameRate);
Assert.Equal(8, res.VideoStream.BitDepth);
- Assert.Equal(69432, res.VideoStream.BitRate);
+ // ffprobe reports no per-stream video bitrate here. The container bitrate must not be
+ // misreported as the video bitrate, and the other streams' bitrates exceed the container
+ // bitrate in this sample, so no sensible video bitrate can be inferred (see #16248).
+ Assert.Null(res.VideoStream.BitRate);
Assert.Equal("h264", res.VideoStream.Codec);
Assert.Equal("1/50", res.VideoStream.CodecTimeBase);
Assert.Equal(240, res.VideoStream.Height);
@@ -105,10 +146,12 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
var audio1 = res.MediaStreams[1];
Assert.Equal("eac3", audio1.Codec);
+ Assert.True(audio1.IsOriginal);
Assert.Equal(AudioSpatialFormat.DolbyAtmos, audio1.AudioSpatialFormat);
var audio2 = res.MediaStreams[2];
Assert.Equal("dts", audio2.Codec);
+ Assert.False(audio2.IsOriginal);
Assert.Equal(AudioSpatialFormat.DTSX, audio2.AudioSpatialFormat);
Assert.Empty(res.Chapters);
@@ -156,6 +199,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal("aac", res.MediaStreams[1].Codec);
Assert.Equal(7, res.MediaStreams[1].Channels);
Assert.True(res.MediaStreams[1].IsDefault);
+ Assert.False(res.MediaStreams[1].IsOriginal);
Assert.Equal("eng", res.MediaStreams[1].Language);
Assert.Equal("Surround 6.1", res.MediaStreams[1].Title);
@@ -319,6 +363,73 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
}
[Fact]
+ public void GetMediaInfo_MissingVideoBitrate_EstimatedFromContainer()
+ {
+ // ffprobe did not report a per-stream video bitrate. The video bitrate must be estimated
+ // as the container bitrate minus the other (audio) stream bitrates, not reported as the
+ // whole container bitrate (see #16248).
+ var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate.json");
+
+ var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
+ MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate.mp4", MediaProtocol.File);
+
+ Assert.Equal(2, res.MediaStreams.Count);
+
+ Assert.NotNull(res.VideoStream);
+ Assert.Equal(MediaStreamType.Video, res.VideoStream.Type);
+
+ var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio);
+ Assert.Equal(128000, audioStream.BitRate);
+
+ // Container bitrate (5128000) minus the audio bitrate (128000).
+ Assert.Equal(5000000, res.VideoStream.BitRate);
+
+ // The container bitrate itself must remain the overall container bitrate.
+ Assert.Equal(5128000, res.Bitrate);
+ }
+
+ [Fact]
+ public void GetMediaInfo_NanosecondDurationTag_BitrateComputedFromBytes()
+ {
+ // The stream carries NUMBER_OF_BYTES and a nanosecond-precision DURATION tag but no
+ // bitrate. TimeSpan only supports 7 fractional digits, so the 9-digit DURATION must be
+ // trimmed for the duration to parse and the bitrate to be computed (bytes * 8 / seconds).
+ var bytes = File.ReadAllBytes("Test Data/Probing/video_nanosecond_duration_bitrate.json");
+
+ var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
+ MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_nanosecond_duration_bitrate.mkv", MediaProtocol.File);
+
+ Assert.NotNull(res.VideoStream);
+
+ // 10000000 bytes * 8 / 100 seconds.
+ Assert.Equal(800000, res.VideoStream.BitRate);
+ }
+
+ [Fact]
+ public void GetMediaInfo_MissingVideoBitrate_UnknownAudioBitrate_NotEstimated()
+ {
+ // ffprobe reported no per-stream video bitrate and the audio bitrate cannot be estimated
+ // (the audio stream has no channel count, so GetEstimatedAudioBitrate returns null). The
+ // video bitrate must be left unset rather than wrongly absorbing the unaccounted audio
+ // bitrate (see #16248).
+ var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate_unknown_audio.json");
+
+ var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
+ MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate_unknown_audio.mp4", MediaProtocol.File);
+
+ Assert.Equal(2, res.MediaStreams.Count);
+
+ Assert.NotNull(res.VideoStream);
+ Assert.Null(res.VideoStream.BitRate);
+
+ var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio);
+ Assert.Null(audioStream.BitRate);
+
+ // The overall container bitrate is still reported.
+ Assert.Equal(5128000, res.Bitrate);
+ }
+
+ [Fact]
public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success()
{
var bytes = File.ReadAllBytes("Test Data/Probing/video_single_frame_mjpeg.json");
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.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json
new file mode 100644
index 0000000000..803a3a7e5f
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json
@@ -0,0 +1,113 @@
+{
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "h264",
+ "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
+ "profile": "High",
+ "codec_type": "video",
+ "codec_time_base": "1/48",
+ "codec_tag_string": "avc1",
+ "codec_tag": "0x31637661",
+ "width": 1920,
+ "height": 1080,
+ "coded_width": 1920,
+ "coded_height": 1080,
+ "closed_captions": 0,
+ "has_b_frames": 2,
+ "pix_fmt": "yuv420p",
+ "level": 40,
+ "chroma_location": "left",
+ "refs": 1,
+ "is_avc": "true",
+ "nal_length_size": "4",
+ "r_frame_rate": "24/1",
+ "avg_frame_rate": "24/1",
+ "time_base": "1/12288",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration_ts": 3686400,
+ "duration": "300.000000",
+ "bits_per_raw_sample": "8",
+ "nb_frames": "7200",
+ "disposition": {
+ "default": 1,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0
+ },
+ "tags": {
+ "language": "und",
+ "handler_name": "VideoHandler"
+ }
+ },
+ {
+ "index": 1,
+ "codec_name": "aac",
+ "codec_long_name": "AAC (Advanced Audio Coding)",
+ "profile": "LC",
+ "codec_type": "audio",
+ "codec_time_base": "1/48000",
+ "codec_tag_string": "mp4a",
+ "codec_tag": "0x6134706d",
+ "sample_fmt": "fltp",
+ "sample_rate": "48000",
+ "channels": 2,
+ "channel_layout": "stereo",
+ "bits_per_sample": 0,
+ "r_frame_rate": "0/0",
+ "avg_frame_rate": "0/0",
+ "time_base": "1/48000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration_ts": 14400000,
+ "duration": "300.000000",
+ "bit_rate": "128000",
+ "nb_frames": "14063",
+ "disposition": {
+ "default": 1,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0
+ },
+ "tags": {
+ "language": "eng",
+ "handler_name": "SoundHandler"
+ }
+ }
+ ],
+ "format": {
+ "filename": "test.1080p.mp4",
+ "nb_streams": 2,
+ "nb_programs": 0,
+ "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
+ "format_long_name": "QuickTime / MOV",
+ "start_time": "0.000000",
+ "duration": "300.000000",
+ "size": "192000000",
+ "bit_rate": "5128000",
+ "probe_score": 100,
+ "tags": {
+ "major_brand": "isom",
+ "minor_version": "512",
+ "compatible_brands": "isomiso2avc1mp41",
+ "encoder": "Lavf58.20.100"
+ }
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json
new file mode 100644
index 0000000000..ff6dc51f27
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json
@@ -0,0 +1,110 @@
+{
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "h264",
+ "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
+ "profile": "High",
+ "codec_type": "video",
+ "codec_time_base": "1/48",
+ "codec_tag_string": "avc1",
+ "codec_tag": "0x31637661",
+ "width": 1920,
+ "height": 1080,
+ "coded_width": 1920,
+ "coded_height": 1080,
+ "closed_captions": 0,
+ "has_b_frames": 2,
+ "pix_fmt": "yuv420p",
+ "level": 40,
+ "chroma_location": "left",
+ "refs": 1,
+ "is_avc": "true",
+ "nal_length_size": "4",
+ "r_frame_rate": "24/1",
+ "avg_frame_rate": "24/1",
+ "time_base": "1/12288",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration_ts": 3686400,
+ "duration": "300.000000",
+ "bits_per_raw_sample": "8",
+ "nb_frames": "7200",
+ "disposition": {
+ "default": 1,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0
+ },
+ "tags": {
+ "language": "und",
+ "handler_name": "VideoHandler"
+ }
+ },
+ {
+ "index": 1,
+ "codec_name": "dts",
+ "codec_long_name": "DCA (DTS Coherent Acoustics)",
+ "profile": "DTS-HD MA",
+ "codec_type": "audio",
+ "codec_time_base": "1/48000",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "sample_fmt": "s32p",
+ "sample_rate": "48000",
+ "bits_per_sample": 0,
+ "r_frame_rate": "0/0",
+ "avg_frame_rate": "0/0",
+ "time_base": "1/48000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration_ts": 14400000,
+ "duration": "300.000000",
+ "nb_frames": "14063",
+ "disposition": {
+ "default": 1,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0
+ },
+ "tags": {
+ "language": "eng",
+ "handler_name": "SoundHandler"
+ }
+ }
+ ],
+ "format": {
+ "filename": "test.1080p.mp4",
+ "nb_streams": 2,
+ "nb_programs": 0,
+ "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
+ "format_long_name": "QuickTime / MOV",
+ "start_time": "0.000000",
+ "duration": "300.000000",
+ "size": "192000000",
+ "bit_rate": "5128000",
+ "probe_score": 100,
+ "tags": {
+ "major_brand": "isom",
+ "minor_version": "512",
+ "compatible_brands": "isomiso2avc1mp41",
+ "encoder": "Lavf58.20.100"
+ }
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json
new file mode 100644
index 0000000000..ff8b2ca80a
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json
@@ -0,0 +1,49 @@
+{
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "h264",
+ "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
+ "profile": "High",
+ "codec_type": "video",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "width": 1920,
+ "height": 1080,
+ "coded_width": 1920,
+ "coded_height": 1080,
+ "has_b_frames": 2,
+ "pix_fmt": "yuv420p",
+ "level": 40,
+ "r_frame_rate": "24/1",
+ "avg_frame_rate": "24/1",
+ "time_base": "1/1000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "disposition": {
+ "default": 1
+ },
+ "tags": {
+ "language": "eng",
+ "BPS-eng": "",
+ "DURATION-eng": "00:01:40.000000000",
+ "NUMBER_OF_FRAMES-eng": "2400",
+ "NUMBER_OF_BYTES-eng": "10000000"
+ }
+ }
+ ],
+ "format": {
+ "filename": "video_nanosecond_duration_bitrate.mkv",
+ "nb_streams": 1,
+ "nb_programs": 0,
+ "format_name": "matroska,webm",
+ "format_long_name": "Matroska / WebM",
+ "start_time": "0.000000",
+ "duration": "100.000000",
+ "size": "10001000",
+ "probe_score": 100,
+ "tags": {
+ "encoder": "libebml v1.4.2 + libmatroska v1.6.4"
+ }
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 8269ae58cd..d94d56bc20 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -81,25 +81,25 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidPixel", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450
[InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450
[InlineData("AndroidPixel", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450
- [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
// Yatse
[InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)]
[InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
- [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// RokuSSPlus
[InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 should be DirectPlay
[InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
[InlineData("RokuSSPlus", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// JellyfinMediaPlayer
[InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
@@ -118,21 +118,21 @@ namespace Jellyfin.Model.Tests
[InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")]
- [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "DirectStream", "http")] // webm requested, aac not supported
+ [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // webm requested, aac not supported
[InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // #6450
[InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux", "http")] // #6450
// TranscodeMedia
[InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
[InlineData("TranscodeMedia", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
[InlineData("TranscodeMedia", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
[InlineData("TranscodeMedia", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")]
+ [InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")]
[InlineData("TranscodeMedia", "mkv-av1-vorbis-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")]
- [InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")]
- [InlineData("TranscodeMedia", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")]
+ [InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")]
+ [InlineData("TranscodeMedia", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")]
[InlineData("TranscodeMedia", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")]
// DirectMedia
[InlineData("DirectMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")]
@@ -150,9 +150,9 @@ namespace Jellyfin.Model.Tests
[InlineData("LowBandwidth", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
[InlineData("LowBandwidth", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
[InlineData("LowBandwidth", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("LowBandwidth", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("LowBandwidth", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("LowBandwidth", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("LowBandwidth", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("LowBandwidth", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("LowBandwidth", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
// Null
[InlineData("Null", "mp4-h264-aac-vtt-2600k", null, TranscodeReason.ContainerBitrateExceedsLimit)]
[InlineData("Null", "mp4-h264-ac3-aac-srt-2600k", null, TranscodeReason.ContainerBitrateExceedsLimit)]
@@ -170,7 +170,10 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)]
[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", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | 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.VideoCodecNotSupported | 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)]
@@ -252,23 +255,23 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidPixel", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450
[InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450
[InlineData("AndroidPixel", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450
- [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
// Yatse
[InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)]
[InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// RokuSSPlus
[InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 should be DirectPlay
[InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// JellyfinMediaPlayer
[InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
@@ -286,7 +289,7 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)]
[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", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9
// Tizen 3 Stereo
[InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)]
[InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)]
@@ -333,7 +336,7 @@ namespace Jellyfin.Model.Tests
// Yatse
[InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")]
- [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// RokuSSPlus
[InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
@@ -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/Book/BookResolverTests.cs b/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs
new file mode 100644
index 0000000000..19ee13cd75
--- /dev/null
+++ b/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs
@@ -0,0 +1,58 @@
+using Emby.Naming.Book;
+using Xunit;
+
+namespace Jellyfin.Naming.Tests.Book;
+
+public class BookResolverTests
+{
+ [Theory]
+ // seriesName (seriesYear?) #index (of count?) (year?)
+ [InlineData("Sherlock Holmes (1887) #1 (of 4) (1887)", null, "Sherlock Holmes", 1, 1887)]
+ [InlineData("Sherlock Holmes #2", null, "Sherlock Holmes", 2, null)]
+ [InlineData("Sherlock Holmes (1887) #1", null, "Sherlock Holmes", 1, null)]
+ [InlineData("Sherlock Holmes #2 (1890)", null, "Sherlock Holmes", 2, 1890)]
+ // name (seriesName, #index) (year?)
+ [InlineData("A Study in Scarlet (Sherlock Holmes, #1) (1887)", "A Study in Scarlet", "Sherlock Holmes", 1, 1887)]
+ [InlineData("The Adventures of Sherlock Holmes (Sherlock Holmes, #5)", "The Adventures of Sherlock Holmes", "Sherlock Holmes", 5, null)]
+ // name (year)
+ [InlineData("The Sign of the Four (1890)", "The Sign of the Four", null, null, 1890)]
+ [InlineData("The Valley of Fear (1915)", "The Valley of Fear", null, null, 1915)]
+ // index - name (year?)
+ [InlineData("2 - The Sign of the Four (1890)", "The Sign of the Four", null, 2, 1890)]
+ [InlineData("4 - The Valley of Fear", "The Valley of Fear", null, 4, null)]
+ // parse entire string as book name
+ [InlineData("A Study in Scarlet", "A Study in Scarlet", null, null, null)]
+ [InlineData("The Adventures of Sherlock Holmes", "The Adventures of Sherlock Holmes", null, null, null)]
+ // leading zeros on index number
+ [InlineData("00 - Dracula's Guest (1914)", "Dracula's Guest", null, 0, 1914)]
+ [InlineData("01 - Dracula (1897)", "Dracula", null, 1, 1897)]
+ // basic decimal support for prequels and novellas
+ [InlineData("2.0 - Twenty Thousand Leagues Under the Sea", "Twenty Thousand Leagues Under the Sea", null, 2, null)]
+ // TODO decide how to process non-zero decimals
+ [InlineData("2.1 - The Blockade Runners", "2.1 - The Blockade Runners", null, null, null)]
+ public void Resolve_Books(string input, string? name, string? series, int? index, int? year)
+ {
+ var result = BookFileNameParser.Parse(input);
+
+ Assert.Equal(name, result.Name);
+ Assert.Equal(series, result.SeriesName);
+ Assert.Equal(index, result.Index);
+ Assert.Equal(year, result.Year);
+ }
+
+ [Theory]
+ // name volume? chapter? (year?)
+ [InlineData("Captain Marvel Adventures v01 (1941)", "Captain Marvel Adventures v01", null, null, 1, 1941)]
+ [InlineData("Captain Marvel Adventures c120", "Captain Marvel Adventures c120", null, 120, null, null)]
+ [InlineData("Captain Marvel Adventures v01 c120", "Captain Marvel Adventures v01 c120", null, 120, 1, null)]
+ public void Resolve_Comics(string input, string? name, string? series, int? chapter, int? volume, int? year)
+ {
+ var result = BookFileNameParser.Parse(input);
+
+ Assert.Equal(name, result.Name);
+ Assert.Equal(series, result.SeriesName);
+ Assert.Equal(chapter, result.Index);
+ Assert.Equal(volume, result.ParentIndex);
+ Assert.Equal(year, result.Year);
+ }
+}
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/ExternalId/MusicBrainzExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs
index 920529bf73..d35211f387 100644
--- a/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs
@@ -8,6 +8,7 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Providers.Plugins.MusicBrainz;
using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -41,7 +42,7 @@ namespace Jellyfin.Providers.Tests.ExternalId
appHostMock.Setup(h => h.ApplicationVersionString).Returns("1.0.0");
appHostMock.Setup(h => h.ApplicationUserAgentAddress).Returns("localhost");
- _ = new Plugin(appPathsMock.Object, xmlSerializerMock.Object, appHostMock.Object);
+ _ = new Plugin(appPathsMock.Object, xmlSerializerMock.Object, appHostMock.Object, NullLogger<Plugin>.Instance);
}
public void Dispose()
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/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
new file mode 100644
index 0000000000..96625ae670
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
@@ -0,0 +1,137 @@
+using System;
+using Emby.Server.Implementations.Dto;
+using Emby.Server.Implementations.Playlists;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Entities;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Dto;
+
+public class DtoServiceImageInheritanceTests
+{
+ [Fact]
+ public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage()
+ {
+ var displayParent = new PlaylistsFolder
+ {
+ Id = Guid.NewGuid(),
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/playlists-custom.jpg",
+ DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var userView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ ViewType = CollectionType.playlists,
+ DisplayParentId = displayParent.Id,
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/generated.png",
+ DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var dtoService = BuildDtoService(displayParent);
+
+ var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
+
+ Assert.NotNull(dto.ParentPrimaryImageItemId);
+ Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag);
+ Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary));
+ }
+
+ [Fact]
+ public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage()
+ {
+ var displayParent = new PlaylistsFolder
+ {
+ Id = Guid.NewGuid(),
+ ImageInfos = []
+ };
+
+ var userView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ ViewType = CollectionType.playlists,
+ DisplayParentId = displayParent.Id,
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/generated.png",
+ DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var dtoService = BuildDtoService(displayParent);
+
+ var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
+
+ Assert.Null(dto.ParentPrimaryImageItemId);
+ Assert.Null(dto.ParentPrimaryImageTag);
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]);
+ }
+
+ private static DtoService BuildDtoService(BaseItem displayParent)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ var userDataManager = new Mock<IUserDataManager>();
+ var imageProcessor = new Mock<IImageProcessor>();
+ var providerManager = new Mock<IProviderManager>();
+ var recordingsManager = new Mock<IRecordingsManager>();
+ var appHost = new Mock<IApplicationHost>();
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ var liveTvManager = new Mock<ILiveTvManager>();
+ var trickplayManager = new Mock<ITrickplayManager>();
+ var chapterManager = new Mock<IChapterManager>();
+ var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>();
+
+ libraryManager
+ .Setup(x => x.GetItemById(displayParent.Id))
+ .Returns(displayParent);
+
+ imageProcessor
+ .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
+ .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
+
+ return new DtoService(
+ logger.Object,
+ libraryManager.Object,
+ userDataManager.Object,
+ imageProcessor.Object,
+ providerManager.Object,
+ recordingsManager.Object,
+ appHost.Object,
+ mediaSourceManager.Object,
+ new Lazy<ILiveTvManager>(() => liveTvManager.Object),
+ trickplayManager.Object,
+ chapterManager.Object);
+ }
+}
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..9c247d54b9
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -0,0 +1,138 @@
+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 MediaBrowser.Model.Querying;
+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_Episode_AttachesSeasonPosterAsParentPrimaryImage()
+ {
+ var (episode, season, _) = BuildEpisode(seasonHasPoster: true);
+ var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] };
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // The season poster is attached additively; the episode keeps its own primary and 16:9 ratio,
+ // and clients decide per view whether to prefer the parent/series poster over the episode still.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
+ Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
+ // Aspect ratio stays the episode's own image, not the poster's.
+ Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
+ }
+
+ [Fact]
+ public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster()
+ {
+ var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
+ var options = new DtoOptions(false);
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // Episode image is retained; ParentPrimaryImage falls back to the series poster.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
+ Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
+ }
+
+ [Fact]
+ public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary()
+ {
+ var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false);
+ var options = new DtoOptions(false);
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // With no season or series poster there is nothing to attach; the episode keeps its own primary.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Null(dto.ParentPrimaryImageItemId);
+ }
+
+ private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true)
+ {
+ // 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" };
+ if (seriesHasPoster)
+ {
+ 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/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
index c06279af2d..6cadfacce8 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
@@ -10,7 +10,7 @@ using Xunit;
namespace Jellyfin.Server.Implementations.Tests.IO;
-public class ManagedFileSystemTests
+public partial class ManagedFileSystemTests
{
private readonly IFixture _fixture;
private readonly ManagedFileSystem _sut;
@@ -117,7 +117,7 @@ public class ManagedFileSystemTests
}
[SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")]
- [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)]
+ [LibraryImport("libc", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)]
- private static extern int symlink(string target, string linkpath);
+ private static partial int symlink([MarshalAs(UnmanagedType.LPStr)] string target, [MarshalAs(UnmanagedType.LPStr)] string linkpath);
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
new file mode 100644
index 0000000000..c8aa14af58
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -0,0 +1,233 @@
+#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes.
+
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Verifies that the alternate-version-aware query shapes used by the resume filter
+/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
+/// and evaluate correctly on the SQLite provider.
+/// </summary>
+public sealed class AlternateVersionQueryTranslationTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+
+ public AlternateVersionQueryTranslationTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+ }
+
+ [Fact]
+ public void ResumeFilter_VersionProgress_SurfacesPlayedVersion()
+ {
+ Guid userId, primaryId, versionId, otherId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, versionId, otherId) = Seed(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var inProgress = ctx.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ // Scope to the seeded items; EnsureCreated also seeds a placeholder row.
+ var seededIds = new[] { primaryId, versionId, otherId };
+
+ // Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery.
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+ var resumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id))
+ .Where(e => inProgressIds.Contains(e.Id))
+ .Where(e => !ctx.BaseItems
+ .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)))
+ .Select(e => e.Id)
+ .ToList();
+
+ Assert.Equal([versionId], resumable);
+
+ // The not-resumable direction keeps primaries only.
+ var resumableMovieIds = inProgress
+ .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
+ var notResumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .Where(e => !resumableMovieIds.Contains(e.Id))
+ .Select(e => e.Id)
+ .ToList();
+
+ Assert.Equal([otherId], notResumable);
+ }
+ }
+
+ [Fact]
+ public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion()
+ {
+ Guid userId, primaryId, versionAId, versionBId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var inProgress = ctx.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ var seededIds = new[] { primaryId, versionAId, versionBId };
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+
+ // The exact production dedup, including the Guid.CompareTo tie-break. This asserts the
+ // expression translates on SQLite and that two versions sharing an identical LastPlayedDate
+ // collapse to a single row instead of double-listing the item in Continue Watching.
+ var resumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id))
+ .Where(e => inProgressIds.Contains(e.Id))
+ .Where(e => !ctx.BaseItems
+ .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)))
+ .Select(e => e.Id)
+ .ToList();
+
+ var survivor = Assert.Single(resumable);
+ Assert.Contains(survivor, new[] { versionAId, versionBId });
+ }
+ }
+
+ [Fact]
+ public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate()
+ {
+ Guid userId, primaryId, otherId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, _, otherId) = Seed(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ // Scope to the seeded items; EnsureCreated also seeds a placeholder row.
+ var seededIds = new[] { primaryId, otherId };
+
+ // Mirrors the DatePlayed mapping in OrderMapper.
+ var ordered = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .OrderByDescending(e => ctx.UserData
+ .Where(w => w.UserId == userId && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
+ .Max(f => f.LastPlayedDate))
+ .Select(e => e.Id)
+ .ToList();
+
+ // The movie whose only progress is on its alternate version sorts before the unplayed one.
+ Assert.Equal([primaryId, otherId], ordered);
+ }
+ }
+
+ private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx)
+ {
+ var user = new User("test", "auth-provider", "reset-provider");
+ ctx.Users.Add(user);
+
+ var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ var version = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ var other = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ ctx.BaseItems.AddRange(primary, version, other);
+
+ // Progress only on the alternate version.
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = version.Id,
+ Item = version,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = version.Id.ToString("N"),
+ PlaybackPositionTicks = 1000,
+ LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)
+ });
+
+ ctx.SaveChanges();
+ return (user.Id, primary.Id, version.Id, other.Id);
+ }
+
+ private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx)
+ {
+ var user = new User("test", "auth-provider", "reset-provider");
+ ctx.Users.Add(user);
+
+ var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ ctx.BaseItems.AddRange(primary, versionA, versionB);
+
+ // Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break.
+ var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = versionA.Id,
+ Item = versionA,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = versionA.Id.ToString("N"),
+ PlaybackPositionTicks = 1000,
+ LastPlayedDate = tied
+ });
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = versionB.Id,
+ Item = versionB,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = versionB.Id.ToString("N"),
+ PlaybackPositionTicks = 2000,
+ LastPlayedDate = tied
+ });
+
+ ctx.SaveChanges();
+ return (user.Id, primary.Id, versionA.Id, versionB.Id);
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
new file mode 100644
index 0000000000..083f725db9
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class BaseItemRepositoryGroupingTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly BaseItemRepository _repository;
+ private readonly string _movieTypeName;
+
+ public BaseItemRepositoryGroupingTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ var itemTypeLookup = new ItemTypeLookup();
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+
+ var serverConfigurationManager = new Mock<IServerConfigurationManager>();
+ serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _repository = new BaseItemRepository(
+ factory.Object,
+ new Mock<IServerApplicationHost>().Object,
+ itemTypeLookup,
+ serverConfigurationManager.Object,
+ NullLogger<BaseItemRepository>.Instance);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public void GetItemList_VersionGroup_ReturnsPrimaryVersion()
+ {
+ // The alternate version sorts before the primary by id, so a plain Min(Id) per
+ // presentation key would wrongly pick the alternate as the group representative.
+ var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ var alternateId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var presentationKey = primaryId.ToString("N");
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie", presentationKey, null));
+ ctx.BaseItems.Add(CreateMovieEntity(alternateId, "Movie - 1080p", presentationKey, primaryId));
+ ctx.SaveChanges();
+ }
+
+ var result = _repository.GetItemList(CreateQuery());
+
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_GroupWithoutPrimary_FallsBackToMinId()
+ {
+ var firstId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+ var otherPrimaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ var presentationKey = otherPrimaryId.ToString("N");
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(firstId, "Movie", presentationKey, otherPrimaryId));
+ ctx.BaseItems.Add(CreateMovieEntity(secondId, "Movie - 4K", presentationKey, otherPrimaryId));
+ ctx.SaveChanges();
+ }
+
+ var result = _repository.GetItemList(CreateQuery());
+
+ var item = Assert.Single(result);
+ Assert.Equal(firstId, item.Id);
+ }
+
+ private static InternalItemsQuery CreateQuery()
+ {
+ // IncludeOwnedItems keeps the alternate version rows in the query so the
+ // grouping collapse is what picks the group representative.
+ return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie],
+ IncludeOwnedItems = true
+ };
+ }
+
+ private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ PresentationUniqueKey = presentationKey,
+ PrimaryVersionId = primaryVersionId,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
index 958ffb8b6e..29de52a2ba 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
+++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
@@ -4,6 +4,7 @@
<PropertyGroup>
<ProjectGuid>{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}</ProjectGuid>
<OutputType>Exe</OutputType>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index 8ed3d8b944..c80f899498 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -1,9 +1,22 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
using AutoFixture;
using AutoFixture.AutoMoq;
+using Castle.Components.DictionaryAdapter;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
+using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Library
@@ -11,12 +24,28 @@ namespace Jellyfin.Server.Implementations.Tests.Library
public class MediaSourceManagerTests
{
private readonly MediaSourceManager _mediaSourceManager;
+ private readonly Mock<IUserDataManager> _mockUserDataManager;
+ private readonly Mock<ILocalizationManager> _mockLocalizationManager;
+ private Video _item;
+ private User _user;
public MediaSourceManagerTests()
{
IFixture fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
fixture.Inject<IFileSystem>(fixture.Create<ManagedFileSystem>());
+
+ _mockUserDataManager = fixture.Freeze<Mock<IUserDataManager>>();
+ _mockUserDataManager.Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())).Returns(new UserItemData() { Key = "key" });
+
+ _mockLocalizationManager = fixture.Create<Mock<ILocalizationManager>>();
+ _mockLocalizationManager.Setup(m => m.FindLanguageInfo(It.IsAny<string>())).Returns((string s) => string.IsNullOrEmpty(s) ? null : new CultureDto(s, s, s, new EditableList<string> { s }));
+ fixture.Inject(_mockLocalizationManager.Object);
+
_mediaSourceManager = fixture.Create<MediaSourceManager>();
+
+ _item = new Video { Id = Guid.NewGuid(), OwnerId = Guid.Empty, ParentId = Guid.Empty };
+
+ _user = fixture.Create<User>();
}
[Theory]
@@ -28,5 +57,196 @@ namespace Jellyfin.Server.Implementations.Tests.Library
[InlineData("rtsp://media.example.com:554/twister/audiotrack", MediaProtocol.Rtsp)]
public void GetPathProtocol_ValidArg_Correct(string path, MediaProtocol expected)
=> Assert.Equal(expected, _mediaSourceManager.GetPathProtocol(path));
+
+ [Theory]
+ [InlineData(5, "eng", "eng", false, true)]
+ [InlineData(5, "eng", "eng", true, true)]
+ [InlineData(2, "ger", "eng", false, true)]
+ [InlineData(2, "ger", "eng", true, true)]
+ [InlineData(1, "fre", "eng", false, true)]
+ [InlineData(2, "fre", "eng", true, true)]
+ [InlineData(5, "OriginalLanguage", "eng", false, false)]
+ [InlineData(4, "OriginalLanguage", "eng", false, true)]
+ [InlineData(5, "OriginalLanguage", "eng", true, false)]
+ [InlineData(5, "OriginalLanguage", "eng", true, true)]
+ [InlineData(2, "OriginalLanguage", "jpn", true, true)]
+ [InlineData(2, "OriginalLanguage", "jpn", false, true)]
+ [InlineData(2, "OriginalLanguage", "jpn,eng", false, true)]
+ [InlineData(4, "OriginalLanguage", null, false, true)]
+ [InlineData(2, "OriginalLanguage", null, true, true)]
+ [InlineData(4, "OriginalLanguage", "", false, true)]
+ [InlineData(2, "OriginalLanguage", "", false, false)]
+ [InlineData(2, "OriginalLanguage", "ger", false, true)]
+ [InlineData(2, "OriginalLanguage", "ger", false, false)]
+ [InlineData(1, "OriginalLanguage", "fre", false, false)]
+ [InlineData(2, "OriginalLanguage", "fre", true, true)]
+ [InlineData(2, "OriginalLanguage", "fre", true, false)]
+ public void SetDefaultAudioStreamIndex_Index_Correct(
+ int expectedIndex,
+ string prefferedLanguage,
+ string? originalLanguage,
+ bool playDefault,
+ bool originalExist)
+ {
+ var streams = new MediaStream[]
+ {
+ new()
+ {
+ Index = 0,
+ Type = MediaStreamType.Video,
+ IsDefault = true
+ },
+ new()
+ {
+ Index = 1,
+ Type = MediaStreamType.Audio,
+ Language = "fre",
+ IsDefault = false,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 2,
+ Type = MediaStreamType.Audio,
+ Language = "jpn",
+ IsDefault = true,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 3,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = false,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 4,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = false,
+ IsOriginal = originalExist,
+ },
+ new()
+ {
+ Index = 5,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = true,
+ IsOriginal = false,
+ }
+ };
+ var mediaInfo = new MediaSourceInfo
+ {
+ MediaStreams = streams
+ };
+ _user.AudioLanguagePreference = prefferedLanguage;
+ _user.PlayDefaultAudioTrack = playDefault;
+ _item.OriginalLanguage = originalLanguage;
+
+ _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
+ Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ SetupUserDataBatch(new Dictionary<Guid, UserItemData>
+ {
+ [alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
+ [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
+ });
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
+
+ // The most recently played version is the default source, so resuming plays the right file.
+ // Per-user positions live in each version's UserData, not on the source.
+ Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ SetupUserDataBatch(new Dictionary<Guid, UserItemData>
+ {
+ [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
+ });
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user);
+
+ // An explicitly opened version keeps its own source first, even when a sibling was
+ // played more recently.
+ Assert.Equal(alt1.Id.ToString("N"), sources[0].Id);
+ Assert.Equal(3, sources.Count);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+ SetupUserDataBatch([]);
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
+
+ Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_NoUser_DoesNotTouchUserData()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false);
+
+ Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
+ _mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()), Times.Never);
+ }
+
+ private void SetupUserDataBatch(Dictionary<Guid, UserItemData> userData)
+ {
+ _mockUserDataManager
+ .Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()))
+ .Returns((IReadOnlyList<BaseItem> items, User _) => items
+ .Where(i => userData.ContainsKey(i.Id))
+ .ToDictionary(i => i.Id, i => userData[i.Id]));
+ }
+
+ private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
+ {
+ var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
+ var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
+ var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
+
+ // BaseItem.GetMediaSources runs against the static service locators.
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())).Returns(new List<MediaStream>());
+ mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>());
+
+ var segmentManager = new Mock<IMediaSegmentManager>();
+ segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
+ libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
+ libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
+
+ var recordingsManager = new Mock<IRecordingsManager>();
+ recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null);
+
+ BaseItem.MediaSegmentManager = segmentManager.Object;
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+ Video.RecordingsManager = recordingsManager.Object;
+
+ return (primary, alt1, alt2);
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/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..bdb726f06d 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,112 @@ 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 GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation()
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+
+ // he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive,
+ // so the region casing has to be preserved or the file is not found and we fall back to en-US.
+ var translated = localizationManager.GetLocalizedString("Books", "he-IL");
+ Assert.Equal("ספרים", 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/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs
new file mode 100644
index 0000000000..bd9e680cd9
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.SyncPlay;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class GroupTests
+{
+ public GroupTests()
+ {
+ var mockLogger = new Mock<ILogger<Emby.Server.Implementations.SyncPlay.Group>>();
+ MockLoggerFactory = new Mock<ILoggerFactory>();
+ MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(mockLogger.Object);
+
+ MockUserManager = new Mock<IUserManager>();
+ MockSessionManager = new Mock<ISessionManager>();
+ MockLibraryManager = new Mock<ILibraryManager>();
+ MockItem = new Mock<BaseItem>();
+ MockItem.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ }
+
+ private Mock<ILoggerFactory> MockLoggerFactory { get; }
+
+ private Mock<IUserManager> MockUserManager { get; }
+
+ private Mock<ISessionManager> MockSessionManager { get; }
+
+ private Mock<ILibraryManager> MockLibraryManager { get; }
+
+ private Mock<BaseItem> MockItem { get; }
+
+ [Fact]
+ public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible()
+ {
+ MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(MockItem.Object);
+
+ var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object);
+ var itemId = Guid.NewGuid();
+ var playlist = new List<Guid> { itemId };
+ group.PlayQueue.Reset();
+ group.PlayQueue.SetPlaylist(playlist);
+
+ Assert.Single(group.PlayQueue.GetPlaylist());
+ Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId);
+
+ var user = new User("test-user", "auth-provider", "pwdreset-provider");
+ var result = group.HasAccessToPlayQueue(user);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem()
+ {
+ MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null);
+
+ Assert.Null(MockLibraryManager.Object.GetItemById(Guid.NewGuid()));
+
+ var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object);
+ var itemId = Guid.NewGuid();
+ var playlist = new List<Guid> { itemId };
+ group.PlayQueue.Reset();
+ group.PlayQueue.SetPlaylist(playlist);
+
+ Assert.Single(group.PlayQueue.GetPlaylist());
+ Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId);
+
+ var user = new User("test-user", "auth-provider", "pwdreset-provider");
+ var result = group.HasAccessToPlayQueue(user);
+
+ Assert.False(result);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
index 92e10c9f92..4a10b2f607 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
@@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
Assert.Null(ex);
}
+
+ [Theory]
+ [InlineData("../evil")]
+ [InlineData("..\\evil")]
+ [InlineData("../../escape_attempt")]
+ [InlineData("..")]
+ [InlineData(".")]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("foo/bar")]
+ [InlineData("foo\\bar")]
+ [InlineData("/absolute")]
+ [InlineData("foo\0bar")]
+ public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name)
+ {
+ var packageInfo = new InstallationInfo()
+ {
+ Name = name,
+ SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip",
+ Checksum = "11b5b2f1a9ebc4f66d6ef19018543361"
+ };
+
+ await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs
new file mode 100644
index 0000000000..8149938b4d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Threading.Tasks;
+using Jellyfin.Server.Implementations.Users;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public class UserManagerLockHelperTests
+ {
+ [Fact]
+ public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ var key = Guid.NewGuid();
+
+ Assert.True(helper.ShouldLock());
+
+ var outerHandle = await helper.LockAsync(key);
+ Assert.False(helper.ShouldLock());
+
+ var innerHandle = await helper.LockAsync(key);
+ Assert.False(helper.ShouldLock());
+
+ innerHandle.Dispose();
+ Assert.False(helper.ShouldLock());
+
+ outerHandle.Dispose();
+ Assert.True(helper.ShouldLock());
+ }
+
+ [Fact]
+ public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ var key = Guid.NewGuid();
+
+ var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondEntered = false;
+
+ var firstTask = Task.Run(
+ async () =>
+ {
+ using var firstHandle = await helper.LockAsync(key);
+ firstAcquired.SetResult(true);
+ await releaseFirst.Task;
+ },
+ TestContext.Current.CancellationToken);
+
+ await firstAcquired.Task;
+
+ var secondTask = Task.Run(
+ async () =>
+ {
+ using var secondHandle = await helper.LockAsync(key);
+ secondEntered = true;
+ },
+ TestContext.Current.CancellationToken);
+
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+ Assert.False(secondEntered);
+
+ releaseFirst.SetResult(true);
+
+ await Task.WhenAll(firstTask, secondTask);
+ Assert.True(secondEntered);
+ }
+
+ [Fact]
+ public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ helper.Dispose();
+
+ await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+
+ helper.Dispose();
+ var ex = Record.Exception(() => helper.Dispose());
+
+ Assert.Null(ex);
+ }
+ }
+}
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")]
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
index 7ea56be731..4e01282dcf 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
@@ -1,6 +1,5 @@
using System;
using System.Globalization;
-using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
@@ -164,5 +163,33 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
using var response = await UpdateUserPassword(client, _testUserId, createRequest);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
+
+ [Fact]
+ [Priority(2)]
+ public async Task UpdateUser_UsernameCaseDifference_Success()
+ {
+ var client = _factory.CreateClient();
+
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken!);
+
+ using var response = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var userDto = await response.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken);
+ Assert.NotNull(userDto);
+
+ userDto.Name = userDto.Name.ToLowerInvariant();
+
+ using var response2 = await client.PostAsJsonAsync($"Users?userId={_testUserId}", userDto, _jsonOptions, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, response2.StatusCode);
+
+ using var response3 = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var newUserDto = await response3.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken);
+ Assert.NotNull(newUserDto);
+ Assert.Equal(userDto.Name, newUserDto.Name);
+
+ // Sanity check, make sure we're testing something
+ Assert.NotEqual(TestUsername, userDto.Name);
+ }
}
}
diff --git a/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs
new file mode 100644
index 0000000000..fa15b33af6
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Net.WebSockets;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Session;
+using Jellyfin.Api.Models.SyncPlayDtos;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller.Net;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests;
+
+public sealed class SyncPlayLostWebSocketTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+
+ public SyncPlayLostWebSocketTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public async Task LostWebSocket_EndsSession_And_RemovesEmptySyncPlayGroup()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var client = _factory.CreateClient();
+ var accessToken = await AuthHelper.CompleteStartupAsync(client);
+ client.DefaultRequestHeaders.AddAuthHeader(accessToken);
+
+ var wsClient = _factory.Server.CreateWebSocketClient();
+ wsClient.ConfigureRequest = request =>
+ request.Headers.Authorization = AuthHelper.DummyAuthHeader + $", Token={accessToken}";
+
+ var webSocket = await wsClient.ConnectAsync(
+ new UriBuilder(_factory.Server.BaseAddress)
+ {
+ Scheme = "ws",
+ Path = "websocket"
+ }.Uri,
+ cancellationToken);
+
+ _ = DrainAsync(webSocket, cancellationToken);
+
+ var watched = await WaitForWatchedWebSocketsAsync(TimeSpan.FromSeconds(10), cancellationToken);
+ var connection = Assert.Single(watched);
+
+ using var createResponse = await client.PostAsync(
+ "SyncPlay/New",
+ JsonContent.Create(new NewGroupRequestDto { GroupName = "ZombieGroupRepro" }, options: JsonDefaults.Options),
+ cancellationToken);
+ Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
+ Assert.Equal(1, await WaitForGroupCountAsync(client, 1, TimeSpan.FromSeconds(10), cancellationToken));
+
+ connection.LastKeepAliveDate = DateTime.UtcNow - TimeSpan.FromSeconds(180);
+
+ var groupCount = await WaitForGroupCountAsync(client, 0, TimeSpan.FromSeconds(45), cancellationToken);
+ Assert.True(
+ groupCount == 0,
+ $"SyncPlay group still listed {groupCount} group(s) after the WebSocket was lost: "
+ + "the keep-alive watchdog removed the socket from its watchlist without closing "
+ + "the session, leaving a zombie participant in the group (SessionWebSocketListener).");
+ }
+
+ private static async Task DrainAsync(WebSocket webSocket, CancellationToken cancellationToken)
+ {
+ var buffer = new byte[4096];
+ try
+ {
+ while (webSocket.State == WebSocketState.Open)
+ {
+ await webSocket.ReceiveAsync(buffer, cancellationToken);
+ }
+ }
+ catch
+ {
+ // The server tears the connection down once the watchdog gives up on it.
+ }
+ }
+
+ private async Task<IReadOnlyList<IWebSocketConnection>> WaitForWatchedWebSocketsAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var listener = _factory.Services.GetRequiredService<IEnumerable<IWebSocketListener>>()
+ .OfType<SessionWebSocketListener>()
+ .Single();
+ var watchlistField = typeof(SessionWebSocketListener)
+ .GetField("_webSockets", BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(watchlistField);
+ var watchlist = (IEnumerable<IWebSocketConnection>)watchlistField.GetValue(listener)!;
+
+ var stopwatch = Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ var snapshot = watchlist.ToArray();
+ if (snapshot.Length > 0 || stopwatch.Elapsed >= timeout)
+ {
+ return snapshot;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // The watchdog mutated the set during enumeration; retry.
+ }
+
+ await Task.Delay(100, cancellationToken);
+ }
+ }
+
+ private static async Task<int> WaitForGroupCountAsync(HttpClient client, int expected, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var count = -1;
+ while (stopwatch.Elapsed < timeout)
+ {
+ using var response = await client.GetAsync("SyncPlay/List", cancellationToken);
+ response.EnsureSuccessStatusCode();
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
+ count = document.RootElement.GetArrayLength();
+ if (count == expected)
+ {
+ return count;
+ }
+
+ await Task.Delay(500, cancellationToken);
+ }
+
+ return count;
+ }
+}