using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Emby.Naming.Common; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Providers.MediaInfo; using Moq; using Xunit; namespace Jellyfin.Providers.Tests.MediaInfo; public class MediaInfoResolverTests { public const string VideoDirectoryPath = "Test Data/Video"; public const string VideoDirectoryRegex = @"Test Data[/\\]Video"; public const string MetadataDirectoryPath = "library/00/00000000000000000000000000000000"; public const string MetadataDirectoryRegex = @"library.*"; private readonly ILocalizationManager _localizationManager; private readonly MediaInfoResolver _subtitleResolver; public MediaInfoResolverTests() { // prep BaseItem and Video for calls made that expect managers Video.LiveTvManager = Mock.Of(); var applicationPaths = new Mock().Object; var serverConfig = new Mock(); serverConfig.Setup(c => c.ApplicationPaths) .Returns(applicationPaths); BaseItem.ConfigurationManager = serverConfig.Object; // build resolver to test with var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" }); var localizationManager = new Mock(MockBehavior.Loose); localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase))) .Returns(englishCultureDto); _localizationManager = localizationManager.Object; var mediaEncoder = new Mock(MockBehavior.Strict); mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny(), It.IsAny())) .Returns((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo { MediaStreams = new List { new() } })); var fileSystem = new Mock(MockBehavior.Strict); fileSystem.Setup(fs => fs.DirectoryExists(It.IsAny())) .Returns(false); fileSystem.Setup(fs => fs.DirectoryExists(It.IsRegex(VideoDirectoryRegex))) .Returns(true); fileSystem.Setup(fs => fs.DirectoryExists(It.IsRegex(MetadataDirectoryRegex))) .Returns(true); _subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.Object, fileSystem.Object, new NamingOptions()); } [Fact] public void GetExternalFiles_BadProtocol_ReturnsNoSubtitles() { // need a media source manager capable of returning something other than file protocol var mediaSourceManager = new Mock(); mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*"))) .Returns(MediaProtocol.Http); BaseItem.MediaSourceManager = mediaSourceManager.Object; var video = new Movie { Path = "https://url.com/My.Video.mkv" }; Assert.Empty(_subtitleResolver.GetExternalFiles(video, Mock.Of(), false)); } [Theory] [InlineData(false)] [InlineData(true)] public void GetExternalFiles_MissingDirectory_DirectoryNotQueried(bool metadataDirectory) { BaseItem.MediaSourceManager = Mock.Of(); string containingFolderPath, metadataPath; if (metadataDirectory) { containingFolderPath = VideoDirectoryPath; metadataPath = "invalid"; } else { containingFolderPath = "invalid"; metadataPath = MetadataDirectoryPath; } var video = new Mock(); video.Setup(m => m.Path) .Returns(VideoDirectoryPath + "/My.Video.mkv"); video.Setup(m => m.ContainingFolderPath) .Returns(containingFolderPath); video.Setup(m => m.GetInternalMetadataPath()) .Returns(metadataPath); string pathNotFoundRegex = metadataDirectory ? MetadataDirectoryRegex : VideoDirectoryRegex; var directoryService = new Mock(MockBehavior.Strict); // any path other than test target exists and provides an empty listing directoryService.Setup(ds => ds.GetFilePaths(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Array.Empty()); _subtitleResolver.GetExternalFiles(video.Object, directoryService.Object, false); directoryService.Verify( ds => ds.GetFilePaths(It.IsRegex(pathNotFoundRegex), It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData("My.Video.mkv", "My.Video.srt", null)] [InlineData("My.Video.mkv", "My.Video.en.srt", "eng")] [InlineData("My.Video.mkv", "My.Video.en.srt", "eng", true)] [InlineData("Example Movie (2021).mp4", "Example Movie (2021).English.Srt", "eng")] [InlineData("[LTDB] Who Framed Roger Rabbit (1998) - [Bluray-1080p].mkv", "[LTDB] Who Framed Roger Rabbit (1998) - [Bluray-1080p].en.srt", "eng")] public void GetExternalFiles_NameMatching_MatchesAndParsesToken(string movie, string file, string? language, bool metadataDirectory = false) { BaseItem.MediaSourceManager = Mock.Of(); var video = new Movie { Path = VideoDirectoryPath + "/" + movie }; var directoryService = GetDirectoryServiceForExternalFile(file, metadataDirectory); var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList(); Assert.Single(streams); var actual = streams[0]; Assert.Equal(language, actual.Language); Assert.Null(actual.Title); } [Theory] [InlineData("cover.jpg")] [InlineData("My.Video.mp3")] [InlineData("My.Video.png")] [InlineData("My.Video.txt")] [InlineData("My.Video Sequel.srt")] [InlineData("Some.Other.Video.srt")] public void GetExternalFiles_NameMatching_RejectsNonMatches(string file) { BaseItem.MediaSourceManager = Mock.Of(); var video = new Movie { Path = VideoDirectoryPath + "/My.Video.mkv" }; var directoryService = GetDirectoryServiceForExternalFile(file); var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList(); Assert.Empty(streams); } [Theory] [InlineData("https://url.com/My.Video.mkv")] [InlineData(VideoDirectoryPath)] // valid but no files found for this test public async void GetExternalStreams_BadPaths_ReturnsNoSubtitles(string path) { // need a media source manager capable of returning something other than file protocol var mediaSourceManager = new Mock(); mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*"))) .Returns(MediaProtocol.Http); BaseItem.MediaSourceManager = mediaSourceManager.Object; var video = new Movie { Path = path }; var directoryService = new Mock(MockBehavior.Strict); directoryService.Setup(ds => ds.GetFilePaths(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Array.Empty()); var mediaEncoder = Mock.Of(MockBehavior.Strict); var fileSystem = Mock.Of(); var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder, fileSystem, new NamingOptions()); var streams = await subtitleResolver.GetExternalStreamsAsync(video, 0, directoryService.Object, false, CancellationToken.None); Assert.Empty(streams); } private static TheoryData GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly_Data() { var data = new TheoryData(); // filename and stream have no metadata set string file = "My.Video.srt"; data.Add( file, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) }, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) }); // filename has metadata file = "My.Video.Title1.default.forced.en.srt"; data.Add( file, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) }, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title1", 0, true, true) }); // single stream with metadata file = "My.Video.mks"; data.Add( file, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true) }, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true) }); // stream wins for title/language, filename wins for flags when conflicting file = "My.Video.Title2.default.forced.en.srt"; data.Add( file, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0) }, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0, true, true) }); // multiple stream with metadata - filename flags ignored but other data filled in when missing from stream file = "My.Video.Title3.default.forced.en.srt"; data.Add( file, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0, true, true), CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 1) }, new[] { CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title3", 0, true, true), CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 1) }); return data; } [Theory] [MemberData(nameof(GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly_Data))] public async void GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly(string file, MediaStream[] inputStreams, MediaStream[] expectedStreams) { BaseItem.MediaSourceManager = Mock.Of(); var video = new Movie { Path = VideoDirectoryPath + "/My.Video.mkv" }; var mediaEncoder = new Mock(MockBehavior.Strict); mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny(), It.IsAny())) .Returns((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo { MediaStreams = inputStreams.ToList() })); var fileSystem = new Mock(MockBehavior.Strict); fileSystem.Setup(fs => fs.DirectoryExists(It.IsRegex(VideoDirectoryRegex))) .Returns(true); fileSystem.Setup(fs => fs.DirectoryExists(It.IsRegex(MetadataDirectoryRegex))) .Returns(true); var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.Object, fileSystem.Object, new NamingOptions()); var directoryService = GetDirectoryServiceForExternalFile(file); var streams = await subtitleResolver.GetExternalStreamsAsync(video, 0, directoryService, false, CancellationToken.None); Assert.Equal(expectedStreams.Length, streams.Count); for (var i = 0; i < expectedStreams.Length; i++) { var expected = expectedStreams[i]; var actual = streams[i]; Assert.True(actual.IsExternal); Assert.Equal(expected.Index, actual.Index); Assert.Equal(expected.Type, actual.Type); Assert.Equal(expected.Path, actual.Path); Assert.Equal(expected.IsDefault, actual.IsDefault); Assert.Equal(expected.IsForced, actual.IsForced); Assert.Equal(expected.Language, actual.Language); Assert.Equal(expected.Title, actual.Title); } } [Theory] [InlineData(1, 1)] [InlineData(1, 2)] [InlineData(2, 1)] [InlineData(2, 2)] public async void GetExternalStreams_StreamIndex_HandlesFilesAndContainers(int fileCount, int streamCount) { BaseItem.MediaSourceManager = Mock.Of(); var video = new Movie { Path = VideoDirectoryPath + "/My.Video.mkv" }; var files = new string[fileCount]; for (int i = 0; i < fileCount; i++) { files[i] = $"{VideoDirectoryPath}/My.Video.{i}.srt"; } var directoryService = new Mock(MockBehavior.Strict); directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny(), It.IsAny())) .Returns(files); directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny(), It.IsAny())) .Returns(Array.Empty()); List GenerateMediaStreams() { var mediaStreams = new List(); for (int i = 0; i < streamCount; i++) { mediaStreams.Add(new()); } return mediaStreams; } var mediaEncoder = new Mock(MockBehavior.Strict); mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny(), It.IsAny())) .Returns((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo { MediaStreams = GenerateMediaStreams() })); var fileSystem = new Mock(MockBehavior.Strict); fileSystem.Setup(fs => fs.DirectoryExists(It.IsRegex(VideoDirectoryRegex))) .Returns(true); fileSystem.Setup(fs => fs.DirectoryExists(It.IsRegex(MetadataDirectoryRegex))) .Returns(true); var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.Object, fileSystem.Object, new NamingOptions()); int startIndex = 1; var streams = await subtitleResolver.GetExternalStreamsAsync(video, startIndex, directoryService.Object, false, CancellationToken.None); Assert.Equal(fileCount * streamCount, streams.Count); for (var i = 0; i < streams.Count; i++) { Assert.Equal(startIndex + i, streams[i].Index); // intentional integer division to ensure correct number of streams come back from each file Assert.Matches(@$".*\.{i / streamCount}\.srt", streams[i].Path); } } private static MediaStream CreateMediaStream(string path, string? language, string? title, int index, bool isForced = false, bool isDefault = false) { return new MediaStream { Index = index, Type = MediaStreamType.Subtitle, Path = path, IsDefault = isDefault, IsForced = isForced, Language = language, Title = title }; } /// /// Provides an that when queried for the test video/metadata directory will return a path including the provided file name. /// /// The name of the file to locate. /// true if the file belongs in the metadata directory. /// A mocked . public static IDirectoryService GetDirectoryServiceForExternalFile(string file, bool useMetadataDirectory = false) { var directoryService = new Mock(MockBehavior.Strict); if (useMetadataDirectory) { directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny(), It.IsAny())) .Returns(Array.Empty()); directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny(), It.IsAny())) .Returns(new[] { MetadataDirectoryPath + "/" + file }); } else { directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny(), It.IsAny())) .Returns(new[] { VideoDirectoryPath + "/" + file }); directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny(), It.IsAny())) .Returns(Array.Empty()); } return directoryService.Object; } }