Selaa lähdekoodia

feat(external-media): refactor to generic provider, extend tests and file recognition, consolidate and extend NamingOptions

Shadowghost 3 vuotta sitten
vanhempi
sitoutus
719b707281

+ 0 - 59
Emby.Naming/Audio/ExternalAudioFilePathParser.cs

@@ -1,59 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using Emby.Naming.Common;
-using Jellyfin.Extensions;
-
-namespace Emby.Naming.Audio
-{
-    /// <summary>
-    /// External Audio Parser class.
-    /// </summary>
-    public class ExternalAudioFilePathParser
-    {
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ExternalAudioFilePathParser"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing AudioFileExtensions, ExternalAudioDefaultFlags, ExternalAudioForcedFlags and ExternalAudioFlagDelimiters.</param>
-        public ExternalAudioFilePathParser(NamingOptions options)
-        {
-            _options = options;
-        }
-
-        /// <summary>
-        /// Parse file to determine if it is a ExternalAudio and <see cref="ExternalAudioFileInfo"/>.
-        /// </summary>
-        /// <param name="path">Path to file.</param>
-        /// <returns>Returns null or <see cref="ExternalAudioFileInfo"/> object if parsing is successful.</returns>
-        public ExternalAudioFileInfo? ParseFile(string path)
-        {
-            if (path.Length == 0)
-            {
-                return null;
-            }
-
-            var extension = Path.GetExtension(path);
-            if (!_options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
-            {
-                return null;
-            }
-
-            var flags = GetFileFlags(path);
-            var info = new ExternalAudioFileInfo(
-                path,
-                _options.ExternalAudioDefaultFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)),
-                _options.ExternalAudioForcedFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)));
-
-            return info;
-        }
-
-        private string[] GetFileFlags(string path)
-        {
-            var file = Path.GetFileNameWithoutExtension(path);
-
-            return file.Split(_options.ExternalAudioFlagDelimiters, StringSplitOptions.RemoveEmptyEntries);
-        }
-    }
-}

+ 120 - 154
Emby.Naming/Common/NamingOptions.cs

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

+ 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 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))
+                || (_type == DlnaProfileType.Video && _namingOptions.VideoFileExtensions.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)
+                {
+                    var lastSeparator = languageString.LastIndexOf(separator, StringComparison.OrdinalIgnoreCase);
+
+                    if (lastSeparator == -1)
+                    {
+                          break;
+                    }
+
+                    string currentSlice = languageString[lastSeparator..];
+
+                    if (_namingOptions.MediaDefaultFlags.Any(s => currentSlice[separatorLength..].Contains(s, StringComparison.OrdinalIgnoreCase)))
+                    {
+                        pathInfo.IsDefault = true;
+                        extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
+                        languageString = languageString[..lastSeparator];
+                        continue;
+                    }
+
+                    if (_namingOptions.MediaForcedFlags.Any(s => currentSlice[separatorLength..].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(currentSlice[separatorLength..]);
+
+                    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;
+        }
+    }
+}

+ 5 - 6
Emby.Naming/Audio/ExternalAudioFileInfo.cs → Emby.Naming/ExternalFiles/ExternalPathParserResult.cs

@@ -1,17 +1,17 @@
-namespace Emby.Naming.Audio
+namespace Emby.Naming.ExternalFiles
 {
 {
     /// <summary>
     /// <summary>
-    /// Class holding information about external audio files.
+    /// Class holding information about external files.
     /// </summary>
     /// </summary>
-    public class ExternalAudioFileInfo
+    public class ExternalPathParserResult
     {
     {
         /// <summary>
         /// <summary>
-        /// Initializes a new instance of the <see cref="ExternalAudioFileInfo"/> class.
+        /// Initializes a new instance of the <see cref="ExternalPathParserResult"/> class.
         /// </summary>
         /// </summary>
         /// <param name="path">Path to file.</param>
         /// <param name="path">Path to file.</param>
         /// <param name="isDefault">Is default.</param>
         /// <param name="isDefault">Is default.</param>
         /// <param name="isForced">Is forced.</param>
         /// <param name="isForced">Is forced.</param>
-        public ExternalAudioFileInfo(string path, bool isDefault, bool isForced)
+        public ExternalPathParserResult(string path, bool isDefault = false, bool isForced = false)
         {
         {
             Path = path;
             Path = path;
             IsDefault = isDefault;
             IsDefault = isDefault;
@@ -42,7 +42,6 @@ namespace Emby.Naming.Audio
         /// <value><c>true</c> if this instance is default; otherwise, <c>false</c>.</value>
         /// <value><c>true</c> if this instance is default; otherwise, <c>false</c>.</value>
         public bool IsDefault { get; set; }
         public bool IsDefault { get; set; }
 
 
-
         /// <summary>
         /// <summary>
         /// Gets or sets a value indicating whether this instance is forced.
         /// Gets or sets a value indicating whether this instance is forced.
         /// </summary>
         /// </summary>

+ 0 - 51
Emby.Naming/Subtitles/SubtitleFileInfo.cs

@@ -1,51 +0,0 @@
-namespace Emby.Naming.Subtitles
-{
-    /// <summary>
-    /// Class holding information about subtitle.
-    /// </summary>
-    public class SubtitleFileInfo
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleFileInfo"/> class.
-        /// </summary>
-        /// <param name="path">Path to file.</param>
-        /// <param name="isDefault">Is subtitle default.</param>
-        /// <param name="isForced">Is subtitle forced.</param>
-        public SubtitleFileInfo(string path, bool isDefault, bool isForced)
-        {
-            Path = path;
-            IsDefault = isDefault;
-            IsForced = isForced;
-        }
-
-        /// <summary>
-        /// Gets or sets the path.
-        /// </summary>
-        /// <value>The path.</value>
-        public string Path { get; set; }
-
-        /// <summary>
-        /// Gets or sets the language.
-        /// </summary>
-        /// <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>
-        /// <value><c>true</c> if this instance is default; otherwise, <c>false</c>.</value>
-        public bool IsDefault { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is forced.
-        /// </summary>
-        /// <value><c>true</c> if this instance is forced; otherwise, <c>false</c>.</value>
-        public bool IsForced { get; set; }
-    }
-}

+ 0 - 59
Emby.Naming/Subtitles/SubtitleFilePathParser.cs

@@ -1,59 +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 SubtitleFilePathParser
-    {
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleFilePathParser"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing SubtitleFileExtensions, SubtitleDefaultFlags, SubtitleForcedFlags and SubtitleFlagDelimiters.</param>
-        public SubtitleFilePathParser(NamingOptions options)
-        {
-            _options = options;
-        }
-
-        /// <summary>
-        /// Parse file to determine if it is a subtitle and <see cref="SubtitleFileInfo"/>.
-        /// </summary>
-        /// <param name="path">Path to file.</param>
-        /// <returns>Returns null or <see cref="SubtitleFileInfo"/> object if parsing is successful.</returns>
-        public SubtitleFileInfo? 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 = GetFileFlags(path);
-            var info = new SubtitleFileInfo(
-                path,
-                _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)),
-                _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)));
-
-            return info;
-        }
-
-        private string[] GetFileFlags(string path)
-        {
-            var file = Path.GetFileNameWithoutExtension(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;
             return Name;
         }
         }
 
 
-        public string GetInternalMetadataPath()
+        public virtual string GetInternalMetadataPath()
         {
         {
             var basePath = ConfigurationManager.ApplicationPaths.InternalMetadataPath;
             var basePath = ConfigurationManager.ApplicationPaths.InternalMetadataPath;
 
 

+ 0 - 216
MediaBrowser.Providers/MediaInfo/AudioResolver.cs

@@ -1,216 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using System.Threading.Tasks;
-using Emby.Naming.Audio;
-using Emby.Naming.Common;
-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.
-    /// </summary>
-    public class AudioResolver
-    {
-        private readonly ILocalizationManager _localizationManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly NamingOptions _namingOptions;
-        private readonly ExternalAudioFilePathParser _externalAudioFilePathParser;
-        private readonly CompareInfo _compareInfo = CultureInfo.InvariantCulture.CompareInfo;
-        private const CompareOptions CompareOptions = System.Globalization.CompareOptions.IgnoreCase | System.Globalization.CompareOptions.IgnoreNonSpace | System.Globalization.CompareOptions.IgnoreSymbols;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AudioResolver"/> class.
-        /// </summary>
-        /// <param name="localizationManager">The localization manager.</param>
-        /// <param name="mediaEncoder">The media encoder.</param>
-        /// <param name="namingOptions">The naming options.</param>
-        public AudioResolver(
-            ILocalizationManager localizationManager,
-            IMediaEncoder mediaEncoder,
-            NamingOptions namingOptions)
-        {
-            _localizationManager = localizationManager;
-            _mediaEncoder = mediaEncoder;
-            _namingOptions = namingOptions;
-            _externalAudioFilePathParser = new ExternalAudioFilePathParser(_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;
-            }
-
-            string videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
-
-            var externalAudioFileInfos = GetExternalAudioFiles(video, directoryService, clearCache);
-            foreach (var externalAudioFileInfo in externalAudioFileInfos)
-            {
-                string fileName = Path.GetFileName(externalAudioFileInfo.Path);
-                string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(externalAudioFileInfo.Path);
-                Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(externalAudioFileInfo.Path, cancellationToken).ConfigureAwait(false);
-
-                if (mediaInfo.MediaStreams.Count == 1)
-                {
-                    MediaStream mediaStream = mediaInfo.MediaStreams.First();
-                    mediaStream.Index = startIndex++;
-                    mediaStream.Type = MediaStreamType.Audio;
-                    mediaStream.IsExternal = true;
-                    mediaStream.Path = externalAudioFileInfo.Path;
-                    mediaStream.IsDefault = externalAudioFileInfo.IsDefault || mediaStream.IsDefault;
-                    mediaStream.IsForced = externalAudioFileInfo.IsForced || mediaStream.IsForced;
-
-                    yield return DetectLanguage(mediaStream, fileNameWithoutExtension, videoFileNameWithoutExtension);
-                }
-                else
-                {
-                    foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
-                    {
-                        mediaStream.Index = startIndex++;
-                        mediaStream.Type = MediaStreamType.Audio;
-                        mediaStream.IsExternal = true;
-                        mediaStream.Path = externalAudioFileInfo.Path;
-
-                        yield return DetectLanguage(mediaStream, fileNameWithoutExtension, videoFileNameWithoutExtension);
-                    }
-                }
-            }
-        }
-
-        /// <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<ExternalAudioFileInfo> 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;
-            }
-
-            var videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
-
-            var files = directoryService.GetFilePaths(folder, clearCache, true);
-            for (int i = 0; i < files.Count; i++)
-            {
-                var subtitleFileInfo = _externalAudioFilePathParser.ParseFile(files[i]);
-
-                if (subtitleFileInfo == null)
-                {
-                    continue;
-                }
-
-                yield return subtitleFileInfo;
-            }
-        }
-
-        /// <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);
-        }
-
-        private MediaStream DetectLanguage(MediaStream mediaStream, string fileNameWithoutExtension, string videoFileNameWithoutExtension)
-        {
-            // Support xbmc naming conventions - 300.spanish.srt
-            var languageString = fileNameWithoutExtension;
-            while (languageString.Length > 0)
-            {
-                var lastDot = languageString.LastIndexOf('.');
-                if (lastDot < videoFileNameWithoutExtension.Length)
-                {
-                    break;
-                }
-
-                var currentSlice = languageString[lastDot..];
-                languageString = languageString[..lastDot];
-
-                if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase)
-                    || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase)
-                    || currentSlice.Equals(".foreign", StringComparison.OrdinalIgnoreCase))
-                {
-                    continue;
-                }
-
-                var currentSliceString = currentSlice[1..];
-
-                // Try to translate to three character code
-                var culture = _localizationManager.FindLanguageInfo(currentSliceString);
-
-                if (culture == null || mediaStream.Language != null)
-                {
-                    if (mediaStream.Title == null)
-                    {
-                        mediaStream.Title = currentSliceString;
-                    }
-                }
-                else
-                {
-                    mediaStream.Language = culture.ThreeLetterISOLanguageName;
-                }
-            }
-
-            return mediaStream;
-        }
-    }
-}

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

@@ -19,6 +19,7 @@ using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Subtitles;
 using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.MediaInfo;
 using MediaBrowser.Model.MediaInfo;
@@ -39,8 +40,8 @@ namespace MediaBrowser.Providers.MediaInfo
         IHasItemChangeMonitor
         IHasItemChangeMonitor
     {
     {
         private readonly ILogger<FFProbeProvider> _logger;
         private readonly ILogger<FFProbeProvider> _logger;
-        private readonly SubtitleResolver _subtitleResolver;
-        private readonly AudioResolver _audioResolver;
+        private readonly MediaInfoResolver _subtitleResolver;
+        private readonly MediaInfoResolver _audioResolver;
         private readonly FFProbeVideoInfo _videoProber;
         private readonly FFProbeVideoInfo _videoProber;
         private readonly FFProbeAudioInfo _audioProber;
         private readonly FFProbeAudioInfo _audioProber;
         private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None);
         private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None);
@@ -60,8 +61,8 @@ namespace MediaBrowser.Providers.MediaInfo
             NamingOptions namingOptions)
             NamingOptions namingOptions)
         {
         {
             _logger = logger;
             _logger = logger;
-            _audioResolver = new AudioResolver(localization, mediaEncoder, namingOptions);
-            _subtitleResolver = new SubtitleResolver(BaseItem.LocalizationManager, mediaEncoder, namingOptions);
+            _audioResolver = new MediaInfoResolver(localization, mediaEncoder, namingOptions, DlnaProfileType.Audio);
+            _subtitleResolver = new MediaInfoResolver(localization, mediaEncoder, namingOptions, DlnaProfileType.Subtitle);
             _videoProber = new FFProbeVideoInfo(
             _videoProber = new FFProbeVideoInfo(
                 _logger,
                 _logger,
                 mediaSourceManager,
                 mediaSourceManager,
@@ -104,7 +105,7 @@ namespace MediaBrowser.Providers.MediaInfo
 
 
             if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
             if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
                 && !video.SubtitleFiles.SequenceEqual(
                 && !video.SubtitleFiles.SequenceEqual(
-                    _subtitleResolver.GetExternalSubtitleFiles(video, directoryService, false)
+                    _subtitleResolver.GetExternalFiles(video, directoryService, false)
                     .Select(info => info.Path).ToList(),
                     .Select(info => info.Path).ToList(),
                     StringComparer.Ordinal))
                     StringComparer.Ordinal))
             {
             {
@@ -114,7 +115,7 @@ namespace MediaBrowser.Providers.MediaInfo
 
 
             if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
             if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
                 && !video.AudioFiles.SequenceEqual(
                 && !video.AudioFiles.SequenceEqual(
-                    _audioResolver.GetExternalAudioFiles(video, directoryService, false)
+                    _audioResolver.GetExternalFiles(video, directoryService, false)
                     .Select(info => info.Path).ToList(),
                     .Select(info => info.Path).ToList(),
                     StringComparer.Ordinal))
                     StringComparer.Ordinal))
             {
             {

+ 7 - 7
MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs

@@ -44,8 +44,8 @@ namespace MediaBrowser.Providers.MediaInfo
         private readonly ISubtitleManager _subtitleManager;
         private readonly ISubtitleManager _subtitleManager;
         private readonly IChapterManager _chapterManager;
         private readonly IChapterManager _chapterManager;
         private readonly ILibraryManager _libraryManager;
         private readonly ILibraryManager _libraryManager;
-        private readonly AudioResolver _audioResolver;
-        private readonly SubtitleResolver _subtitleResolver;
+        private readonly MediaInfoResolver _audioResolver;
+        private readonly MediaInfoResolver _subtitleResolver;
         private readonly IMediaSourceManager _mediaSourceManager;
         private readonly IMediaSourceManager _mediaSourceManager;
 
 
         private readonly long _dummyChapterDuration = TimeSpan.FromMinutes(5).Ticks;
         private readonly long _dummyChapterDuration = TimeSpan.FromMinutes(5).Ticks;
@@ -62,8 +62,8 @@ namespace MediaBrowser.Providers.MediaInfo
             ISubtitleManager subtitleManager,
             ISubtitleManager subtitleManager,
             IChapterManager chapterManager,
             IChapterManager chapterManager,
             ILibraryManager libraryManager,
             ILibraryManager libraryManager,
-            SubtitleResolver subtitleResolver,
-            AudioResolver audioResolver)
+            MediaInfoResolver subtitleResolver,
+            MediaInfoResolver audioResolver)
         {
         {
             _logger = logger;
             _logger = logger;
             _mediaEncoder = mediaEncoder;
             _mediaEncoder = mediaEncoder;
@@ -536,7 +536,7 @@ namespace MediaBrowser.Providers.MediaInfo
             CancellationToken cancellationToken)
             CancellationToken cancellationToken)
         {
         {
             var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
             var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
-            var externalSubtitleStreamsAsync = _subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, false, cancellationToken);
+            var externalSubtitleStreamsAsync = _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken);
 
 
             List<MediaStream> externalSubtitleStreams = new List<MediaStream>();
             List<MediaStream> externalSubtitleStreams = new List<MediaStream>();
 
 
@@ -597,7 +597,7 @@ namespace MediaBrowser.Providers.MediaInfo
                 // Rescan
                 // Rescan
                 if (downloadedLanguages.Count > 0)
                 if (downloadedLanguages.Count > 0)
                 {
                 {
-                    await foreach (MediaStream externalSubtitleStream in _subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, true, cancellationToken))
+                    await foreach (MediaStream externalSubtitleStream in _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken))
                     {
                     {
                         externalSubtitleStreams.Add(externalSubtitleStream);
                         externalSubtitleStreams.Add(externalSubtitleStream);
                     }
                     }
@@ -623,7 +623,7 @@ namespace MediaBrowser.Providers.MediaInfo
             CancellationToken cancellationToken)
             CancellationToken cancellationToken)
         {
         {
             var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1;
             var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1;
-            var externalAudioStreams = _audioResolver.GetExternalAudioStreams(video, startIndex, options.DirectoryService, false, cancellationToken);
+            var externalAudioStreams = _audioResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken);
 
 
             await foreach (MediaStream externalAudioStream in externalAudioStreams)
             await foreach (MediaStream externalAudioStream in externalAudioStreams)
             {
             {

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

@@ -0,0 +1,208 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+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 videos.
+    /// </summary>
+    public 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>
+        public 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 to cancel operation.</param>
+        /// <returns>The external streams located.</returns>
+        public async IAsyncEnumerable<MediaStream> GetExternalStreamsAsync(
+            Video video,
+            int startIndex,
+            IDirectoryService directoryService,
+            bool clearCache,
+            [EnumeratorCancellation] CancellationToken cancellationToken)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+
+            if (!video.IsFileProtocol)
+            {
+                yield break;
+            }
+
+            var pathInfos = GetExternalFiles(video, directoryService, clearCache);
+
+            foreach (var pathInfo in pathInfos)
+            {
+                Model.MediaInfo.MediaInfo 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;
+
+                    yield return MergeMetadata(mediaStream, pathInfo);
+                }
+                else
+                {
+                    foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
+                    {
+                        mediaStream.Index = startIndex++;
+
+                        yield return MergeMetadata(mediaStream, pathInfo);
+                    }
+                }
+            }
+        }
+
+        /// <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 IEnumerable<ExternalPathParserResult> GetExternalFiles(
+            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;
+            }
+
+            var files = directoryService.GetFilePaths(folder, clearCache).ToList();
+            files.AddRange(directoryService.GetFilePaths(video.GetInternalMetadataPath(), clearCache));
+
+            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)
+                    {
+                        yield return externalPathInfo;
+                    }
+                }
+            }
+        }
+
+        /// <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,
+                DlnaProfileType.Video => MediaStreamType.Video,
+                _ => mediaStream.Type
+            };
+
+            return mediaStream;
+        }
+    }
+}

+ 0 - 219
MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs

@@ -1,219 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using System.Threading.Tasks;
-using Emby.Naming.Common;
-using Emby.Naming.Subtitles;
-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 subtitles for videos.
-    /// </summary>
-    public class SubtitleResolver
-    {
-        private readonly ILocalizationManager _localizationManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly NamingOptions _namingOptions;
-        private readonly SubtitleFilePathParser _subtitleFilePathParser;
-        private readonly CompareInfo _compareInfo = CultureInfo.InvariantCulture.CompareInfo;
-        private const CompareOptions CompareOptions = System.Globalization.CompareOptions.IgnoreCase | System.Globalization.CompareOptions.IgnoreNonSpace | System.Globalization.CompareOptions.IgnoreSymbols;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleResolver"/> class.
-        /// </summary>
-        /// <param name="localization">The localization manager.</param>
-        /// <param name="mediaEncoder">The media encoder.</param>
-        /// <param name="namingOptions">The naming Options.</param>
-        public SubtitleResolver(
-            ILocalizationManager localization,
-            IMediaEncoder mediaEncoder,
-            NamingOptions namingOptions)
-        {
-            _localizationManager = localization;
-            _mediaEncoder = mediaEncoder;
-            _namingOptions = namingOptions;
-            _subtitleFilePathParser = new SubtitleFilePathParser(_namingOptions);
-        }
-
-        /// <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>
-        /// <param name="cancellationToken">The cancellation token to cancel operation.</param>
-        /// <returns>The external subtitle streams located.</returns>
-        public async IAsyncEnumerable<MediaStream> GetExternalSubtitleStreams(
-            Video video,
-            int startIndex,
-            IDirectoryService directoryService,
-            bool clearCache,
-            [EnumeratorCancellation] CancellationToken cancellationToken)
-        {
-
-            cancellationToken.ThrowIfCancellationRequested();
-
-            if (!video.IsFileProtocol)
-            {
-                yield break;
-            }
-
-            var subtitleFileInfos = GetExternalSubtitleFiles(video, directoryService, clearCache);
-
-            var videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
-
-            foreach (var subtitleFileInfo in subtitleFileInfos)
-            {
-                string fileName = Path.GetFileName(subtitleFileInfo.Path);
-                string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(subtitleFileInfo.Path);
-                Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(subtitleFileInfo.Path, cancellationToken).ConfigureAwait(false);
-
-                if (mediaInfo.MediaStreams.Count == 1)
-                {
-                    MediaStream mediaStream = mediaInfo.MediaStreams.First();
-                    mediaStream.Index = startIndex++;
-                    mediaStream.Type = MediaStreamType.Subtitle;
-                    mediaStream.IsExternal = true;
-                    mediaStream.Path = subtitleFileInfo.Path;
-                    mediaStream.IsDefault = subtitleFileInfo.IsDefault || mediaStream.IsDefault;
-                    mediaStream.IsForced = subtitleFileInfo.IsForced || mediaStream.IsForced;
-
-                    yield return DetectLanguage(mediaStream, fileNameWithoutExtension, videoFileNameWithoutExtension);
-                }
-                else
-                {
-                    foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
-                    {
-                        mediaStream.Index = startIndex++;
-                        mediaStream.Type = MediaStreamType.Subtitle;
-                        mediaStream.IsExternal = true;
-                        mediaStream.Path = subtitleFileInfo.Path;
-
-                        yield return DetectLanguage(mediaStream, fileNameWithoutExtension, videoFileNameWithoutExtension);
-                    }
-                }
-            }
-        }
-
-        /// <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<SubtitleFileInfo> GetExternalSubtitleFiles(
-            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;
-            }
-
-            var videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
-
-            var files = directoryService.GetFilePaths(folder, clearCache, true);
-            for (int i = 0; i < files.Count; i++)
-            {
-                var subtitleFileInfo = _subtitleFilePathParser.ParseFile(files[i]);
-
-                if (subtitleFileInfo == null)
-                {
-                    continue;
-                }
-
-                yield return subtitleFileInfo;
-            }
-        }
-
-        /// <summary>
-        /// Returns the media info of the given subtitle file.
-        /// </summary>
-        /// <param name="path">The path to the subtitle file.</param>
-        /// <param name="cancellationToken">The cancellation token to cancel operation.</param>
-        /// <returns>The media info for the given subtitle file.</returns>
-        private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, CancellationToken cancellationToken)
-        {
-            cancellationToken.ThrowIfCancellationRequested();
-
-            return _mediaEncoder.GetMediaInfo(
-                new MediaInfoRequest
-                {
-                    MediaType = DlnaProfileType.Subtitle,
-                    MediaSource = new MediaSourceInfo
-                    {
-                        Path = path,
-                        Protocol = MediaProtocol.File
-                    }
-                },
-                cancellationToken);
-        }
-
-        private MediaStream DetectLanguage(MediaStream mediaStream, string fileNameWithoutExtension, string videoFileNameWithoutExtension)
-        {
-            // Support xbmc naming conventions - 300.spanish.srt
-            var languageString = fileNameWithoutExtension;
-            while (languageString.Length > 0)
-            {
-                var lastDot = languageString.LastIndexOf('.');
-                if (lastDot < videoFileNameWithoutExtension.Length)
-                {
-                    break;
-                }
-
-                var currentSlice = languageString[lastDot..];
-                languageString = languageString[..lastDot];
-
-                if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase)
-                    || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase)
-                    || currentSlice.Equals(".foreign", StringComparison.OrdinalIgnoreCase))
-                {
-                    continue;
-                }
-
-                var currentSliceString = currentSlice[1..];
-
-                // Try to translate to three character code
-                var culture = _localizationManager.FindLanguageInfo(currentSliceString);
-
-                if (culture == null || mediaStream.Language != null)
-                {
-                    if (mediaStream.Title == null)
-                    {
-                        mediaStream.Title = currentSliceString;
-                    }
-                }
-                else
-                {
-                    mediaStream.Language = culture.ThreeLetterISOLanguageName;
-                }
-            }
-
-            return mediaStream;
-        }
-    }
-}

+ 0 - 40
tests/Jellyfin.Naming.Tests/Subtitles/SubtitleFilePathParserTests.cs

@@ -1,40 +0,0 @@
-using Emby.Naming.Common;
-using Emby.Naming.Subtitles;
-using Xunit;
-
-namespace Jellyfin.Naming.Tests.Subtitles
-{
-    public class SubtitleFilePathParserTests
-    {
-        private readonly NamingOptions _namingOptions = new NamingOptions();
-
-        [Theory]
-        [InlineData("The Skin I Live In (2011).srt", false, false)]
-        [InlineData("The Skin I Live In (2011).eng.srt", false, false)]
-        [InlineData("The Skin I Live In (2011).default.srt", true, false)]
-        [InlineData("The Skin I Live In (2011).forced.srt", false, true)]
-        [InlineData("The Skin I Live In (2011).eng.foreign.srt", false, true)]
-        [InlineData("The Skin I Live In (2011).eng.default.foreign.srt", true, true)]
-        [InlineData("The Skin I Live In (2011).default.foreign.eng.srt", true, true)]
-        public void SubtitleFilePathParser_ValidFileName_Parses(string input, bool isDefault, bool isForced)
-        {
-            var parser = new SubtitleFilePathParser(_namingOptions);
-
-            var result = parser.ParseFile(input);
-
-            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 SubtitleFilePathParser_InvalidFileName_ReturnsNull(string input)
-        {
-            var parser = new SubtitleFilePathParser(_namingOptions);
-
-            Assert.Null(parser.ParseFile(input));
-        }
-    }
-}

+ 60 - 38
tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs

@@ -1,13 +1,14 @@
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Text.RegularExpressions;
 using System.Text.RegularExpressions;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Emby.Naming.Common;
 using Emby.Naming.Common;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Providers.MediaInfo;
 using MediaBrowser.Providers.MediaInfo;
@@ -18,8 +19,9 @@ namespace Jellyfin.Providers.Tests.MediaInfo
 {
 {
     public class AudioResolverTests
     public class AudioResolverTests
     {
     {
-        private const string DirectoryPath = "Test Data/Video";
-        private readonly AudioResolver _audioResolver;
+        private const string VideoDirectoryPath = "Test Data/Video";
+        private const string MetadataDirectoryPath = "Test Data/Metadata";
+        private readonly MediaInfoResolver _audioResolver;
 
 
         public AudioResolverTests()
         public AudioResolverTests()
         {
         {
@@ -45,52 +47,68 @@ namespace Jellyfin.Providers.Tests.MediaInfo
                     }
                     }
                 }));
                 }));
 
 
-            _audioResolver = new AudioResolver(localizationManager.Object, mediaEncoder.Object, new NamingOptions());
+            _audioResolver = new MediaInfoResolver(localizationManager.Object, mediaEncoder.Object, new NamingOptions(), DlnaProfileType.Audio);
         }
         }
 
 
         [Fact]
         [Fact]
-        public async void AddExternalAudioStreams_GivenMixedFilenames_ReturnsValidSubtitles()
+        public async void AddExternalStreams_GivenMixedFilenames_ReturnsValidSubtitles()
         {
         {
             var startIndex = 0;
             var startIndex = 0;
             var index = startIndex;
             var index = startIndex;
             var files = new[]
             var files = new[]
             {
             {
-                DirectoryPath + "/My.Video.mp3",
-                // DirectoryPath + "/Some.Other.Video.mp3", // TODO should not be picked up
-                DirectoryPath + "/My.Video.png",
-                DirectoryPath + "/My.Video.srt",
-                DirectoryPath + "/My.Video.txt",
-                DirectoryPath + "/My.Video.vtt",
-                DirectoryPath + "/My.Video.ass",
-                DirectoryPath + "/My.Video.sub",
-                DirectoryPath + "/My.Video.ssa",
-                DirectoryPath + "/My.Video.smi",
-                DirectoryPath + "/My.Video.sami",
-                DirectoryPath + "/My.Video.en.mp3",
-                DirectoryPath + "/My.Video.Label.mp3",
-                DirectoryPath + "/My.Video.With.Additional.Garbage.en.mp3",
-                // DirectoryPath + "/My.Video With Additional Garbage.mp3" // TODO no "." after "My.Video", previously would be picked up
+                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 expectedResult = new[]
             {
             {
-                CreateMediaStream(DirectoryPath + "/My.Video.mp3", null, null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.en.mp3", "eng", null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.Label.mp3", null, "Label", index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.With.Additional.Garbage.en.mp3", "eng", "Garbage", index) // TODO only "Garbage" is picked up as title, none of the other extra text
+                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>();
             BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
-            var video = new Movie
-            {
-                // Must be valid for video.IsFileProtocol check
-                Path = DirectoryPath + "/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);
             var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
             directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
             directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
                 .Returns(files);
                 .Returns(files);
+            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Metadata"), It.IsAny<bool>(), It.IsAny<bool>()))
+                .Returns(metadataFiles);
 
 
-            var asyncStreams = _audioResolver.GetExternalAudioStreams(video, startIndex, directoryService.Object, false, CancellationToken.None).ConfigureAwait(false);
+            var asyncStreams = _audioResolver.GetExternalStreamsAsync(video.Object, startIndex, directoryService.Object, false, CancellationToken.None).ConfigureAwait(false);
 
 
             var streams = new List<MediaStream>();
             var streams = new List<MediaStream>();
             await foreach (var stream in asyncStreams)
             await foreach (var stream in asyncStreams)
@@ -114,6 +132,8 @@ namespace Jellyfin.Providers.Tests.MediaInfo
         }
         }
 
 
         [Theory]
         [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.mp3", null, null, false, false)]
         [InlineData("My.Video.English.mp3", "eng", null, false, false)]
         [InlineData("My.Video.English.mp3", "eng", null, false, false)]
         [InlineData("My.Video.Title.mp3", null, "Title", false, false)]
         [InlineData("My.Video.Title.mp3", null, "Title", false, false)]
@@ -123,17 +143,19 @@ namespace Jellyfin.Providers.Tests.MediaInfo
         public async void GetExternalAudioStreams_GivenSingleFile_ReturnsExpectedStream(string file, string? language, string? title, bool isForced, bool isDefault)
         public async void GetExternalAudioStreams_GivenSingleFile_ReturnsExpectedStream(string file, string? language, string? title, bool isForced, bool isDefault)
         {
         {
             BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
             BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
-            var video = new Movie
-            {
-                // Must be valid for video.IsFileProtocol check
-                Path = DirectoryPath + "/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);
             var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
             directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
             directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(new[] { DirectoryPath + "/" + file });
+                .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 asyncStreams = _audioResolver.GetExternalAudioStreams(video, 0, directoryService.Object, false, CancellationToken.None).ConfigureAwait(false);
+            var asyncStreams = _audioResolver.GetExternalStreamsAsync(video.Object, 0, directoryService.Object, false, CancellationToken.None).ConfigureAwait(false);
 
 
             var streams = new List<MediaStream>();
             var streams = new List<MediaStream>();
             await foreach (var stream in asyncStreams)
             await foreach (var stream in asyncStreams)
@@ -145,7 +167,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
 
 
             var actual = streams[0];
             var actual = streams[0];
 
 
-            var expected = CreateMediaStream(DirectoryPath + "/" + file, language, title, 0, isForced, isDefault);
+            var expected = CreateMediaStream(VideoDirectoryPath + "/" + file, language, title, 0, isForced, isDefault);
             Assert.Equal(expected.Index, actual.Index);
             Assert.Equal(expected.Index, actual.Index);
             Assert.Equal(expected.Type, actual.Type);
             Assert.Equal(expected.Type, actual.Type);
             Assert.Equal(expected.IsExternal, actual.IsExternal);
             Assert.Equal(expected.IsExternal, actual.IsExternal);

+ 70 - 57
tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs

@@ -1,13 +1,14 @@
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Text.RegularExpressions;
 using System.Text.RegularExpressions;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Emby.Naming.Common;
 using Emby.Naming.Common;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Providers.MediaInfo;
 using MediaBrowser.Providers.MediaInfo;
@@ -18,8 +19,9 @@ namespace Jellyfin.Providers.Tests.MediaInfo
 {
 {
     public class SubtitleResolverTests
     public class SubtitleResolverTests
     {
     {
-        private const string DirectoryPath = "Test Data/Video";
-        private readonly SubtitleResolver _subtitleResolver;
+        private const string VideoDirectoryPath = "Test Data/Video";
+        private const string MetadataDirectoryPath = "Test Data/Metadata";
+        private readonly MediaInfoResolver _subtitleResolver;
 
 
         public SubtitleResolverTests()
         public SubtitleResolverTests()
         {
         {
@@ -54,7 +56,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
                     }
                     }
                 }));
                 }));
 
 
-            _subtitleResolver = new SubtitleResolver(localizationManager.Object, mediaEncoder.Object, new NamingOptions());
+            _subtitleResolver = new MediaInfoResolver(localizationManager.Object, mediaEncoder.Object, new NamingOptions(), DlnaProfileType.Subtitle);
         }
         }
 
 
         [Fact]
         [Fact]
@@ -64,52 +66,68 @@ namespace Jellyfin.Providers.Tests.MediaInfo
             var index = startIndex;
             var index = startIndex;
             var files = new[]
             var files = new[]
             {
             {
-                DirectoryPath + "/My.Video.mp3",
-                DirectoryPath + "/My.Video.png",
-                DirectoryPath + "/My.Video.srt",
-                // DirectoryPath + "/Some.Other.Video.srt", // TODO should not be picked up
-                DirectoryPath + "/My.Video.txt",
-                DirectoryPath + "/My.Video.vtt",
-                DirectoryPath + "/My.Video.ass",
-                DirectoryPath + "/My.Video.sub",
-                DirectoryPath + "/My.Video.ssa",
-                DirectoryPath + "/My.Video.smi",
-                DirectoryPath + "/My.Video.sami",
-                DirectoryPath + "/My.Video.en.srt",
-                DirectoryPath + "/My.Video.default.en.srt",
-                DirectoryPath + "/My.Video.default.forced.en.srt",
-                DirectoryPath + "/My.Video.en.default.forced.srt",
-                DirectoryPath + "/My.Video.With.Additional.Garbage.en.srt",
-                // DirectoryPath + "/My.Video With Additional Garbage.srt" // TODO no "." after "My.Video", previously would be picked up
+                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 expectedResult = new[]
             {
             {
-                CreateMediaStream(DirectoryPath + "/My.Video.srt", "srt", null, null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.vtt", "vtt", null, null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.ass", "ass", null, null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.sub", "sub", null, null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.ssa", "ssa", null, null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.smi", "smi", null, null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.sami", "sami", null, null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.en.srt", "srt", "eng", null, index++),
-                CreateMediaStream(DirectoryPath + "/My.Video.default.en.srt", "srt", "eng", null, index++, isDefault: true),
-                CreateMediaStream(DirectoryPath + "/My.Video.default.forced.en.srt", "srt", "eng", null, index++, isForced: true, isDefault: true),
-                CreateMediaStream(DirectoryPath + "/My.Video.en.default.forced.srt", "srt", "eng", null, index++, isForced: true, isDefault: true),
-                CreateMediaStream(DirectoryPath + "/My.Video.With.Additional.Garbage.en.srt", "srt", "eng", "Garbage", index) // TODO only "Garbage" is picked up as title, none of the other extra text
+                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>();
             BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
-            var video = new Movie
-            {
-                // Must be valid for video.IsFileProtocol check
-                Path = DirectoryPath + "/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);
             var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
             directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
             directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
                 .Returns(files);
                 .Returns(files);
+            directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Metadata"), It.IsAny<bool>(), It.IsAny<bool>()))
+                .Returns(metadataFiles);
 
 
-            var asyncStreams = _subtitleResolver.GetExternalSubtitleStreams(video, startIndex, directoryService.Object, false, CancellationToken.None).ConfigureAwait(false);
+            var asyncStreams = _subtitleResolver.GetExternalStreamsAsync(video.Object, startIndex, directoryService.Object, false, CancellationToken.None).ConfigureAwait(false);
 
 
             var streams = new List<MediaStream>();
             var streams = new List<MediaStream>();
             await foreach (var stream in asyncStreams)
             await foreach (var stream in asyncStreams)
@@ -124,7 +142,6 @@ namespace Jellyfin.Providers.Tests.MediaInfo
                 var actual = streams[i];
                 var actual = streams[i];
 
 
                 Assert.Equal(expected.Index, actual.Index);
                 Assert.Equal(expected.Index, actual.Index);
-                // Assert.Equal(expected.Codec, actual.Codec); TODO should codec still be set to file extension?
                 Assert.Equal(expected.Type, actual.Type);
                 Assert.Equal(expected.Type, actual.Type);
                 Assert.Equal(expected.IsExternal, actual.IsExternal);
                 Assert.Equal(expected.IsExternal, actual.IsExternal);
                 Assert.Equal(expected.Path, actual.Path);
                 Assert.Equal(expected.Path, actual.Path);
@@ -136,14 +153,10 @@ namespace Jellyfin.Providers.Tests.MediaInfo
         }
         }
 
 
         [Theory]
         [Theory]
-        [InlineData("My Video.srt", "srt", null, null, false, false)]
-        [InlineData("My Video.ass", "ass", null, null, false, false)]
-        [InlineData("my video.srt", "srt", null, null, false, false)]
-        [InlineData("My Vidèo.srt", "srt", null, null, false, false)]
-        [InlineData("My. Video.srt", "srt", null, null, false, false)]
+        [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.srt", "srt", null, null, false, false)]
         [InlineData("My.Video.foreign.srt", "srt", null, null, true, false)]
         [InlineData("My.Video.foreign.srt", "srt", null, null, true, false)]
-        [InlineData("My Video.forced.srt", "srt", null, null, true, false)]
         [InlineData("My.Video.default.srt", "srt", null, null, false, true)]
         [InlineData("My.Video.default.srt", "srt", null, null, false, true)]
         [InlineData("My.Video.forced.default.srt", "srt", null, null, true, 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.en.srt", "srt", "eng", null, false, false)]
@@ -153,24 +166,25 @@ namespace Jellyfin.Providers.Tests.MediaInfo
         [InlineData("My.Video.default.forced.en.srt", "srt", "eng", null, true, 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.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.srt", "srt", null, "Track.Label", false, false)] // TODO fails - only "Label" is picked up for title, not "Track.Label"
-        // [InlineData("MyVideo.Track Label.srt", "srt", null, "Track Label", false, false)] // TODO fails - fuzzy match doesn't pick up on end of matching segment being shorter?
+        [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.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)]
         [InlineData("My.Video.en.default.forced.Track Label.srt", "srt", "eng", "Track Label", true, true)]
         public async void AddExternalSubtitleStreams_GivenSingleFile_ReturnsExpectedSubtitle(string file, string codec, string? language, string? title, bool isForced, bool isDefault)
         public async void AddExternalSubtitleStreams_GivenSingleFile_ReturnsExpectedSubtitle(string file, string codec, string? language, string? title, bool isForced, bool isDefault)
         {
         {
             BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
             BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
-            var video = new Movie
-            {
-                // Must be valid for video.IsFileProtocol check
-                Path = DirectoryPath + "/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);
             var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
             directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
             directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(@"Test Data[/\\]Video"), It.IsAny<bool>(), It.IsAny<bool>()))
-                .Returns(new[] { DirectoryPath + "/" + file });
+                .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 asyncStreams = _subtitleResolver.GetExternalSubtitleStreams(video, 0, directoryService.Object, false, CancellationToken.None).ConfigureAwait(false);
+            var asyncStreams = _subtitleResolver.GetExternalStreamsAsync(video.Object, 0, directoryService.Object, false, CancellationToken.None).ConfigureAwait(false);
 
 
             var streams = new List<MediaStream>();
             var streams = new List<MediaStream>();
             await foreach (var stream in asyncStreams)
             await foreach (var stream in asyncStreams)
@@ -181,9 +195,8 @@ namespace Jellyfin.Providers.Tests.MediaInfo
             Assert.Single(streams);
             Assert.Single(streams);
             var actual = streams[0];
             var actual = streams[0];
 
 
-            var expected = CreateMediaStream(DirectoryPath + "/" + file, codec, language, title, 0, isForced, isDefault);
+            var expected = CreateMediaStream(VideoDirectoryPath + "/" + file, codec, language, title, 0, isForced, isDefault);
             Assert.Equal(expected.Index, actual.Index);
             Assert.Equal(expected.Index, actual.Index);
-            // Assert.Equal(expected.Codec, actual.Codec); TODO should codec still be set to file extension?
             Assert.Equal(expected.Type, actual.Type);
             Assert.Equal(expected.Type, actual.Type);
             Assert.Equal(expected.IsExternal, actual.IsExternal);
             Assert.Equal(expected.IsExternal, actual.IsExternal);
             Assert.Equal(expected.Path, actual.Path);
             Assert.Equal(expected.Path, actual.Path);