Browse Source

Merge pull request #7255 from Shadowghost/external-sub-audio

Joshua M. Boniface 3 years ago
parent
commit
59040bfa7d

+ 140 - 145
Emby.Naming/Common/NamingOptions.cs

@@ -23,47 +23,60 @@ namespace Emby.Naming.Common
         {
             VideoFileExtensions = new[]
             {
-                ".m4v",
+                ".001",
+                ".3g2",
                 ".3gp",
-                ".nsv",
-                ".ts",
-                ".ty",
-                ".strm",
-                ".rm",
-                ".rmvb",
-                ".ifo",
-                ".mov",
-                ".qt",
-                ".divx",
-                ".xvid",
-                ".bivx",
-                ".vob",
-                ".nrg",
-                ".img",
-                ".iso",
-                ".pva",
-                ".wmv",
+                ".amv",
                 ".asf",
                 ".asx",
-                ".ogm",
-                ".m2v",
                 ".avi",
                 ".bin",
+                ".bivx",
+                ".divx",
+                ".dv",
                 ".dvr-ms",
-                ".mpg",
-                ".mpeg",
-                ".mp4",
+                ".f4v",
+                ".fli",
+                ".flv",
+                ".ifo",
+                ".img",
+                ".iso",
+                ".m2t",
+                ".m2ts",
+                ".m2v",
+                ".m4v",
                 ".mkv",
-                ".avc",
-                ".vp3",
-                ".svq3",
+                ".mk3d",
+                ".mov",
+                ".mp2",
+                ".mp4",
+                ".mpe",
+                ".mpeg",
+                ".mpg",
+                ".mts",
+                ".mxf",
+                ".nrg",
+                ".nsv",
                 ".nuv",
+                ".ogg",
+                ".ogm",
+                ".ogv",
+                ".pva",
+                ".qt",
+                ".rec",
+                ".rm",
+                ".rmvb",
+                ".svq3",
+                ".tp",
+                ".ts",
+                ".ty",
                 ".viv",
-                ".dv",
-                ".fli",
-                ".flv",
-                ".001",
-                ".tp"
+                ".vob",
+                ".vp3",
+                ".webm",
+                ".wmv",
+                ".wtv",
+                ".xvid"
             };
 
             VideoFlagDelimiters = new[]
@@ -149,32 +162,20 @@ namespace Emby.Naming.Common
 
             SubtitleFileExtensions = new[]
             {
+                ".ass",
+                ".mks",
+                ".sami",
+                ".smi",
                 ".srt",
                 ".ssa",
-                ".ass",
-                ".sub"
-            };
-
-            SubtitleFlagDelimiters = new[]
-            {
-                '.'
-            };
-
-            SubtitleForcedFlags = new[]
-            {
-                "foreign",
-                "forced"
-            };
-
-            SubtitleDefaultFlags = new[]
-            {
-                "default"
+                ".sub",
+                ".vtt",
             };
 
             AlbumStackingPrefixes = new[]
             {
-                "disc",
                 "cd",
+                "disc",
                 "disk",
                 "vol",
                 "volume"
@@ -182,68 +183,101 @@ namespace Emby.Naming.Common
 
             AudioFileExtensions = new[]
             {
-                ".nsv",
-                ".m4a",
-                ".flac",
+                ".669",
+                ".3gp",
+                ".aa",
                 ".aac",
-                ".strm",
-                ".pls",
-                ".rm",
-                ".mpa",
-                ".wav",
-                ".wma",
-                ".ogg",
-                ".opus",
-                ".mp3",
-                ".mp2",
-                ".mod",
+                ".aax",
+                ".ac3",
+                ".act",
+                ".adp",
+                ".adplug",
+                ".adx",
+                ".afc",
                 ".amf",
-                ".669",
+                ".aif",
+                ".aiff",
+                ".alac",
+                ".amr",
+                ".ape",
+                ".ast",
+                ".au",
+                ".awb",
+                ".cda",
+                ".cue",
                 ".dmf",
+                ".dsf",
                 ".dsm",
+                ".dsp",
+                ".dts",
+                ".dvf",
                 ".far",
+                ".flac",
                 ".gdm",
+                ".gsm",
+                ".gym",
+                ".hps",
                 ".imf",
                 ".it",
                 ".m15",
+                ".m4a",
+                ".m4b",
+                ".mac",
                 ".med",
+                ".mka",
+                ".mmf",
+                ".mod",
+                ".mogg",
+                ".mp2",
+                ".mp3",
+                ".mpa",
+                ".mpc",
+                ".mpp",
+                ".mp+",
+                ".msv",
+                ".nmf",
+                ".nsf",
+                ".nsv",
+                ".oga",
+                ".ogg",
                 ".okt",
+                ".opus",
+                ".pls",
+                ".ra",
+                ".rf64",
+                ".rm",
                 ".s3m",
-                ".stm",
                 ".sfx",
+                ".shn",
+                ".sid",
+                ".spc",
+                ".stm",
+                ".strm",
                 ".ult",
                 ".uni",
-                ".xm",
-                ".sid",
-                ".ac3",
-                ".dts",
-                ".cue",
-                ".aif",
-                ".aiff",
-                ".ape",
-                ".mac",
-                ".mpc",
-                ".mp+",
-                ".mpp",
-                ".shn",
+                ".vox",
+                ".wav",
+                ".wma",
                 ".wv",
-                ".nsf",
-                ".spc",
-                ".gym",
-                ".adplug",
-                ".adx",
-                ".dsp",
-                ".adp",
-                ".ymf",
-                ".ast",
-                ".afc",
-                ".hps",
+                ".xm",
                 ".xsp",
-                ".acc",
-                ".m4b",
-                ".oga",
-                ".dsf",
-                ".mka"
+                ".ymf"
+            };
+
+            MediaFlagDelimiters = new[]
+            {
+                "."
+            };
+
+            MediaForcedFlags = new[]
+            {
+                "foreign",
+                "forced"
+            };
+
+            MediaDefaultFlags = new[]
+            {
+                "default"
             };
 
             EpisodeExpressions = new[]
@@ -648,45 +682,6 @@ namespace Emby.Naming.Common
                 @"^\s*(?<name>[^ ].*?)\s*$"
             };
 
-            var extensions = VideoFileExtensions.ToList();
-
-            extensions.AddRange(new[]
-            {
-                ".mkv",
-                ".m2t",
-                ".m2ts",
-                ".img",
-                ".iso",
-                ".mk3d",
-                ".ts",
-                ".rmvb",
-                ".mov",
-                ".avi",
-                ".mpg",
-                ".mpeg",
-                ".wmv",
-                ".mp4",
-                ".divx",
-                ".dvr-ms",
-                ".wtv",
-                ".ogm",
-                ".ogv",
-                ".asf",
-                ".m4v",
-                ".flv",
-                ".f4v",
-                ".3gp",
-                ".webm",
-                ".mts",
-                ".m2v",
-                ".rec",
-                ".mxf"
-            });
-
-            VideoFileExtensions = extensions
-                .Distinct(StringComparer.OrdinalIgnoreCase)
-                .ToArray();
-
             MultipleEpisodeExpressions = new[]
             {
                 @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
@@ -718,29 +713,29 @@ namespace Emby.Naming.Common
         public string[] AudioFileExtensions { get; set; }
 
         /// <summary>
-        /// Gets or sets list of album stacking prefixes.
+        /// Gets or sets list of external media flag delimiters.
         /// </summary>
-        public string[] AlbumStackingPrefixes { get; set; }
+        public string[] MediaFlagDelimiters { get; set; }
 
         /// <summary>
-        /// Gets or sets list of subtitle file extensions.
+        /// Gets or sets list of external media forced flags.
         /// </summary>
-        public string[] SubtitleFileExtensions { get; set; }
+        public string[] MediaForcedFlags { get; set; }
 
         /// <summary>
-        /// Gets or sets list of subtitles flag delimiters.
+        /// Gets or sets list of external media default flags.
         /// </summary>
-        public char[] SubtitleFlagDelimiters { get; set; }
+        public string[] MediaDefaultFlags { get; set; }
 
         /// <summary>
-        /// Gets or sets list of subtitle forced flags.
+        /// Gets or sets list of album stacking prefixes.
         /// </summary>
-        public string[] SubtitleForcedFlags { get; set; }
+        public string[] AlbumStackingPrefixes { get; set; }
 
         /// <summary>
-        /// Gets or sets list of subtitle default flags.
+        /// Gets or sets list of subtitle file extensions.
         /// </summary>
-        public string[] SubtitleDefaultFlags { get; set; }
+        public string[] SubtitleFileExtensions { get; set; }
 
         /// <summary>
         /// Gets or sets list of episode regular expressions.

+ 116 - 0
Emby.Naming/ExternalFiles/ExternalPathParser.cs

@@ -0,0 +1,116 @@
+using System;
+using System.IO;
+using System.Linq;
+using Emby.Naming.Common;
+using Jellyfin.Extensions;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Globalization;
+
+namespace Emby.Naming.ExternalFiles
+{
+    /// <summary>
+    /// External media file parser class.
+    /// </summary>
+    public class ExternalPathParser
+    {
+        private readonly NamingOptions _namingOptions;
+        private readonly DlnaProfileType _type;
+        private readonly ILocalizationManager _localizationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ExternalPathParser"/> class.
+        /// </summary>
+        /// <param name="localizationManager">The localization manager.</param>
+        /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param>
+        /// <param name="type">The <see cref="DlnaProfileType"/> of the parsed file.</param>
+        public ExternalPathParser(NamingOptions namingOptions, ILocalizationManager localizationManager, DlnaProfileType type)
+        {
+            _localizationManager = localizationManager;
+            _namingOptions = namingOptions;
+            _type = type;
+        }
+
+        /// <summary>
+        /// Parse filename and extract information.
+        /// </summary>
+        /// <param name="path">Path to file.</param>
+        /// <param name="extraString">Part of the filename only containing the extra information.</param>
+        /// <returns>Returns null or an <see cref="ExternalPathParserResult"/> object if parsing is successful.</returns>
+        public ExternalPathParserResult? ParseFile(string path, string? extraString)
+        {
+            if (path.Length == 0)
+            {
+                return null;
+            }
+
+            var extension = Path.GetExtension(path);
+            if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
+                && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
+            {
+                return null;
+            }
+
+            var pathInfo = new ExternalPathParserResult(path);
+
+            if (string.IsNullOrEmpty(extraString))
+            {
+                return pathInfo;
+            }
+
+            foreach (var separator in _namingOptions.MediaFlagDelimiters)
+            {
+                var languageString = extraString;
+                var titleString = string.Empty;
+                int separatorLength = separator.Length;
+
+                while (languageString.Length > 0)
+                {
+                    int lastSeparator = languageString.LastIndexOf(separator, StringComparison.OrdinalIgnoreCase);
+
+                    if (lastSeparator == -1)
+                    {
+                          break;
+                    }
+
+                    string currentSlice = languageString[lastSeparator..];
+                    string currentSliceWithoutSeparator = currentSlice[separatorLength..];
+
+                    if (_namingOptions.MediaDefaultFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase)))
+                    {
+                        pathInfo.IsDefault = true;
+                        extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
+                        languageString = languageString[..lastSeparator];
+                        continue;
+                    }
+
+                    if (_namingOptions.MediaForcedFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase)))
+                    {
+                        pathInfo.IsForced = true;
+                        extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
+                        languageString = languageString[..lastSeparator];
+                        continue;
+                    }
+
+                    // Try to translate to three character code
+                    var culture = _localizationManager.FindLanguageInfo(currentSliceWithoutSeparator);
+
+                    if (culture != null && pathInfo.Language == null)
+                    {
+                        pathInfo.Language = culture.ThreeLetterISOLanguageName;
+                        extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
+                    }
+                    else
+                    {
+                        titleString = currentSlice + titleString;
+                    }
+
+                    languageString = languageString[..lastSeparator];
+                }
+
+                pathInfo.Title = separatorLength <= titleString.Length ? titleString[separatorLength..] : null;
+            }
+
+            return pathInfo;
+        }
+    }
+}

+ 13 - 7
Emby.Naming/Subtitles/SubtitleInfo.cs → Emby.Naming/ExternalFiles/ExternalPathParserResult.cs

@@ -1,17 +1,17 @@
-namespace Emby.Naming.Subtitles
+namespace Emby.Naming.ExternalFiles
 {
     /// <summary>
-    /// Class holding information about subtitle.
+    /// Class holding information about external files.
     /// </summary>
-    public class SubtitleInfo
+    public class ExternalPathParserResult
     {
         /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleInfo"/> class.
+        /// Initializes a new instance of the <see cref="ExternalPathParserResult"/> class.
         /// </summary>
         /// <param name="path">Path to file.</param>
-        /// <param name="isDefault">Is subtitle default.</param>
-        /// <param name="isForced">Is subtitle forced.</param>
-        public SubtitleInfo(string path, bool isDefault, bool isForced)
+        /// <param name="isDefault">Is default.</param>
+        /// <param name="isForced">Is forced.</param>
+        public ExternalPathParserResult(string path, bool isDefault = false, bool isForced = false)
         {
             Path = path;
             IsDefault = isDefault;
@@ -30,6 +30,12 @@ namespace Emby.Naming.Subtitles
         /// <value>The language.</value>
         public string? Language { get; set; }
 
+        /// <summary>
+        /// Gets or sets the title.
+        /// </summary>
+        /// <value>The title.</value>
+        public string? Title { get; set; }
+
         /// <summary>
         /// Gets or sets a value indicating whether this instance is default.
         /// </summary>

+ 0 - 71
Emby.Naming/Subtitles/SubtitleParser.cs

@@ -1,71 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using Emby.Naming.Common;
-using Jellyfin.Extensions;
-
-namespace Emby.Naming.Subtitles
-{
-    /// <summary>
-    /// Subtitle Parser class.
-    /// </summary>
-    public class SubtitleParser
-    {
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleParser"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing SubtitleFileExtensions, SubtitleDefaultFlags, SubtitleForcedFlags and SubtitleFlagDelimiters.</param>
-        public SubtitleParser(NamingOptions options)
-        {
-            _options = options;
-        }
-
-        /// <summary>
-        /// Parse file to determine if is subtitle and <see cref="SubtitleInfo"/>.
-        /// </summary>
-        /// <param name="path">Path to file.</param>
-        /// <returns>Returns null or <see cref="SubtitleInfo"/> object if parsing is successful.</returns>
-        public SubtitleInfo? ParseFile(string path)
-        {
-            if (path.Length == 0)
-            {
-                return null;
-            }
-
-            var extension = Path.GetExtension(path);
-            if (!_options.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
-            {
-                return null;
-            }
-
-            var flags = GetFlags(path);
-            var info = new SubtitleInfo(
-                path,
-                _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)),
-                _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)));
-
-            var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparison.OrdinalIgnoreCase)
-                && !_options.SubtitleForcedFlags.Contains(i, StringComparison.OrdinalIgnoreCase))
-                .ToList();
-
-            // Should have a name, language and file extension
-            if (parts.Count >= 3)
-            {
-                info.Language = parts[^2];
-            }
-
-            return info;
-        }
-
-        private string[] GetFlags(string path)
-        {
-            // Note: the tags need be surrounded be either a space ( ), hyphen -, dot . or underscore _.
-
-            var file = Path.GetFileName(path);
-
-            return file.Split(_options.SubtitleFlagDelimiters, StringSplitOptions.RemoveEmptyEntries);
-        }
-    }
-}

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

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

+ 2 - 1
MediaBrowser.Model/Dlna/DlnaProfileType.cs

@@ -6,6 +6,7 @@ namespace MediaBrowser.Model.Dlna
     {
         Audio = 0,
         Video = 1,
-        Photo = 2
+        Photo = 2,
+        Subtitle = 3
     }
 }

+ 5 - 153
MediaBrowser.Providers/MediaInfo/AudioResolver.cs

@@ -1,176 +1,28 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using System.Threading.Tasks;
-using Emby.Naming.Audio;
 using Emby.Naming.Common;
-using Jellyfin.Extensions;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Dlna;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
-using MediaBrowser.Model.MediaInfo;
 
 namespace MediaBrowser.Providers.MediaInfo
 {
     /// <summary>
-    /// Resolves external audios for videos.
+    /// Resolves external audio files for <see cref="Video"/>.
     /// </summary>
-    public class AudioResolver
+    public class AudioResolver : MediaInfoResolver
     {
-        private readonly ILocalizationManager _localizationManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly NamingOptions _namingOptions;
-
         /// <summary>
-        /// Initializes a new instance of the <see cref="AudioResolver"/> class.
+        /// Initializes a new instance of the <see cref="MediaInfoResolver"/> class for external audio file processing.
         /// </summary>
         /// <param name="localizationManager">The localization manager.</param>
         /// <param name="mediaEncoder">The media encoder.</param>
-        /// <param name="namingOptions">The naming options.</param>
+        /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param>
         public AudioResolver(
             ILocalizationManager localizationManager,
             IMediaEncoder mediaEncoder,
             NamingOptions namingOptions)
-        {
-            _localizationManager = localizationManager;
-            _mediaEncoder = mediaEncoder;
-            _namingOptions = namingOptions;
-        }
-
-        /// <summary>
-        /// Returns the audio streams found in the external audio files for the given video.
-        /// </summary>
-        /// <param name="video">The video to get the external audio streams from.</param>
-        /// <param name="startIndex">The stream index to start adding audio streams at.</param>
-        /// <param name="directoryService">The directory service to search for files.</param>
-        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
-        /// <param name="cancellationToken">The cancellation token to cancel operation.</param>
-        /// <returns>A list of external audio streams.</returns>
-        public async IAsyncEnumerable<MediaStream> GetExternalAudioStreams(
-            Video video,
-            int startIndex,
-            IDirectoryService directoryService,
-            bool clearCache,
-            [EnumeratorCancellation] CancellationToken cancellationToken)
-        {
-            cancellationToken.ThrowIfCancellationRequested();
-
-            if (!video.IsFileProtocol)
-            {
-                yield break;
-            }
-
-            IEnumerable<string> paths = GetExternalAudioFiles(video, directoryService, clearCache);
-            foreach (string path in paths)
+            : base(localizationManager, mediaEncoder, namingOptions, DlnaProfileType.Audio)
             {
-                string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path);
-                Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(path, cancellationToken).ConfigureAwait(false);
-
-                foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
-                {
-                    mediaStream.Index = startIndex++;
-                    mediaStream.Type = MediaStreamType.Audio;
-                    mediaStream.IsExternal = true;
-                    mediaStream.Path = path;
-                    mediaStream.IsDefault = false;
-                    mediaStream.Title = null;
-
-                    if (string.IsNullOrEmpty(mediaStream.Language))
-                    {
-                        // Try to translate to three character code
-                        // Be flexible and check against both the full and three character versions
-                        var language = StringExtensions.RightPart(fileNameWithoutExtension, '.').ToString();
-
-                        if (language != fileNameWithoutExtension)
-                        {
-                            var culture = _localizationManager.FindLanguageInfo(language);
-
-                            language = culture == null ? language : culture.ThreeLetterISOLanguageName;
-                            mediaStream.Language = language;
-                        }
-                    }
-
-                    yield return mediaStream;
-                }
-            }
-        }
-
-        /// <summary>
-        /// Returns the external audio file paths for the given video.
-        /// </summary>
-        /// <param name="video">The video to get the external audio file paths from.</param>
-        /// <param name="directoryService">The directory service to search for files.</param>
-        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
-        /// <returns>A list of external audio file paths.</returns>
-        public IEnumerable<string> GetExternalAudioFiles(
-            Video video,
-            IDirectoryService directoryService,
-            bool clearCache)
-        {
-            if (!video.IsFileProtocol)
-            {
-                yield break;
-            }
-
-            // Check if video folder exists
-            string folder = video.ContainingFolderPath;
-            if (!Directory.Exists(folder))
-            {
-                yield break;
-            }
-
-            string videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
-
-            var files = directoryService.GetFilePaths(folder, clearCache, true);
-            for (int i = 0; i < files.Count; i++)
-            {
-                string file = files[i];
-                if (string.Equals(video.Path, file, StringComparison.OrdinalIgnoreCase)
-                    || !AudioFileParser.IsAudioFile(file, _namingOptions)
-                    || Path.GetExtension(file.AsSpan()).Equals(".strm", StringComparison.OrdinalIgnoreCase))
-                {
-                    continue;
-                }
-
-                string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file);
-                // The audio filename must either be equal to the video filename or start with the video filename followed by a dot
-                if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)
-                    || (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length
-                        && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.'
-                        && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)))
-                {
-                    yield return file;
-                }
-            }
-        }
-
-        /// <summary>
-        /// Returns the media info of the given audio file.
-        /// </summary>
-        /// <param name="path">The path to the audio file.</param>
-        /// <param name="cancellationToken">The cancellation token to cancel operation.</param>
-        /// <returns>The media info for the given audio file.</returns>
-        private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, CancellationToken cancellationToken)
-        {
-            cancellationToken.ThrowIfCancellationRequested();
-
-            return _mediaEncoder.GetMediaInfo(
-                new MediaInfoRequest
-                {
-                    MediaType = DlnaProfileType.Audio,
-                    MediaSource = new MediaSourceInfo
-                    {
-                        Path = path,
-                        Protocol = MediaProtocol.File
-                    }
-                },
-                cancellationToken);
         }
     }
 }

+ 11 - 6
MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs

@@ -19,6 +19,7 @@ using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.MediaInfo;
@@ -39,11 +40,10 @@ namespace MediaBrowser.Providers.MediaInfo
         IHasItemChangeMonitor
     {
         private readonly ILogger<FFProbeProvider> _logger;
-        private readonly SubtitleResolver _subtitleResolver;
         private readonly AudioResolver _audioResolver;
+        private readonly SubtitleResolver _subtitleResolver;
         private readonly FFProbeVideoInfo _videoProber;
         private readonly FFProbeAudioInfo _audioProber;
-
         private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None);
 
         public FFProbeProvider(
@@ -62,7 +62,7 @@ namespace MediaBrowser.Providers.MediaInfo
         {
             _logger = logger;
             _audioResolver = new AudioResolver(localization, mediaEncoder, namingOptions);
-            _subtitleResolver = new SubtitleResolver(BaseItem.LocalizationManager);
+            _subtitleResolver = new SubtitleResolver(localization, mediaEncoder, namingOptions);
             _videoProber = new FFProbeVideoInfo(
                 _logger,
                 mediaSourceManager,
@@ -75,7 +75,8 @@ namespace MediaBrowser.Providers.MediaInfo
                 subtitleManager,
                 chapterManager,
                 libraryManager,
-                _audioResolver);
+                _audioResolver,
+                _subtitleResolver);
             _audioProber = new FFProbeAudioInfo(mediaSourceManager, mediaEncoder, itemRepo, libraryManager);
         }
 
@@ -104,7 +105,9 @@ namespace MediaBrowser.Providers.MediaInfo
 
             if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
                 && !video.SubtitleFiles.SequenceEqual(
-                        _subtitleResolver.GetExternalSubtitleFiles(video, directoryService, false), StringComparer.Ordinal))
+                    _subtitleResolver.GetExternalFiles(video, directoryService, false)
+                    .Select(info => info.Path).ToList(),
+                    StringComparer.Ordinal))
             {
                 _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
                 return true;
@@ -112,7 +115,9 @@ namespace MediaBrowser.Providers.MediaInfo
 
             if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
                 && !video.AudioFiles.SequenceEqual(
-                        _audioResolver.GetExternalAudioFiles(video, directoryService, false), StringComparer.Ordinal))
+                    _audioResolver.GetExternalFiles(video, directoryService, false)
+                    .Select(info => info.Path).ToList(),
+                    StringComparer.Ordinal))
             {
                 _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
                 return true;

+ 11 - 13
MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs

@@ -45,6 +45,7 @@ namespace MediaBrowser.Providers.MediaInfo
         private readonly IChapterManager _chapterManager;
         private readonly ILibraryManager _libraryManager;
         private readonly AudioResolver _audioResolver;
+        private readonly SubtitleResolver _subtitleResolver;
         private readonly IMediaSourceManager _mediaSourceManager;
 
         private readonly long _dummyChapterDuration = TimeSpan.FromMinutes(5).Ticks;
@@ -61,9 +62,11 @@ namespace MediaBrowser.Providers.MediaInfo
             ISubtitleManager subtitleManager,
             IChapterManager chapterManager,
             ILibraryManager libraryManager,
-            AudioResolver audioResolver)
+            AudioResolver audioResolver,
+            SubtitleResolver subtitleResolver)
         {
             _logger = logger;
+            _mediaSourceManager = mediaSourceManager;
             _mediaEncoder = mediaEncoder;
             _itemRepo = itemRepo;
             _blurayExaminer = blurayExaminer;
@@ -74,7 +77,7 @@ namespace MediaBrowser.Providers.MediaInfo
             _chapterManager = chapterManager;
             _libraryManager = libraryManager;
             _audioResolver = audioResolver;
-            _mediaSourceManager = mediaSourceManager;
+            _subtitleResolver = subtitleResolver;
         }
 
         public async Task<ItemUpdateType> ProbeVideo<T>(
@@ -215,7 +218,7 @@ namespace MediaBrowser.Providers.MediaInfo
                 chapters = Array.Empty<ChapterInfo>();
             }
 
-            await AddExternalSubtitles(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
+            await AddExternalSubtitlesAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
 
             await AddExternalAudioAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
 
@@ -526,16 +529,14 @@ namespace MediaBrowser.Providers.MediaInfo
         /// <param name="options">The refreshOptions.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task.</returns>
-        private async Task AddExternalSubtitles(
+        private async Task AddExternalSubtitlesAsync(
             Video video,
             List<MediaStream> currentStreams,
             MetadataRefreshOptions options,
             CancellationToken cancellationToken)
         {
-            var subtitleResolver = new SubtitleResolver(_localization);
-
             var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
-            var externalSubtitleStreams = subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, false);
+            var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken);
 
             var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default ||
                                             options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh;
@@ -589,7 +590,7 @@ namespace MediaBrowser.Providers.MediaInfo
                 // Rescan
                 if (downloadedLanguages.Count > 0)
                 {
-                    externalSubtitleStreams = subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, true);
+                    externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken);
                 }
             }
 
@@ -612,12 +613,9 @@ namespace MediaBrowser.Providers.MediaInfo
             CancellationToken cancellationToken)
         {
             var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1;
-            var externalAudioStreams = _audioResolver.GetExternalAudioStreams(video, startIndex, options.DirectoryService, false, cancellationToken);
+            var externalAudioStreams = await _audioResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken).ConfigureAwait(false);
 
-            await foreach (MediaStream externalAudioStream in externalAudioStreams)
-            {
-                currentStreams.Add(externalAudioStream);
-            }
+            currentStreams = currentStreams.Concat(externalAudioStreams).ToList();
 
             // Select all external audio file paths
             video.AudioFiles = currentStreams.Where(i => i.Type == MediaStreamType.Audio && i.IsExternal).Select(i => i.Path).Distinct().ToArray();

+ 223 - 0
MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs

@@ -0,0 +1,223 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Naming.Common;
+using Emby.Naming.ExternalFiles;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.MediaInfo;
+
+namespace MediaBrowser.Providers.MediaInfo
+{
+    /// <summary>
+    /// Resolves external files for <see cref="Video"/>.
+    /// </summary>
+    public abstract class MediaInfoResolver
+    {
+        /// <summary>
+        /// The <see cref="CompareOptions"/> instance.
+        /// </summary>
+        private const CompareOptions CompareOptions = System.Globalization.CompareOptions.IgnoreCase | System.Globalization.CompareOptions.IgnoreNonSpace | System.Globalization.CompareOptions.IgnoreSymbols;
+
+        /// <summary>
+        /// The <see cref="CompareInfo"/> instance.
+        /// </summary>
+        private readonly CompareInfo _compareInfo = CultureInfo.InvariantCulture.CompareInfo;
+
+        /// <summary>
+        /// The <see cref="ExternalPathParser"/> instance.
+        /// </summary>
+        private readonly ExternalPathParser _externalPathParser;
+
+        /// <summary>
+        /// The <see cref="IMediaEncoder"/> instance.
+        /// </summary>
+        private readonly IMediaEncoder _mediaEncoder;
+
+        /// <summary>
+        /// The <see cref="DlnaProfileType"/> of the files this resolver should resolve.
+        /// </summary>
+        private readonly DlnaProfileType _type;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="MediaInfoResolver"/> class.
+        /// </summary>
+        /// <param name="localizationManager">The localization manager.</param>
+        /// <param name="mediaEncoder">The media encoder.</param>
+        /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param>
+        /// <param name="type">The <see cref="DlnaProfileType"/> of the parsed file.</param>
+        protected MediaInfoResolver(
+            ILocalizationManager localizationManager,
+            IMediaEncoder mediaEncoder,
+            NamingOptions namingOptions,
+            DlnaProfileType type)
+        {
+            _mediaEncoder = mediaEncoder;
+            _type = type;
+            _externalPathParser = new ExternalPathParser(namingOptions, localizationManager, _type);
+        }
+
+        /// <summary>
+        /// Retrieves the external streams for the provided video.
+        /// </summary>
+        /// <param name="video">The <see cref="Video"/> object to search external streams for.</param>
+        /// <param name="startIndex">The stream index to start adding external streams at.</param>
+        /// <param name="directoryService">The directory service to search for files.</param>
+        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>The external streams located.</returns>
+        public async Task<IReadOnlyList<MediaStream>> GetExternalStreamsAsync(
+            Video video,
+            int startIndex,
+            IDirectoryService directoryService,
+            bool clearCache,
+            CancellationToken cancellationToken)
+        {
+            if (!video.IsFileProtocol)
+            {
+                return Array.Empty<MediaStream>();
+            }
+
+            var pathInfos = GetExternalFiles(video, directoryService, clearCache);
+
+            if (!pathInfos.Any())
+            {
+                return Array.Empty<MediaStream>();
+            }
+
+            var mediaStreams = new List<MediaStream>();
+
+            foreach (var pathInfo in pathInfos)
+            {
+                var mediaInfo = await GetMediaInfo(pathInfo.Path, _type, cancellationToken).ConfigureAwait(false);
+
+                if (mediaInfo.MediaStreams.Count == 1)
+                {
+                    MediaStream mediaStream = mediaInfo.MediaStreams.First();
+                    mediaStream.Index = startIndex++;
+                    mediaStream.IsDefault = pathInfo.IsDefault || mediaStream.IsDefault;
+                    mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced;
+
+                    mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
+                }
+                else
+                {
+                    foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
+                    {
+                        mediaStream.Index = startIndex++;
+
+                        mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
+                    }
+                }
+            }
+
+            return mediaStreams.AsReadOnly();
+        }
+
+        /// <summary>
+        /// Returns the external file infos for the given video.
+        /// </summary>
+        /// <param name="video">The <see cref="Video"/> object to search external files for.</param>
+        /// <param name="directoryService">The directory service to search for files.</param>
+        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
+        /// <returns>The external file paths located.</returns>
+        public IReadOnlyList<ExternalPathParserResult> GetExternalFiles(
+            Video video,
+            IDirectoryService directoryService,
+            bool clearCache)
+        {
+            if (!video.IsFileProtocol)
+            {
+                return Array.Empty<ExternalPathParserResult>();
+            }
+
+            // Check if video folder exists
+            string folder = video.ContainingFolderPath;
+            if (!Directory.Exists(folder))
+            {
+                return Array.Empty<ExternalPathParserResult>();
+            }
+
+            var externalPathInfos = new List<ExternalPathParserResult>();
+
+            var files = directoryService.GetFilePaths(folder, clearCache).ToList();
+            files.AddRange(directoryService.GetFilePaths(video.GetInternalMetadataPath(), clearCache));
+
+            if (!files.Any())
+            {
+                return Array.Empty<ExternalPathParserResult>();
+            }
+
+            foreach (var file in files)
+            {
+                if (_compareInfo.IsPrefix(Path.GetFileNameWithoutExtension(file), video.FileNameWithoutExtension, CompareOptions, out int matchLength))
+                {
+                    var externalPathInfo = _externalPathParser.ParseFile(file, Path.GetFileNameWithoutExtension(file)[matchLength..]);
+
+                    if (externalPathInfo != null)
+                    {
+                        externalPathInfos.Add(externalPathInfo);
+                    }
+                }
+            }
+
+            return externalPathInfos;
+        }
+
+        /// <summary>
+        /// Returns the media info of the given file.
+        /// </summary>
+        /// <param name="path">The path to the file.</param>
+        /// <param name="type">The <see cref="DlnaProfileType"/>.</param>
+        /// <param name="cancellationToken">The cancellation token to cancel operation.</param>
+        /// <returns>The media info for the given file.</returns>
+        private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, DlnaProfileType type, CancellationToken cancellationToken)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+
+            return _mediaEncoder.GetMediaInfo(
+                new MediaInfoRequest
+                {
+                    MediaType = type,
+                    MediaSource = new MediaSourceInfo
+                    {
+                        Path = path,
+                        Protocol = MediaProtocol.File
+                    }
+                },
+                cancellationToken);
+        }
+
+        /// <summary>
+        /// Merges path metadata into stream metadata.
+        /// </summary>
+        /// <param name="mediaStream">The <see cref="MediaStream"/> object.</param>
+        /// <param name="pathInfo">The <see cref="ExternalPathParserResult"/> object.</param>
+        /// <returns>The modified mediaStream.</returns>
+        private MediaStream MergeMetadata(MediaStream mediaStream, ExternalPathParserResult pathInfo)
+        {
+            mediaStream.Path = pathInfo.Path;
+            mediaStream.IsExternal = true;
+            mediaStream.Title = string.IsNullOrEmpty(mediaStream.Title) ? (string.IsNullOrEmpty(pathInfo.Title) ? null : pathInfo.Title) : mediaStream.Title;
+            mediaStream.Language = string.IsNullOrEmpty(mediaStream.Language) ? (string.IsNullOrEmpty(pathInfo.Language) ? null : pathInfo.Language) : mediaStream.Language;
+
+            mediaStream.Type = _type switch
+            {
+                DlnaProfileType.Audio => MediaStreamType.Audio,
+                DlnaProfileType.Subtitle => MediaStreamType.Subtitle,
+                _ => mediaStream.Type
+            };
+
+            return mediaStream;
+        }
+    }
+}

+ 14 - 221
MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs

@@ -1,235 +1,28 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
+using Emby.Naming.Common;
 using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Globalization;
 
 namespace MediaBrowser.Providers.MediaInfo
 {
     /// <summary>
-    /// Resolves external subtitles for videos.
+    /// Resolves external subtitle files for <see cref="Video"/>.
     /// </summary>
-    public class SubtitleResolver
+    public class SubtitleResolver : MediaInfoResolver
     {
-        private readonly ILocalizationManager _localization;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleResolver"/> class.
-        /// </summary>
-        /// <param name="localization">The localization manager.</param>
-        public SubtitleResolver(ILocalizationManager localization)
-        {
-            _localization = localization;
-        }
-
-        /// <summary>
-        /// Retrieves the external subtitle streams for the provided video.
-        /// </summary>
-        /// <param name="video">The video to search from.</param>
-        /// <param name="startIndex">The stream index to start adding subtitle streams at.</param>
-        /// <param name="directoryService">The directory service to search for files.</param>
-        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
-        /// <returns>The external subtitle streams located.</returns>
-        public List<MediaStream> GetExternalSubtitleStreams(
-            Video video,
-            int startIndex,
-            IDirectoryService directoryService,
-            bool clearCache)
-        {
-            var streams = new List<MediaStream>();
-
-            if (!video.IsFileProtocol)
-            {
-                return streams;
-            }
-
-            AddExternalSubtitleStreams(streams, video.ContainingFolderPath, video.Path, startIndex, directoryService, clearCache);
-
-            startIndex += streams.Count;
-
-            string folder = video.GetInternalMetadataPath();
-
-            if (!Directory.Exists(folder))
-            {
-                return streams;
-            }
-
-            try
-            {
-                AddExternalSubtitleStreams(streams, folder, video.Path, startIndex, directoryService, clearCache);
-            }
-            catch (IOException)
-            {
-            }
-
-            return streams;
-        }
-
-        /// <summary>
-        /// Locates the external subtitle files for the provided video.
-        /// </summary>
-        /// <param name="video">The video to search from.</param>
-        /// <param name="directoryService">The directory service to search for files.</param>
-        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
-        /// <returns>The external subtitle file paths located.</returns>
-        public IEnumerable<string> GetExternalSubtitleFiles(
-            Video video,
-            IDirectoryService directoryService,
-            bool clearCache)
-        {
-            if (!video.IsFileProtocol)
-            {
-                yield break;
-            }
-
-            var streams = GetExternalSubtitleStreams(video, 0, directoryService, clearCache);
-
-            foreach (var stream in streams)
-            {
-                yield return stream.Path;
-            }
-        }
-
         /// <summary>
-        /// Extracts the subtitle files from the provided list and adds them to the list of streams.
+        /// Initializes a new instance of the <see cref="MediaInfoResolver"/> class for external subtitle file processing.
         /// </summary>
-        /// <param name="streams">The list of streams to add external subtitles to.</param>
-        /// <param name="videoPath">The path to the video file.</param>
-        /// <param name="startIndex">The stream index to start adding subtitle streams at.</param>
-        /// <param name="files">The files to add if they are subtitles.</param>
-        public void AddExternalSubtitleStreams(
-            List<MediaStream> streams,
-            string videoPath,
-            int startIndex,
-            IReadOnlyList<string> files)
-        {
-            var videoFileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(videoPath);
-
-            for (var i = 0; i < files.Count; i++)
+        /// <param name="localizationManager">The localization manager.</param>
+        /// <param name="mediaEncoder">The media encoder.</param>
+        /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param>
+        public SubtitleResolver(
+            ILocalizationManager localizationManager,
+            IMediaEncoder mediaEncoder,
+            NamingOptions namingOptions)
+            : base(localizationManager, mediaEncoder, namingOptions, DlnaProfileType.Subtitle)
             {
-                var fullName = files[i];
-                var extension = Path.GetExtension(fullName.AsSpan());
-                if (!IsSubtitleExtension(extension))
-                {
-                    continue;
-                }
-
-                var fileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(fullName);
-
-                MediaStream mediaStream;
-
-                // The subtitle filename must either be equal to the video filename or start with the video filename followed by a dot
-                if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))
-                {
-                    mediaStream = new MediaStream
-                    {
-                        Index = startIndex++,
-                        Type = MediaStreamType.Subtitle,
-                        IsExternal = true,
-                        Path = fullName
-                    };
-                }
-                else if (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length
-                         && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.'
-                         && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))
-                {
-                    var isForced = fullName.Contains(".forced.", StringComparison.OrdinalIgnoreCase)
-                                   || fullName.Contains(".foreign.", StringComparison.OrdinalIgnoreCase);
-
-                    var isDefault = fullName.Contains(".default.", StringComparison.OrdinalIgnoreCase);
-
-                    // Support xbmc naming conventions - 300.spanish.srt
-                    var languageSpan = fileNameWithoutExtension;
-                    while (languageSpan.Length > 0)
-                    {
-                        var lastDot = languageSpan.LastIndexOf('.');
-                        if (lastDot < videoFileNameWithoutExtension.Length)
-                        {
-                            languageSpan = ReadOnlySpan<char>.Empty;
-                            break;
-                        }
-
-                        var currentSlice = languageSpan[lastDot..];
-                        if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase)
-                            || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase)
-                            || currentSlice.Equals(".foreign", StringComparison.OrdinalIgnoreCase))
-                        {
-                            languageSpan = languageSpan[..lastDot];
-                            continue;
-                        }
-
-                        languageSpan = languageSpan[(lastDot + 1)..];
-                        break;
-                    }
-
-                    var language = languageSpan.ToString();
-                    if (string.IsNullOrWhiteSpace(language))
-                    {
-                        language = null;
-                    }
-                    else
-                    {
-                        // Try to translate to three character code
-                        // Be flexible and check against both the full and three character versions
-                        var culture = _localization.FindLanguageInfo(language);
-
-                        language = culture == null ? language : culture.ThreeLetterISOLanguageName;
-                    }
-
-                    mediaStream = new MediaStream
-                    {
-                        Index = startIndex++,
-                        Type = MediaStreamType.Subtitle,
-                        IsExternal = true,
-                        Path = fullName,
-                        Language = language,
-                        IsForced = isForced,
-                        IsDefault = isDefault
-                    };
-                }
-                else
-                {
-                    continue;
-                }
-
-                mediaStream.Codec = extension.TrimStart('.').ToString().ToLowerInvariant();
-
-                streams.Add(mediaStream);
-            }
-        }
-
-        private static bool IsSubtitleExtension(ReadOnlySpan<char> extension)
-        {
-            return extension.Equals(".srt", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".ssa", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".ass", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".sub", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".vtt", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".smi", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".sami", StringComparison.OrdinalIgnoreCase);
-        }
-
-        private static ReadOnlySpan<char> NormalizeFilenameForSubtitleComparison(string filename)
-        {
-            // Try to account for sloppy file naming
-            filename = filename.Replace("_", string.Empty, StringComparison.Ordinal);
-            filename = filename.Replace(" ", string.Empty, StringComparison.Ordinal);
-            return Path.GetFileNameWithoutExtension(filename.AsSpan());
-        }
-
-        private void AddExternalSubtitleStreams(
-            List<MediaStream> streams,
-            string folder,
-            string videoPath,
-            int startIndex,
-            IDirectoryService directoryService,
-            bool clearCache)
-        {
-            var files = directoryService.GetFilePaths(folder, clearCache, true);
-
-            AddExternalSubtitleStreams(streams, videoPath, startIndex, files);
         }
     }
 }

+ 0 - 41
tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs

@@ -1,41 +0,0 @@
-using Emby.Naming.Common;
-using Emby.Naming.Subtitles;
-using Xunit;
-
-namespace Jellyfin.Naming.Tests.Subtitles
-{
-    public class SubtitleParserTests
-    {
-        private readonly NamingOptions _namingOptions = new NamingOptions();
-
-        [Theory]
-        [InlineData("The Skin I Live In (2011).srt", null, false, false)]
-        [InlineData("The Skin I Live In (2011).eng.srt", "eng", false, false)]
-        [InlineData("The Skin I Live In (2011).eng.default.srt", "eng", true, false)]
-        [InlineData("The Skin I Live In (2011).eng.forced.srt", "eng", false, true)]
-        [InlineData("The Skin I Live In (2011).eng.foreign.srt", "eng", false, true)]
-        [InlineData("The Skin I Live In (2011).eng.default.foreign.srt", "eng", true, true)]
-        [InlineData("The Skin I Live In (2011).default.foreign.eng.srt", "eng", true, true)]
-        public void SubtitleParser_ValidFileName_Parses(string input, string language, bool isDefault, bool isForced)
-        {
-            var parser = new SubtitleParser(_namingOptions);
-
-            var result = parser.ParseFile(input);
-
-            Assert.Equal(language, result?.Language, true);
-            Assert.Equal(isDefault, result?.IsDefault);
-            Assert.Equal(isForced, result?.IsForced);
-            Assert.Equal(input, result?.Path);
-        }
-
-        [Theory]
-        [InlineData("The Skin I Live In (2011).mp4")]
-        [InlineData("")]
-        public void SubtitleParser_InvalidFileName_ReturnsNull(string input)
-        {
-            var parser = new SubtitleParser(_namingOptions);
-
-            Assert.Null(parser.ParseFile(input));
-        }
-    }
-}

+ 177 - 0
tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs

@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+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;
+
+        public AudioResolverTests()
+        {
+            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);
+
+            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()
+                    }
+                }));
+
+            _audioResolver = new AudioResolver(localizationManager.Object, mediaEncoder.Object, new NamingOptions());
+        }
+
+        [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[]
+            {
+                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);
+
+            Assert.Equal(expectedResult.Length, streams.Count);
+            for (var i = 0; i < expectedResult.Length; i++)
+            {
+                var expected = expectedResult[i];
+                var actual = streams[i];
+
+                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("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)
+        {
+            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(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);
+
+            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);
+        }
+
+        private static MediaStream CreateMediaStream(string path, string? language, string? title, int index, bool isForced = false, bool isDefault = false)
+        {
+            return new()
+            {
+                Index = index,
+                Type = MediaStreamType.Audio,
+                IsExternal = true,
+                Path = path,
+                Language = language,
+                Title = title,
+                IsForced = isForced,
+                IsDefault = isDefault
+            };
+        }
+    }
+}

+ 137 - 66
tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs

@@ -1,6 +1,13 @@
-#pragma warning disable CA1002 // Do not expose generic lists
-
+using System;
 using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Providers.MediaInfo;
@@ -11,58 +18,103 @@ namespace Jellyfin.Providers.Tests.MediaInfo
 {
     public class SubtitleResolverTests
     {
-        public static TheoryData<List<MediaStream>, string, int, string[], MediaStream[]> AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData()
+        private const string VideoDirectoryPath = "Test Data/Video";
+        private const string MetadataDirectoryPath = "Test Data/Metadata";
+        private readonly SubtitleResolver _subtitleResolver;
+
+        public SubtitleResolverTests()
         {
-            var data = new TheoryData<List<MediaStream>, string, int, string[], MediaStream[]>();
-
-            var index = 0;
-            data.Add(
-                new List<MediaStream>(),
-                "/video/My.Video.mkv",
-                index,
-                new[]
-                {
-                    "/video/My.Video.mp3",
-                    "/video/My.Video.png",
-                    "/video/My.Video.srt",
-                    "/video/My.Video.txt",
-                    "/video/My.Video.vtt",
-                    "/video/My.Video.ass",
-                    "/video/My.Video.sub",
-                    "/video/My.Video.ssa",
-                    "/video/My.Video.smi",
-                    "/video/My.Video.sami",
-                    "/video/My.Video.en.srt",
-                    "/video/My.Video.default.en.srt",
-                    "/video/My.Video.default.forced.en.srt",
-                    "/video/My.Video.en.default.forced.srt",
-                    "/video/My.Video.With.Additional.Garbage.en.srt",
-                    "/video/My.Video With Additional Garbage.srt"
-                },
-                new[]
+            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);
+
+            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("/video/My.Video.srt", "srt", null, index++),
-                    CreateMediaStream("/video/My.Video.vtt", "vtt", null, index++),
-                    CreateMediaStream("/video/My.Video.ass", "ass", null, index++),
-                    CreateMediaStream("/video/My.Video.sub", "sub", null, index++),
-                    CreateMediaStream("/video/My.Video.ssa", "ssa", null, index++),
-                    CreateMediaStream("/video/My.Video.smi", "smi", null, index++),
-                    CreateMediaStream("/video/My.Video.sami", "sami", null, index++),
-                    CreateMediaStream("/video/My.Video.en.srt", "srt", "en", index++),
-                    CreateMediaStream("/video/My.Video.default.en.srt", "srt", "en", index++, isDefault: true),
-                    CreateMediaStream("/video/My.Video.default.forced.en.srt", "srt", "en", index++, isForced: true, isDefault: true),
-                    CreateMediaStream("/video/My.Video.en.default.forced.srt", "srt", "en", index++, isForced: true, isDefault: true),
-                    CreateMediaStream("/video/My.Video.With.Additional.Garbage.en.srt", "srt", "en", index),
-                });
-
-            return data;
+                    MediaStreams = new List<MediaStream>
+                    {
+                        new()
+                    }
+                }));
+
+            _subtitleResolver = new SubtitleResolver(localizationManager.Object, mediaEncoder.Object, new NamingOptions());
         }
 
-        [Theory]
-        [MemberData(nameof(AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData))]
-        public void AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles(List<MediaStream> streams, string videoPath, int startIndex, string[] files, MediaStream[] expectedResult)
+        [Fact]
+        public async void AddExternalStreamsAsync_GivenMixedFilenames_ReturnsValidSubtitles()
         {
-            new SubtitleResolver(Mock.Of<ILocalizationManager>()).AddExternalSubtitleStreams(streams, videoPath, startIndex, files);
+            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[]
+            {
+                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);
 
             Assert.Equal(expectedResult.Length, streams.Count);
             for (var i = 0; i < expectedResult.Length; i++)
@@ -77,31 +129,48 @@ namespace Jellyfin.Providers.Tests.MediaInfo
                 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("/video/My Video.mkv", "/video/My Video.srt", "srt", null, false, false)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.srt", "srt", null, false, false)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.foreign.srt", "srt", null, true, false)]
-        [InlineData("/video/My Video.mkv", "/video/My Video.forced.srt", "srt", null, true, false)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.default.srt", "srt", null, false, true)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.forced.default.srt", "srt", null, true, true)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.en.srt", "srt", "en", false, false)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.default.en.srt", "srt", "en", false, true)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.default.forced.en.srt", "srt", "en", true, true)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.en.default.forced.srt", "srt", "en", true, true)]
-        public void AddExternalSubtitleStreams_GivenSingleFile_ReturnsExpectedSubtitle(string videoPath, string file, string codec, string? language, bool isForced, bool isDefault)
+        [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 streams = new List<MediaStream>();
-            var expected = CreateMediaStream(file, codec, language, 0, isForced, isDefault);
+            BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
 
-            new SubtitleResolver(Mock.Of<ILocalizationManager>()).AddExternalSubtitleStreams(streams, videoPath, 0, new[] { file });
+            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);
 
-            Assert.Single(streams);
+            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);
+
+            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);
@@ -109,9 +178,10 @@ namespace Jellyfin.Providers.Tests.MediaInfo
             Assert.Equal(expected.IsDefault, actual.IsDefault);
             Assert.Equal(expected.IsForced, actual.IsForced);
             Assert.Equal(expected.Language, actual.Language);
+            Assert.Equal(expected.Title, actual.Title);
         }
 
-        private static MediaStream CreateMediaStream(string path, string codec, string? language, int index, bool isForced = false, bool isDefault = false)
+        private static MediaStream CreateMediaStream(string path, string codec, string? language, string? title, int index, bool isForced = false, bool isDefault = false)
         {
             return new()
             {
@@ -122,7 +192,8 @@ namespace Jellyfin.Providers.Tests.MediaInfo
                 Path = path,
                 IsDefault = isDefault,
                 IsForced = isForced,
-                Language = language
+                Language = language,
+                Title = title
             };
         }
     }

+ 0 - 0
tests/Jellyfin.Providers.Tests/Test Data/Video/My.Video.mkv