Browse Source

Merge pull request #7349 from 1337joe/MediaInfoResolver-tests

Bond-009 3 years ago
parent
commit
ce62a4465a

+ 1 - 1
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -887,7 +887,7 @@ namespace MediaBrowser.Controller.Entities
             return Name;
         }
 
-        public virtual string GetInternalMetadataPath()
+        public string GetInternalMetadataPath()
         {
             var basePath = ConfigurationManager.ApplicationPaths.InternalMetadataPath;
 

+ 1 - 1
MediaBrowser.Providers/MediaInfo/AudioResolver.cs

@@ -12,7 +12,7 @@ namespace MediaBrowser.Providers.MediaInfo
     public class AudioResolver : MediaInfoResolver
     {
         /// <summary>
-        /// Initializes a new instance of the <see cref="MediaInfoResolver"/> class for external audio file processing.
+        /// Initializes a new instance of the <see cref="AudioResolver"/> class for external audio file processing.
         /// </summary>
         /// <param name="localizationManager">The localization manager.</param>
         /// <param name="mediaEncoder">The media encoder.</param>

+ 11 - 3
MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs

@@ -43,6 +43,11 @@ namespace MediaBrowser.Providers.MediaInfo
         /// </summary>
         private readonly IMediaEncoder _mediaEncoder;
 
+        /// <summary>
+        /// The <see cref="NamingOptions"/> instance.
+        /// </summary>
+        private readonly NamingOptions _namingOptions;
+
         /// <summary>
         /// The <see cref="DlnaProfileType"/> of the files this resolver should resolve.
         /// </summary>
@@ -62,6 +67,7 @@ namespace MediaBrowser.Providers.MediaInfo
             DlnaProfileType type)
         {
             _mediaEncoder = mediaEncoder;
+            _namingOptions = namingOptions;
             _type = type;
             _externalPathParser = new ExternalPathParser(namingOptions, localizationManager, _type);
         }
@@ -102,7 +108,7 @@ namespace MediaBrowser.Providers.MediaInfo
 
                 if (mediaInfo.MediaStreams.Count == 1)
                 {
-                    MediaStream mediaStream = mediaInfo.MediaStreams.First();
+                    MediaStream mediaStream = mediaInfo.MediaStreams[0];
                     mediaStream.Index = startIndex++;
                     mediaStream.IsDefault = pathInfo.IsDefault || mediaStream.IsDefault;
                     mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced;
@@ -159,9 +165,11 @@ namespace MediaBrowser.Providers.MediaInfo
 
             foreach (var file in files)
             {
-                if (_compareInfo.IsPrefix(Path.GetFileNameWithoutExtension(file), video.FileNameWithoutExtension, CompareOptions, out int matchLength))
+                var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file);
+                if (_compareInfo.IsPrefix(fileNameWithoutExtension, video.FileNameWithoutExtension, CompareOptions, out int matchLength)
+                    && (fileNameWithoutExtension.Length == matchLength || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[matchLength].ToString())))
                 {
-                    var externalPathInfo = _externalPathParser.ParseFile(file, Path.GetFileNameWithoutExtension(file)[matchLength..]);
+                    var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[matchLength..]);
 
                     if (externalPathInfo != null)
                     {

+ 1 - 1
MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs

@@ -12,7 +12,7 @@ namespace MediaBrowser.Providers.MediaInfo
     public class SubtitleResolver : MediaInfoResolver
     {
         /// <summary>
-        /// Initializes a new instance of the <see cref="MediaInfoResolver"/> class for external subtitle file processing.
+        /// Initializes a new instance of the <see cref="SubtitleResolver"/> class for external subtitle file processing.
         /// </summary>
         /// <param name="localizationManager">The localization manager.</param>
         /// <param name="mediaEncoder">The media encoder.</param>

+ 111 - 0
tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs

@@ -0,0 +1,111 @@
+using System.Text.RegularExpressions;
+using Emby.Naming.Common;
+using Emby.Naming.ExternalFiles;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Globalization;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Naming.Tests.ExternalFiles;
+
+public class ExternalPathParserTests
+{
+    private readonly ExternalPathParser _audioPathParser;
+    private readonly ExternalPathParser _subtitlePathParser;
+
+    public ExternalPathParserTests()
+    {
+        var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" });
+        var frenchCultureDto = new CultureDto("French", "French", "fr", new[] { "fre", "fra" });
+
+        var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose);
+        localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase)))
+            .Returns(englishCultureDto);
+        localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"fr.*", RegexOptions.IgnoreCase)))
+            .Returns(frenchCultureDto);
+
+        _audioPathParser = new ExternalPathParser(new NamingOptions(), localizationManager.Object, DlnaProfileType.Audio);
+        _subtitlePathParser = new ExternalPathParser(new NamingOptions(), localizationManager.Object, DlnaProfileType.Subtitle);
+    }
+
+    [Theory]
+    [InlineData("")]
+    [InlineData("MyVideo.ass")]
+    [InlineData("MyVideo.mks")]
+    [InlineData("MyVideo.sami")]
+    [InlineData("MyVideo.srt")]
+    [InlineData("MyVideo.m4v")]
+    public void ParseFile_AudioExtensionsNotMatched_ReturnsNull(string path)
+    {
+        Assert.Null(_audioPathParser.ParseFile(path, string.Empty));
+    }
+
+    [Theory]
+    [InlineData("MyVideo.aa")]
+    [InlineData("MyVideo.aac")]
+    [InlineData("MyVideo.flac")]
+    [InlineData("MyVideo.m4a")]
+    [InlineData("MyVideo.mka")]
+    [InlineData("MyVideo.mp3")]
+    public void ParseFile_AudioExtensionsMatched_ReturnsPath(string path)
+    {
+        var actual = _audioPathParser.ParseFile(path, string.Empty);
+        Assert.NotNull(actual);
+        Assert.Equal(path, actual!.Path);
+    }
+
+    [Theory]
+    [InlineData("")]
+    [InlineData("MyVideo.aa")]
+    [InlineData("MyVideo.aac")]
+    [InlineData("MyVideo.flac")]
+    [InlineData("MyVideo.mka")]
+    [InlineData("MyVideo.m4v")]
+    public void ParseFile_SubtitleExtensionsNotMatched_ReturnsNull(string path)
+    {
+        Assert.Null(_subtitlePathParser.ParseFile(path, string.Empty));
+    }
+
+    [Theory]
+    [InlineData("MyVideo.ass")]
+    [InlineData("MyVideo.mks")]
+    [InlineData("MyVideo.sami")]
+    [InlineData("MyVideo.srt")]
+    [InlineData("MyVideo.vtt")]
+    public void ParseFile_SubtitleExtensionsMatched_ReturnsPath(string path)
+    {
+        var actual = _subtitlePathParser.ParseFile(path, string.Empty);
+        Assert.NotNull(actual);
+        Assert.Equal(path, actual!.Path);
+    }
+
+    [Theory]
+    [InlineData("", null, null)]
+    [InlineData(".default", null, null, true, false)]
+    [InlineData(".forced", null, null, false, true)]
+    [InlineData(".foreign", null, null, false, true)]
+    [InlineData(".default.forced", null, null, true, true)]
+    [InlineData(".forced.default", null, null, true, true)]
+    [InlineData(".DEFAULT.FORCED", null, null, true, true)]
+    [InlineData(".en", null, "eng")]
+    [InlineData(".EN", null, "eng")]
+    [InlineData(".fr.en", "fr", "eng")]
+    [InlineData(".en.fr", "en", "fre")]
+    [InlineData(".title.en.fr", "title.en", "fre")]
+    [InlineData(".Title Goes Here", "Title Goes Here", null)]
+    [InlineData(".Title.with.Separator", "Title.with.Separator", null)]
+    [InlineData(".title.en.default.forced", "title", "eng", true, true)]
+    [InlineData(".forced.default.en.title", "title", "eng", true, true)]
+    public void ParseFile_ExtraTokens_ParseToValues(string tokens, string? title, string? language, bool isDefault = false, bool isForced = false)
+    {
+        var path = "My.Video" + tokens + ".srt";
+
+        var actual = _subtitlePathParser.ParseFile(path, tokens);
+
+        Assert.NotNull(actual);
+        Assert.Equal(title, actual!.Title);
+        Assert.Equal(language, actual.Language);
+        Assert.Equal(isDefault, actual.IsDefault);
+        Assert.Equal(isForced, actual.IsForced);
+    }
+}

+ 1 - 0
tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj

@@ -13,6 +13,7 @@
 
   <ItemGroup>
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
+    <PackageReference Include="Moq" Version="4.16.1" />
     <PackageReference Include="xunit" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="3.1.2" />

+ 47 - 145
tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs

@@ -1,177 +1,79 @@
-using System;
 using System.Collections.Generic;
-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.Providers.MediaInfo;
 using Moq;
 using Xunit;
 
-namespace Jellyfin.Providers.Tests.MediaInfo
-{
-    public class AudioResolverTests
-    {
-        private const string VideoDirectoryPath = "Test Data/Video";
-        private const string MetadataDirectoryPath = "Test Data/Metadata";
-        private readonly AudioResolver _audioResolver;
+namespace Jellyfin.Providers.Tests.MediaInfo;
 
-        public AudioResolverTests()
-        {
-            var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" });
+public class AudioResolverTests
+{
+    private readonly AudioResolver _audioResolver;
 
-            var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose);
-            localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase)))
-                .Returns(englishCultureDto);
+    public AudioResolverTests()
+    {
+        // prep BaseItem and Video for calls made that expect managers
+        Video.LiveTvManager = Mock.Of<ILiveTvManager>();
 
-            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
-            mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>()))
-                .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo
-                {
-                    MediaStreams = new List<MediaStream>
-                    {
-                        new()
-                    }
-                }));
+        var applicationPaths = new Mock<IServerApplicationPaths>().Object;
+        var serverConfig = new Mock<IServerConfigurationManager>();
+        serverConfig.Setup(c => c.ApplicationPaths)
+            .Returns(applicationPaths);
+        BaseItem.ConfigurationManager = serverConfig.Object;
 
-            _audioResolver = new AudioResolver(localizationManager.Object, mediaEncoder.Object, new NamingOptions());
-        }
+        // build resolver to test with
+        var localizationManager = Mock.Of<ILocalizationManager>();
 
-        [Fact]
-        public async void AddExternalStreamsAsync_GivenMixedFilenames_ReturnsValidSubtitles()
-        {
-            var startIndex = 0;
-            var index = startIndex;
-            var files = new[]
-            {
-                VideoDirectoryPath + "/MyVideo.en.aac",
-                VideoDirectoryPath + "/MyVideo.en.forced.default.dts",
-                VideoDirectoryPath + "/My.Video.mp3",
-                VideoDirectoryPath + "/Some.Other.Video.mp3",
-                VideoDirectoryPath + "/My.Video.png",
-                VideoDirectoryPath + "/My.Video.srt",
-                VideoDirectoryPath + "/My.Video.txt",
-                VideoDirectoryPath + "/My.Video.vtt",
-                VideoDirectoryPath + "/My.Video.ass",
-                VideoDirectoryPath + "/My.Video.sub",
-                VideoDirectoryPath + "/My.Video.ssa",
-                VideoDirectoryPath + "/My.Video.smi",
-                VideoDirectoryPath + "/My.Video.sami",
-                VideoDirectoryPath + "/My.Video.en.mp3",
-                VideoDirectoryPath + "/My.Video.en.forced.mp3",
-                VideoDirectoryPath + "/My.Video.en.default.forced.aac",
-                VideoDirectoryPath + "/My.Video.Label.mp3",
-                VideoDirectoryPath + "/My.Video.With Additional Garbage.en.aac",
-                VideoDirectoryPath + "/My.Video.With.Additional.Garbage.en.mp3"
-            };
-            var metadataFiles = new[]
-            {
-                MetadataDirectoryPath + "/My.Video.en.aac"
-            };
-            var expectedResult = new[]
+        var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+        mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>()))
+            .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo
             {
-                CreateMediaStream(VideoDirectoryPath + "/MyVideo.en.aac", "eng", null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/MyVideo.en.forced.default.dts", "eng", null, index++, isDefault: true, isForced: true),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.mp3", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.en.mp3", "eng", null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.en.forced.mp3", "eng", null, index++, isDefault: false, isForced: true),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.en.default.forced.aac", "eng", null, index++, isDefault: true, isForced: true),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.Label.mp3", null, "Label", index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.With Additional Garbage.en.aac", "eng", "With Additional Garbage", index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.With.Additional.Garbage.en.mp3", "eng", "With.Additional.Garbage", index++),
-                CreateMediaStream(MetadataDirectoryPath + "/My.Video.en.aac", "eng", null, index)
-            };
-
-            BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
-
-            var video = new Mock<Video>();
-            video.CallBase = true;
-            video.Setup(moq => moq.Path).Returns(VideoDirectoryPath + "/My.Video.mkv");
-            video.Setup(moq => moq.GetInternalMetadataPath()).Returns(MetadataDirectoryPath);
-
-            var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
-            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(files);
-            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Metadata"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(metadataFiles);
-
-            var streams = await _audioResolver.GetExternalStreamsAsync(video.Object, startIndex, directoryService.Object, false, CancellationToken.None);
+                MediaStreams = new List<MediaStream>
+                {
+                    new()
+                }
+            }));
 
-            Assert.Equal(expectedResult.Length, streams.Count);
-            for (var i = 0; i < expectedResult.Length; i++)
-            {
-                var expected = expectedResult[i];
-                var actual = streams[i];
+        _audioResolver = new AudioResolver(localizationManager, mediaEncoder.Object, new NamingOptions());
+    }
 
-                Assert.Equal(expected.Index, actual.Index);
-                Assert.Equal(expected.Type, actual.Type);
-                Assert.Equal(expected.IsExternal, actual.IsExternal);
-                Assert.Equal(expected.Path, actual.Path);
-                Assert.Equal(expected.Language, actual.Language);
-                Assert.Equal(expected.Title, actual.Title);
-            }
-        }
+    [Theory]
+    [InlineData("My.Video.srt", false, false)]
+    [InlineData("My.Video.mp3", false, true)]
+    [InlineData("My.Video.srt", true, false)]
+    [InlineData("My.Video.mp3", true, true)]
+    public async void GetExternalStreams_MixedFilenames_PicksAudio(string file, bool metadataDirectory, bool matches)
+    {
+        BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
 
-        [Theory]
-        [InlineData("MyVideo.en.aac", "eng", null, false, false)]
-        [InlineData("MyVideo.en.forced.default.dts", "eng", null, true, true)]
-        [InlineData("My.Video.mp3", null, null, false, false)]
-        [InlineData("My.Video.English.mp3", "eng", null, false, false)]
-        [InlineData("My.Video.Title.mp3", null, "Title", false, false)]
-        [InlineData("My.Video.forced.English.mp3", "eng", null, true, false)]
-        [InlineData("My.Video.default.English.mp3", "eng", null, false, true)]
-        [InlineData("My.Video.English.forced.default.Title.mp3", "eng", "Title", true, true)]
-        public async void AddExternalStreamsAsync_GivenSingleFile_ReturnsExpectedStream(string file, string? language, string? title, bool isForced, bool isDefault)
+        var video = new Movie
         {
-            BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
-
-            var video = new Mock<Video>();
-            video.CallBase = true;
-            video.Setup(moq => moq.Path).Returns(VideoDirectoryPath + "/My.Video.mkv");
-            video.Setup(moq => moq.GetInternalMetadataPath()).Returns(MetadataDirectoryPath);
+            Path = MediaInfoResolverTests.VideoDirectoryPath + "/My.Video.mkv"
+        };
 
-            var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
-            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(new[] { VideoDirectoryPath + "/" + file });
-            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Metadata"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(Array.Empty<string>());
-
-            var streams = await _audioResolver.GetExternalStreamsAsync(video.Object, 0, directoryService.Object, false, CancellationToken.None);
+        var directoryService = MediaInfoResolverTests.GetDirectoryServiceForExternalFile(file, metadataDirectory);
+        var streams = await _audioResolver.GetExternalStreamsAsync(video, 0, directoryService, false, CancellationToken.None);
 
+        if (matches)
+        {
             Assert.Single(streams);
-
             var actual = streams[0];
-
-            var expected = CreateMediaStream(VideoDirectoryPath + "/" + file, language, title, 0, isForced, isDefault);
-            Assert.Equal(expected.Index, actual.Index);
-            Assert.Equal(expected.Type, actual.Type);
-            Assert.Equal(expected.IsExternal, actual.IsExternal);
-            Assert.Equal(expected.Path, actual.Path);
-            Assert.Equal(expected.Language, actual.Language);
-            Assert.Equal(expected.Title, actual.Title);
-            Assert.Equal(expected.IsDefault, actual.IsDefault);
-            Assert.Equal(expected.IsForced, actual.IsForced);
+            Assert.Equal(MediaStreamType.Audio, actual.Type);
         }
-
-        private static MediaStream CreateMediaStream(string path, string? language, string? title, int index, bool isForced = false, bool isDefault = false)
+        else
         {
-            return new()
-            {
-                Index = index,
-                Type = MediaStreamType.Audio,
-                IsExternal = true,
-                Path = path,
-                Language = language,
-                Title = title,
-                IsForced = isForced,
-                IsDefault = isDefault
-            };
+            Assert.Empty(streams);
         }
     }
 }

+ 375 - 0
tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs

@@ -0,0 +1,375 @@
+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.MediaInfo;
+using MediaBrowser.Providers.MediaInfo;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.MediaInfo;
+
+public class MediaInfoResolverTests
+{
+    public const string VideoDirectoryPath = "Test Data/Video";
+    private const string VideoDirectoryRegex = @"Test Data[/\\]Video";
+    private const string MetadataDirectoryPath = "library/00/00000000000000000000000000000000";
+    private 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<ILiveTvManager>();
+
+        var applicationPaths = new Mock<IServerApplicationPaths>().Object;
+        var serverConfig = new Mock<IServerConfigurationManager>();
+        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<ILocalizationManager>(MockBehavior.Loose);
+        localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase)))
+            .Returns(englishCultureDto);
+        _localizationManager = localizationManager.Object;
+
+        var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+        mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>()))
+            .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo
+            {
+                MediaStreams = new List<MediaStream>
+                {
+                    new()
+                }
+            }));
+
+        _subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.Object, new NamingOptions());
+    }
+
+    [Theory]
+    [InlineData("https://url.com/My.Video.mkv")]
+    [InlineData("non-existent/path")]
+    public void GetExternalFiles_BadPaths_ReturnsNoSubtitles(string path)
+    {
+        // need a media source manager capable of returning something other than file protocol
+        var mediaSourceManager = new Mock<IMediaSourceManager>();
+        mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*")))
+            .Returns(MediaProtocol.Http);
+        BaseItem.MediaSourceManager = mediaSourceManager.Object;
+
+        var video = new Movie
+        {
+            Path = path
+        };
+
+        var files = _subtitleResolver.GetExternalFiles(video, Mock.Of<IDirectoryService>(), false);
+
+        Assert.Empty(files);
+    }
+
+    [Theory]
+    [InlineData("My.Video.srt", null)] // exact
+    [InlineData("My.Video.en.srt", "eng")]
+    [InlineData("MyVideo.en.srt", "eng")] // shorter title
+    [InlineData("My _ Video.en.srt", "eng")] // longer title
+    [InlineData("My.Video.en.srt", "eng", true)]
+    public void GetExternalFiles_FuzzyMatching_MatchesAndParsesToken(string file, string? language, bool metadataDirectory = false)
+    {
+        BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+        var video = new Movie
+        {
+            Path = VideoDirectoryPath + "/My.Video.mkv"
+        };
+
+        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("My.Video.mp3")]
+    [InlineData("My.Video.png")]
+    [InlineData("My.Video.txt")]
+    [InlineData("My.Video Sequel.srt")]
+    [InlineData("Some.Other.Video.srt")]
+    public void GetExternalFiles_FuzzyMatching_RejectsNonMatches(string file)
+    {
+        BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+        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("non-existent/path")]
+    [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<IMediaSourceManager>();
+        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<IDirectoryService>(MockBehavior.Strict);
+        directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>()))
+            .Returns(Array.Empty<string>());
+
+        var mediaEncoder = Mock.Of<IMediaEncoder>(MockBehavior.Strict);
+
+        var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder, new NamingOptions());
+
+        var streams = await subtitleResolver.GetExternalStreamsAsync(video, 0, directoryService.Object, false, CancellationToken.None);
+
+        Assert.Empty(streams);
+    }
+
+    private static TheoryData<string, MediaStream[], MediaStream[]> GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly_Data()
+    {
+        var data = new TheoryData<string, MediaStream[], MediaStream[]>();
+
+        // 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<IMediaSourceManager>();
+
+        var video = new Movie
+        {
+            Path = VideoDirectoryPath + "/My.Video.mkv"
+        };
+
+        var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+        mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>()))
+            .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo
+            {
+                MediaStreams = inputStreams.ToList()
+            }));
+
+        var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.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<IMediaSourceManager>();
+
+        var video = new Movie
+        {
+            Path = VideoDirectoryPath + "/My.Video.mkv"
+        };
+
+        var files = new string[fileCount];
+        for (int i = 0; i < fileCount; i++)
+        {
+            files[i] = $"{VideoDirectoryPath}/MyVideo.{i}.srt";
+        }
+
+        var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+        directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+            .Returns(files);
+        directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+            .Returns(Array.Empty<string>());
+
+        List<MediaStream> GenerateMediaStreams()
+        {
+            var mediaStreams = new List<MediaStream>();
+            for (int i = 0; i < streamCount; i++)
+            {
+                mediaStreams.Add(new());
+            }
+
+            return mediaStreams;
+        }
+
+        var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+        mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>()))
+            .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo
+            {
+                MediaStreams = GenerateMediaStreams()
+            }));
+
+        var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.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
+        };
+    }
+
+    /// <summary>
+    /// Provides an <see cref="IDirectoryService"/> that when queried for the test video/metadata directory will return a path including the provided file name.
+    /// </summary>
+    /// <param name="file">The name of the file to locate.</param>
+    /// <param name="useMetadataDirectory"><c>true</c> if the file belongs in the metadata directory.</param>
+    /// <returns>A mocked <see cref="IDirectoryService"/>.</returns>
+    public static IDirectoryService GetDirectoryServiceForExternalFile(string file, bool useMetadataDirectory = false)
+    {
+        var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+        if (useMetadataDirectory)
+        {
+            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+                .Returns(Array.Empty<string>());
+            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+                .Returns(new[] { MetadataDirectoryPath + "/" + file });
+        }
+        else
+        {
+            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+                .Returns(new[] { VideoDirectoryPath + "/" + file });
+            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+                .Returns(Array.Empty<string>());
+        }
+
+        return directoryService.Object;
+    }
+}

+ 47 - 168
tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs

@@ -1,200 +1,79 @@
-using System;
 using System.Collections.Generic;
-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.Providers.MediaInfo;
 using Moq;
 using Xunit;
 
-namespace Jellyfin.Providers.Tests.MediaInfo
-{
-    public class SubtitleResolverTests
-    {
-        private const string VideoDirectoryPath = "Test Data/Video";
-        private const string MetadataDirectoryPath = "Test Data/Metadata";
-        private readonly SubtitleResolver _subtitleResolver;
+namespace Jellyfin.Providers.Tests.MediaInfo;
 
-        public SubtitleResolverTests()
-        {
-            var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" });
-            var frenchCultureDto = new CultureDto("French", "French", "fr", new[] { "fre", "fra" });
+public class SubtitleResolverTests
+{
+    private readonly SubtitleResolver _subtitleResolver;
 
-            var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose);
-            localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase)))
-                .Returns(englishCultureDto);
-            localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"fr.*", RegexOptions.IgnoreCase)))
-                .Returns(frenchCultureDto);
+    public SubtitleResolverTests()
+    {
+        // prep BaseItem and Video for calls made that expect managers
+        Video.LiveTvManager = Mock.Of<ILiveTvManager>();
 
-            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
-            mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>()))
-                .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo
-                {
-                    MediaStreams = new List<MediaStream>
-                    {
-                        new()
-                    }
-                }));
+        var applicationPaths = new Mock<IServerApplicationPaths>().Object;
+        var serverConfig = new Mock<IServerConfigurationManager>();
+        serverConfig.Setup(c => c.ApplicationPaths)
+            .Returns(applicationPaths);
+        BaseItem.ConfigurationManager = serverConfig.Object;
 
-            _subtitleResolver = new SubtitleResolver(localizationManager.Object, mediaEncoder.Object, new NamingOptions());
-        }
+        // build resolver to test with
+        var localizationManager = Mock.Of<ILocalizationManager>();
 
-        [Fact]
-        public async void AddExternalStreamsAsync_GivenMixedFilenames_ReturnsValidSubtitles()
-        {
-            var startIndex = 0;
-            var index = startIndex;
-            var files = new[]
-            {
-                VideoDirectoryPath + "/MyVideo.en.srt",
-                VideoDirectoryPath + "/MyVideo.en.forced.default.sub",
-                VideoDirectoryPath + "/My.Video.mp3",
-                VideoDirectoryPath + "/My.Video.png",
-                VideoDirectoryPath + "/My.Video.srt",
-                VideoDirectoryPath + "/My.Video.txt",
-                VideoDirectoryPath + "/My.Video.vtt",
-                VideoDirectoryPath + "/My.Video.ass",
-                VideoDirectoryPath + "/My.Video.sub",
-                VideoDirectoryPath + "/My.Video.ssa",
-                VideoDirectoryPath + "/My.Video.smi",
-                VideoDirectoryPath + "/My.Video.sami",
-                VideoDirectoryPath + "/My.Video.mks",
-                VideoDirectoryPath + "/My.Video.en.srt",
-                VideoDirectoryPath + "/My.Video.default.en.srt",
-                VideoDirectoryPath + "/My.Video.default.forced.en.srt",
-                VideoDirectoryPath + "/My.Video.en.default.forced.srt",
-                VideoDirectoryPath + "/My.Video.en.With Additional Garbage.sub",
-                VideoDirectoryPath + "/My.Video.With Additional Garbage.English.sub",
-                VideoDirectoryPath + "/My.Video.With.Additional.Garbage.en.srt",
-                VideoDirectoryPath + "/Some.Other.Video.srt"
-            };
-            var metadataFiles = new[]
-            {
-                MetadataDirectoryPath + "/My.Video.en.srt"
-            };
-            var expectedResult = new[]
+        var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+        mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>()))
+            .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo
             {
-                CreateMediaStream(VideoDirectoryPath + "/MyVideo.en.srt", "srt", "eng", null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/MyVideo.en.forced.default.sub", "sub", "eng", null, index++, isDefault: true, isForced: true),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.srt", "srt", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.vtt", "vtt", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.ass", "ass", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.sub", "sub", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.ssa", "ssa", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.smi", "smi", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.sami", "sami", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.mks", "mks", null, null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.en.srt", "srt", "eng", null, index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.default.en.srt", "srt", "eng", null, index++, isDefault: true),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.default.forced.en.srt", "srt", "eng", null, index++, isForced: true, isDefault: true),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.en.default.forced.srt", "srt", "eng", null, index++, isForced: true, isDefault: true),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.en.With Additional Garbage.sub", "sub", "eng", "With Additional Garbage", index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.With Additional Garbage.English.sub", "sub", "eng", "With Additional Garbage", index++),
-                CreateMediaStream(VideoDirectoryPath + "/My.Video.With.Additional.Garbage.en.srt", "srt", "eng", "With.Additional.Garbage", index++),
-                CreateMediaStream(MetadataDirectoryPath + "/My.Video.en.srt", "srt", "eng", null, index)
-            };
-
-            BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
-
-            var video = new Mock<Video>();
-            video.CallBase = true;
-            video.Setup(moq => moq.Path).Returns(VideoDirectoryPath + "/My.Video.mkv");
-            video.Setup(moq => moq.GetInternalMetadataPath()).Returns(MetadataDirectoryPath);
-
-            var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
-            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(files);
-            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Metadata"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(metadataFiles);
-
-            var streams = await _subtitleResolver.GetExternalStreamsAsync(video.Object, startIndex, directoryService.Object, false, CancellationToken.None);
+                MediaStreams = new List<MediaStream>
+                {
+                    new()
+                }
+            }));
 
-            Assert.Equal(expectedResult.Length, streams.Count);
-            for (var i = 0; i < expectedResult.Length; i++)
-            {
-                var expected = expectedResult[i];
-                var actual = streams[i];
+        _subtitleResolver = new SubtitleResolver(localizationManager, mediaEncoder.Object, new NamingOptions());
+    }
 
-                Assert.Equal(expected.Index, actual.Index);
-                Assert.Equal(expected.Type, actual.Type);
-                Assert.Equal(expected.IsExternal, actual.IsExternal);
-                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("My.Video.srt", false, true)]
+    [InlineData("My.Video.mp3", false, false)]
+    [InlineData("My.Video.srt", true, true)]
+    [InlineData("My.Video.mp3", true, false)]
+    public async void GetExternalStreams_MixedFilenames_PicksSubtitles(string file, bool metadataDirectory, bool matches)
+    {
+        BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
 
-        [Theory]
-        [InlineData("MyVideo.en.srt", "srt", "eng", null, false, false)]
-        [InlineData("MyVideo.en.forced.default.srt", "srt", "eng", null, true, true)]
-        [InlineData("My.Video.srt", "srt", null, null, false, false)]
-        [InlineData("My.Video.foreign.srt", "srt", null, null, true, false)]
-        [InlineData("My.Video.default.srt", "srt", null, null, false, true)]
-        [InlineData("My.Video.forced.default.srt", "srt", null, null, true, true)]
-        [InlineData("My.Video.en.srt", "srt", "eng", null, false, false)]
-        [InlineData("My.Video.fr.en.srt", "srt", "eng", "fr", false, false)]
-        [InlineData("My.Video.en.fr.srt", "srt", "fre", "en", false, false)]
-        [InlineData("My.Video.default.en.srt", "srt", "eng", null, false, true)]
-        [InlineData("My.Video.default.forced.en.srt", "srt", "eng", null, true, true)]
-        [InlineData("My.Video.en.default.forced.srt", "srt", "eng", null, true, true)]
-        [InlineData("My.Video.Track Label.srt", "srt", null, "Track Label", false, false)]
-        [InlineData("My.Video.Track.Label.srt", "srt", null, "Track.Label", false, false)]
-        [InlineData("My.Video.Track Label.en.default.forced.srt", "srt", "eng", "Track Label", true, true)]
-        [InlineData("My.Video.en.default.forced.Track Label.srt", "srt", "eng", "Track Label", true, true)]
-        public async void AddExternalStreamsAsync_GivenSingleFile_ReturnsExpectedSubtitle(string file, string codec, string? language, string? title, bool isForced, bool isDefault)
+        var video = new Movie
         {
-            BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+            Path = MediaInfoResolverTests.VideoDirectoryPath + "/My.Video.mkv"
+        };
 
-            var video = new Mock<Video>();
-            video.CallBase = true;
-            video.Setup(moq => moq.Path).Returns(VideoDirectoryPath + "/My.Video.mkv");
-            video.Setup(moq => moq.GetInternalMetadataPath()).Returns(MetadataDirectoryPath);
-
-            var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
-            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(new[] { VideoDirectoryPath + "/" + file });
-            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Metadata"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(Array.Empty<string>());
-
-            var streams = await _subtitleResolver.GetExternalStreamsAsync(video.Object, 0, directoryService.Object, false, CancellationToken.None);
+        var directoryService = MediaInfoResolverTests.GetDirectoryServiceForExternalFile(file, metadataDirectory);
+        var streams = await _subtitleResolver.GetExternalStreamsAsync(video, 0, directoryService, false, CancellationToken.None);
 
+        if (matches)
+        {
             Assert.Single(streams);
             var actual = streams[0];
-
-            var expected = CreateMediaStream(VideoDirectoryPath + "/" + file, codec, language, title, 0, isForced, isDefault);
-            Assert.Equal(expected.Index, actual.Index);
-            Assert.Equal(expected.Type, actual.Type);
-            Assert.Equal(expected.IsExternal, actual.IsExternal);
-            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);
+            Assert.Equal(MediaStreamType.Subtitle, actual.Type);
         }
-
-        private static MediaStream CreateMediaStream(string path, string codec, string? language, string? title, int index, bool isForced = false, bool isDefault = false)
+        else
         {
-            return new()
-            {
-                Index = index,
-                Codec = codec,
-                Type = MediaStreamType.Subtitle,
-                IsExternal = true,
-                Path = path,
-                IsDefault = isDefault,
-                IsForced = isForced,
-                Language = language,
-                Title = title
-            };
+            Assert.Empty(streams);
         }
     }
 }