diff options
Diffstat (limited to 'tests')
42 files changed, 4641 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 198cdaa4fc..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); @@ -322,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 0b103debad..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,10 +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.VideoRotationNotSupported, "Transcode")] // #13712 + [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)] @@ -255,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 @@ -289,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)] @@ -336,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 @@ -675,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.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 66eec077dc..1f523f7f21 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -136,6 +136,65 @@ namespace Jellyfin.Networking.Tests } /// <summary> + /// Verifies that IPv4 entries whose '!' polarity doesn't match the requested pass are skipped silently, + /// not logged as invalid. Callers parse the same list twice (LAN and excluded) so the off-polarity + /// entries are expected, not erroneous. + /// </summary> + [Fact] + public static void TryParseToSubnets_PolarityMismatchIPv4_DoesNotWarn() + { + var logger = new Mock<ILogger>(); + var values = new[] { "127.0.0.0/8", "192.168.178.0/24", "!10.0.0.0/8" }; + + // Non-negated pass picks up the two non-'!' entries and ignores '!10.0.0.0/8' silently. + Assert.True(NetworkUtils.TryParseToSubnets(values, out var lanResult, false, logger.Object)); + Assert.NotNull(lanResult); + Assert.Equal(2, lanResult.Count); + + // Negated pass picks up the single '!' entry and ignores the others silently. + Assert.True(NetworkUtils.TryParseToSubnets(values, out var excludedResult, true, logger.Object)); + Assert.NotNull(excludedResult); + Assert.Single(excludedResult); + + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + /// <summary> + /// Same as the IPv4 case but for IPv6 entries — makes sure the polarity pre-check works + /// for IPv6 CIDR notation (with '::') as well. + /// </summary> + [Fact] + public static void TryParseToSubnets_PolarityMismatchIPv6_DoesNotWarn() + { + var logger = new Mock<ILogger>(); + var values = new[] { "fd00::/8", "fe80::/10", "!fd12:3456:789a::/48" }; + + Assert.True(NetworkUtils.TryParseToSubnets(values, out var lanResult, false, logger.Object)); + Assert.NotNull(lanResult); + Assert.Equal(2, lanResult.Count); + + Assert.True(NetworkUtils.TryParseToSubnets(values, out var excludedResult, true, logger.Object)); + Assert.NotNull(excludedResult); + Assert.Single(excludedResult); + + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + /// <summary> /// Checks if IPv4 address is within a defined subnet. /// </summary> /// <param name="netMask">Network mask.</param> diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs index 99604e0933..aaa500b762 100644 --- a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs @@ -1,7 +1,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; -using MediaBrowser.Providers.Plugins.ComicVine; +using MediaBrowser.Providers.Books.ComicVine; using Xunit; namespace Jellyfin.Providers.Tests.ExternalId diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs index eec64ac53f..b9ce895dbc 100644 --- a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs @@ -1,7 +1,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; -using MediaBrowser.Providers.Plugins.GoogleBooks; +using MediaBrowser.Providers.Books.GoogleBooks; using Xunit; namespace Jellyfin.Providers.Tests.ExternalId diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 87e7a4b564..5749944fcd 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -576,7 +576,8 @@ namespace Jellyfin.Providers.Tests.Manager baseItemManager!, Mock.Of<ILyricManager>(), Mock.Of<IMemoryCache>(), - Mock.Of<IMediaSegmentManager>()); + Mock.Of<IMediaSegmentManager>(), + Mock.Of<ISimilarItemsManager>()); return providerManager; } diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs index a7491f42e9..2438ef06d1 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs @@ -37,9 +37,9 @@ public class FFProbeVideoInfoTests { Assert.Throws<ArgumentException>( () => _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - })); + { + RunTimeTicks = runtime + })); } [Theory] @@ -53,9 +53,9 @@ public class FFProbeVideoInfoTests public void CreateDummyChapters_ValidRuntime_CorrectChaptersCount(long? runtime, int chaptersCount) { var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - }); + { + RunTimeTicks = runtime + }); Assert.Equal(chaptersCount, chapters.Length); } @@ -69,9 +69,9 @@ public class FFProbeVideoInfoTests public void CreateDummyChapters_PositiveRuntime_NoChapterBeyondRuntime(long runtime) { var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - }); + { + RunTimeTicks = runtime + }); Assert.All(chapters, chapter => Assert.True(chapter.StartPositionTicks < runtime)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/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 facdb2bc2e..c80f899498 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using AutoFixture; using AutoFixture.AutoMoq; using Castle.Components.DictionaryAdapter; @@ -7,6 +9,8 @@ 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; @@ -144,5 +148,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library _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; + } +} |
