diff options
Diffstat (limited to 'tests/Jellyfin.Controller.Tests')
4 files changed, 780 insertions, 0 deletions
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); + } +} |
