瀏覽代碼

feat(external-media): refactor external subtitle and audio provider

Shadowghost 3 年之前
父節點
當前提交
ca5112f45a

+ 52 - 0
Emby.Naming/Audio/ExternalAudioFileInfo.cs

@@ -0,0 +1,52 @@
+namespace Emby.Naming.Audio
+{
+    /// <summary>
+    /// Class holding information about external audio files.
+    /// </summary>
+    public class ExternalAudioFileInfo
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ExternalAudioFileInfo"/> class.
+        /// </summary>
+        /// <param name="path">Path to file.</param>
+        /// <param name="isDefault">Is default.</param>
+        /// <param name="isForced">Is forced.</param>
+        public ExternalAudioFileInfo(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; }
+    }
+}

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

@@ -0,0 +1,59 @@
+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);
+        }
+    }
+}

+ 39 - 10
Emby.Naming/Common/NamingOptions.cs

@@ -149,10 +149,14 @@ namespace Emby.Naming.Common
 
             SubtitleFileExtensions = new[]
             {
+                ".ass",
+                ".smi",
+                ".sami",
                 ".srt",
                 ".ssa",
-                ".ass",
-                ".sub"
+                ".sub",
+                ".vtt",
+                ".mks"
             };
 
             SubtitleFlagDelimiters = new[]
@@ -246,6 +250,22 @@ namespace Emby.Naming.Common
                 ".mka"
             };
 
+            ExternalAudioFlagDelimiters = new[]
+            {
+                '.'
+            };
+
+            ExternalAudioForcedFlags = new[]
+            {
+                "foreign",
+                "forced"
+            };
+
+            ExternalAudioDefaultFlags = new[]
+            {
+                "default"
+            };
+
             EpisodeExpressions = new[]
             {
                 // *** Begin Kodi Standard Naming
@@ -648,9 +668,7 @@ namespace Emby.Naming.Common
                 @"^\s*(?<name>[^ ].*?)\s*$"
             };
 
-            var extensions = VideoFileExtensions.ToList();
-
-            extensions.AddRange(new[]
+            VideoFileExtensions = new[]
             {
                 ".mkv",
                 ".m2t",
@@ -681,11 +699,7 @@ namespace Emby.Naming.Common
                 ".m2v",
                 ".rec",
                 ".mxf"
-            });
-
-            VideoFileExtensions = extensions
-                .Distinct(StringComparer.OrdinalIgnoreCase)
-                .ToArray();
+            };
 
             MultipleEpisodeExpressions = new[]
             {
@@ -717,6 +731,21 @@ namespace Emby.Naming.Common
         /// </summary>
         public string[] AudioFileExtensions { get; set; }
 
+        /// <summary>
+        /// Gets or sets list of external audio flag delimiters.
+        /// </summary>
+        public char[] ExternalAudioFlagDelimiters { get; set; }
+
+        /// <summary>
+        /// Gets or sets list of external audio forced flags.
+        /// </summary>
+        public string[] ExternalAudioForcedFlags { get; set; }
+
+        /// <summary>
+        /// Gets or sets list of external audio default flags.
+        /// </summary>
+        public string[] ExternalAudioDefaultFlags { get; set; }
+
         /// <summary>
         /// Gets or sets list of album stacking prefixes.
         /// </summary>

+ 9 - 3
Emby.Naming/Subtitles/SubtitleInfo.cs → Emby.Naming/Subtitles/SubtitleFileInfo.cs

@@ -3,15 +3,15 @@ namespace Emby.Naming.Subtitles
     /// <summary>
     /// Class holding information about subtitle.
     /// </summary>
-    public class SubtitleInfo
+    public class SubtitleFileInfo
     {
         /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleInfo"/> class.
+        /// 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 SubtitleInfo(string path, bool isDefault, bool isForced)
+        public SubtitleFileInfo(string path, bool isDefault, bool isForced)
         {
             Path = path;
             IsDefault = isDefault;
@@ -30,6 +30,12 @@ namespace Emby.Naming.Subtitles
         /// <value>The language.</value>
         public string? Language { get; set; }
 
+        /// <summary>
+        /// Gets or sets the title.
+        /// </summary>
+        /// <value>The title.</value>
+        public string? Title { get; set; }
+
         /// <summary>
         /// Gets or sets a value indicating whether this instance is default.
         /// </summary>

+ 10 - 22
Emby.Naming/Subtitles/SubtitleParser.cs → Emby.Naming/Subtitles/SubtitleFilePathParser.cs

@@ -9,25 +9,25 @@ namespace Emby.Naming.Subtitles
     /// <summary>
     /// Subtitle Parser class.
     /// </summary>
-    public class SubtitleParser
+    public class SubtitleFilePathParser
     {
         private readonly NamingOptions _options;
 
         /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleParser"/> class.
+        /// 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 SubtitleParser(NamingOptions options)
+        public SubtitleFilePathParser(NamingOptions options)
         {
             _options = options;
         }
 
         /// <summary>
-        /// Parse file to determine if is subtitle and <see cref="SubtitleInfo"/>.
+        /// 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="SubtitleInfo"/> object if parsing is successful.</returns>
-        public SubtitleInfo? ParseFile(string path)
+        /// <returns>Returns null or <see cref="SubtitleFileInfo"/> object if parsing is successful.</returns>
+        public SubtitleFileInfo? ParseFile(string path)
         {
             if (path.Length == 0)
             {
@@ -40,30 +40,18 @@ namespace Emby.Naming.Subtitles
                 return null;
             }
 
-            var flags = GetFlags(path);
-            var info = new SubtitleInfo(
+            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)));
 
-            var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparison.OrdinalIgnoreCase)
-                && !_options.SubtitleForcedFlags.Contains(i, StringComparison.OrdinalIgnoreCase))
-                .ToList();
-
-            // Should have a name, language and file extension
-            if (parts.Count >= 3)
-            {
-                info.Language = parts[^2];
-            }
-
             return info;
         }
 
-        private string[] GetFlags(string path)
+        private string[] GetFileFlags(string path)
         {
-            // Note: the tags need be surrounded be either a space ( ), hyphen -, dot . or underscore _.
-
-            var file = Path.GetFileName(path);
+            var file = Path.GetFileNameWithoutExtension(path);
 
             return file.Split(_options.SubtitleFlagDelimiters, StringSplitOptions.RemoveEmptyEntries);
         }

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

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

+ 77 - 37
MediaBrowser.Providers/MediaInfo/AudioResolver.cs

@@ -1,12 +1,13 @@
 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 Jellyfin.Extensions;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Providers;
@@ -26,6 +27,9 @@ namespace MediaBrowser.Providers.MediaInfo
         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.
@@ -41,6 +45,7 @@ namespace MediaBrowser.Providers.MediaInfo
             _localizationManager = localizationManager;
             _mediaEncoder = mediaEncoder;
             _namingOptions = namingOptions;
+            _externalAudioFilePathParser = new ExternalAudioFilePathParser(_namingOptions);
         }
 
         /// <summary>
@@ -66,37 +71,38 @@ namespace MediaBrowser.Providers.MediaInfo
                 yield break;
             }
 
-            IEnumerable<string> paths = GetExternalAudioFiles(video, directoryService, clearCache);
-            foreach (string path in paths)
+            string videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
+
+            var externalAudioFileInfos = GetExternalAudioFiles(video, directoryService, clearCache);
+            foreach (var externalAudioFileInfo in externalAudioFileInfos)
             {
-                string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path);
-                Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(path, cancellationToken).ConfigureAwait(false);
+                string fileName = Path.GetFileName(externalAudioFileInfo.Path);
+                string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(externalAudioFileInfo.Path);
+                Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(externalAudioFileInfo.Path, cancellationToken).ConfigureAwait(false);
 
-                foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
+                if (mediaInfo.MediaStreams.Count == 1)
                 {
+                    MediaStream mediaStream = mediaInfo.MediaStreams.First();
                     mediaStream.Index = startIndex++;
                     mediaStream.Type = MediaStreamType.Audio;
                     mediaStream.IsExternal = true;
-                    mediaStream.Path = path;
-                    mediaStream.IsDefault = false;
-                    mediaStream.Title = null;
+                    mediaStream.Path = externalAudioFileInfo.Path;
+                    mediaStream.IsDefault = externalAudioFileInfo.IsDefault || mediaStream.IsDefault;
+                    mediaStream.IsForced = externalAudioFileInfo.IsForced || mediaStream.IsForced;
 
-                    if (string.IsNullOrEmpty(mediaStream.Language))
+                    yield return DetectLanguage(mediaStream, fileNameWithoutExtension, videoFileNameWithoutExtension);
+                }
+                else
+                {
+                    foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
                     {
-                        // Try to translate to three character code
-                        // Be flexible and check against both the full and three character versions
-                        var language = StringExtensions.RightPart(fileNameWithoutExtension, '.').ToString();
+                        mediaStream.Index = startIndex++;
+                        mediaStream.Type = MediaStreamType.Audio;
+                        mediaStream.IsExternal = true;
+                        mediaStream.Path = externalAudioFileInfo.Path;
 
-                        if (language != fileNameWithoutExtension)
-                        {
-                            var culture = _localizationManager.FindLanguageInfo(language);
-
-                            language = culture == null ? language : culture.ThreeLetterISOLanguageName;
-                            mediaStream.Language = language;
-                        }
+                        yield return DetectLanguage(mediaStream, fileNameWithoutExtension, videoFileNameWithoutExtension);
                     }
-
-                    yield return mediaStream;
                 }
             }
         }
@@ -108,7 +114,7 @@ namespace MediaBrowser.Providers.MediaInfo
         /// <param name="directoryService">The directory service to search for files.</param>
         /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
         /// <returns>A list of external audio file paths.</returns>
-        public IEnumerable<string> GetExternalAudioFiles(
+        public IEnumerable<ExternalAudioFileInfo> GetExternalAudioFiles(
             Video video,
             IDirectoryService directoryService,
             bool clearCache)
@@ -125,28 +131,19 @@ namespace MediaBrowser.Providers.MediaInfo
                 yield break;
             }
 
-            string videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
+            var videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
 
             var files = directoryService.GetFilePaths(folder, clearCache, true);
             for (int i = 0; i < files.Count; i++)
             {
-                string file = files[i];
-                if (string.Equals(video.Path, file, StringComparison.OrdinalIgnoreCase)
-                    || !AudioFileParser.IsAudioFile(file, _namingOptions)
-                    || Path.GetExtension(file.AsSpan()).Equals(".strm", StringComparison.OrdinalIgnoreCase))
+                var subtitleFileInfo = _externalAudioFilePathParser.ParseFile(files[i]);
+
+                if (subtitleFileInfo == null)
                 {
                     continue;
                 }
 
-                string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file);
-                // The audio filename must either be equal to the video filename or start with the video filename followed by a dot
-                if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)
-                    || (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length
-                        && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.'
-                        && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)))
-                {
-                    yield return file;
-                }
+                yield return subtitleFileInfo;
             }
         }
 
@@ -172,5 +169,48 @@ namespace MediaBrowser.Providers.MediaInfo
                 },
                 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;
+        }
     }
 }

+ 8 - 4
MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs

@@ -43,7 +43,6 @@ namespace MediaBrowser.Providers.MediaInfo
         private readonly AudioResolver _audioResolver;
         private readonly FFProbeVideoInfo _videoProber;
         private readonly FFProbeAudioInfo _audioProber;
-
         private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None);
 
         public FFProbeProvider(
@@ -62,7 +61,7 @@ namespace MediaBrowser.Providers.MediaInfo
         {
             _logger = logger;
             _audioResolver = new AudioResolver(localization, mediaEncoder, namingOptions);
-            _subtitleResolver = new SubtitleResolver(BaseItem.LocalizationManager);
+            _subtitleResolver = new SubtitleResolver(BaseItem.LocalizationManager, mediaEncoder, namingOptions);
             _videoProber = new FFProbeVideoInfo(
                 _logger,
                 mediaSourceManager,
@@ -75,6 +74,7 @@ namespace MediaBrowser.Providers.MediaInfo
                 subtitleManager,
                 chapterManager,
                 libraryManager,
+                _subtitleResolver,
                 _audioResolver);
             _audioProber = new FFProbeAudioInfo(mediaSourceManager, mediaEncoder, itemRepo, libraryManager);
         }
@@ -104,7 +104,9 @@ namespace MediaBrowser.Providers.MediaInfo
 
             if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
                 && !video.SubtitleFiles.SequenceEqual(
-                        _subtitleResolver.GetExternalSubtitleFiles(video, directoryService, false), StringComparer.Ordinal))
+                    _subtitleResolver.GetExternalSubtitleFiles(video, directoryService, false)
+                    .Select(info => info.Path).ToList(),
+                    StringComparer.Ordinal))
             {
                 _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
                 return true;
@@ -112,7 +114,9 @@ namespace MediaBrowser.Providers.MediaInfo
 
             if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
                 && !video.AudioFiles.SequenceEqual(
-                        _audioResolver.GetExternalAudioFiles(video, directoryService, false), StringComparer.Ordinal))
+                    _audioResolver.GetExternalAudioFiles(video, directoryService, false)
+                    .Select(info => info.Path).ToList(),
+                    StringComparer.Ordinal))
             {
                 _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
                 return true;

+ 17 - 6
MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs

@@ -45,6 +45,7 @@ namespace MediaBrowser.Providers.MediaInfo
         private readonly IChapterManager _chapterManager;
         private readonly ILibraryManager _libraryManager;
         private readonly AudioResolver _audioResolver;
+        private readonly SubtitleResolver _subtitleResolver;
         private readonly IMediaSourceManager _mediaSourceManager;
 
         private readonly long _dummyChapterDuration = TimeSpan.FromMinutes(5).Ticks;
@@ -61,6 +62,7 @@ namespace MediaBrowser.Providers.MediaInfo
             ISubtitleManager subtitleManager,
             IChapterManager chapterManager,
             ILibraryManager libraryManager,
+            SubtitleResolver subtitleResolver,
             AudioResolver audioResolver)
         {
             _logger = logger;
@@ -74,6 +76,7 @@ namespace MediaBrowser.Providers.MediaInfo
             _chapterManager = chapterManager;
             _libraryManager = libraryManager;
             _audioResolver = audioResolver;
+            _subtitleResolver = subtitleResolver;
             _mediaSourceManager = mediaSourceManager;
         }
 
@@ -215,7 +218,7 @@ namespace MediaBrowser.Providers.MediaInfo
                 chapters = Array.Empty<ChapterInfo>();
             }
 
-            await AddExternalSubtitles(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
+            await AddExternalSubtitlesAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
 
             await AddExternalAudioAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
 
@@ -526,16 +529,21 @@ namespace MediaBrowser.Providers.MediaInfo
         /// <param name="options">The refreshOptions.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task.</returns>
-        private async Task AddExternalSubtitles(
+        private async Task AddExternalSubtitlesAsync(
             Video video,
             List<MediaStream> currentStreams,
             MetadataRefreshOptions options,
             CancellationToken cancellationToken)
         {
-            var subtitleResolver = new SubtitleResolver(_localization);
-
             var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
-            var externalSubtitleStreams = subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, false);
+            var externalSubtitleStreamsAsync = _subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, false, cancellationToken);
+
+            List<MediaStream> externalSubtitleStreams = new List<MediaStream>();
+
+            await foreach (MediaStream externalSubtitleStream in externalSubtitleStreamsAsync)
+            {
+                externalSubtitleStreams.Add(externalSubtitleStream);
+            }
 
             var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default ||
                                             options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh;
@@ -589,7 +597,10 @@ namespace MediaBrowser.Providers.MediaInfo
                 // Rescan
                 if (downloadedLanguages.Count > 0)
                 {
-                    externalSubtitleStreams = subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, true);
+                    await foreach (MediaStream externalSubtitleStream in _subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, true, cancellationToken))
+                    {
+                        externalSubtitleStreams.Add(externalSubtitleStream);
+                    }
                 }
             }
 

+ 126 - 142
MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs

@@ -1,10 +1,22 @@
 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
 {
@@ -13,15 +25,28 @@ namespace MediaBrowser.Providers.MediaInfo
     /// </summary>
     public class SubtitleResolver
     {
-        private readonly ILocalizationManager _localization;
+        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>
-        public SubtitleResolver(ILocalizationManager localization)
+        /// <param name="mediaEncoder">The media encoder.</param>
+        /// <param name="namingOptions">The naming Options.</param>
+        public SubtitleResolver(
+            ILocalizationManager localization,
+            IMediaEncoder mediaEncoder,
+            NamingOptions namingOptions)
         {
-            _localization = localization;
+            _localizationManager = localization;
+            _mediaEncoder = mediaEncoder;
+            _namingOptions = namingOptions;
+            _subtitleFilePathParser = new SubtitleFilePathParser(_namingOptions);
         }
 
         /// <summary>
@@ -31,40 +56,58 @@ namespace MediaBrowser.Providers.MediaInfo
         /// <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 List<MediaStream> GetExternalSubtitleStreams(
+        public async IAsyncEnumerable<MediaStream> GetExternalSubtitleStreams(
             Video video,
             int startIndex,
             IDirectoryService directoryService,
-            bool clearCache)
+            bool clearCache,
+            [EnumeratorCancellation] CancellationToken cancellationToken)
         {
-            var streams = new List<MediaStream>();
+
+            cancellationToken.ThrowIfCancellationRequested();
 
             if (!video.IsFileProtocol)
             {
-                return streams;
+                yield break;
             }
 
-            AddExternalSubtitleStreams(streams, video.ContainingFolderPath, video.Path, startIndex, directoryService, clearCache);
-
-            startIndex += streams.Count;
+            var subtitleFileInfos = GetExternalSubtitleFiles(video, directoryService, clearCache);
 
-            string folder = video.GetInternalMetadataPath();
+            var videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
 
-            if (!Directory.Exists(folder))
+            foreach (var subtitleFileInfo in subtitleFileInfos)
             {
-                return streams;
-            }
+                string fileName = Path.GetFileName(subtitleFileInfo.Path);
+                string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(subtitleFileInfo.Path);
+                Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(subtitleFileInfo.Path, cancellationToken).ConfigureAwait(false);
 
-            try
-            {
-                AddExternalSubtitleStreams(streams, folder, video.Path, startIndex, directoryService, clearCache);
-            }
-            catch (IOException)
-            {
-            }
+                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;
 
-            return streams;
+                        yield return DetectLanguage(mediaStream, fileNameWithoutExtension, videoFileNameWithoutExtension);
+                    }
+                }
+            }
         }
 
         /// <summary>
@@ -74,7 +117,7 @@ namespace MediaBrowser.Providers.MediaInfo
         /// <param name="directoryService">The directory service to search for files.</param>
         /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
         /// <returns>The external subtitle file paths located.</returns>
-        public IEnumerable<string> GetExternalSubtitleFiles(
+        public IEnumerable<SubtitleFileInfo> GetExternalSubtitleFiles(
             Video video,
             IDirectoryService directoryService,
             bool clearCache)
@@ -84,152 +127,93 @@ namespace MediaBrowser.Providers.MediaInfo
                 yield break;
             }
 
-            var streams = GetExternalSubtitleStreams(video, 0, directoryService, clearCache);
+            // Check if video folder exists
+            string folder = video.ContainingFolderPath;
+            if (!Directory.Exists(folder))
+            {
+                yield break;
+            }
+
+            var videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path);
 
-            foreach (var stream in streams)
+            var files = directoryService.GetFilePaths(folder, clearCache, true);
+            for (int i = 0; i < files.Count; i++)
             {
-                yield return stream.Path;
+                var subtitleFileInfo = _subtitleFilePathParser.ParseFile(files[i]);
+
+                if (subtitleFileInfo == null)
+                {
+                    continue;
+                }
+
+                yield return subtitleFileInfo;
             }
         }
 
         /// <summary>
-        /// Extracts the subtitle files from the provided list and adds them to the list of streams.
+        /// Returns the media info of the given subtitle file.
         /// </summary>
-        /// <param name="streams">The list of streams to add external subtitles to.</param>
-        /// <param name="videoPath">The path to the video file.</param>
-        /// <param name="startIndex">The stream index to start adding subtitle streams at.</param>
-        /// <param name="files">The files to add if they are subtitles.</param>
-        public void AddExternalSubtitleStreams(
-            List<MediaStream> streams,
-            string videoPath,
-            int startIndex,
-            IReadOnlyList<string> files)
+        /// <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)
         {
-            var videoFileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(videoPath);
+            cancellationToken.ThrowIfCancellationRequested();
 
-            for (var i = 0; i < files.Count; i++)
+            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 fullName = files[i];
-                var extension = Path.GetExtension(fullName.AsSpan());
-                if (!IsSubtitleExtension(extension))
+                var lastDot = languageString.LastIndexOf('.');
+                if (lastDot < videoFileNameWithoutExtension.Length)
                 {
-                    continue;
+                    break;
                 }
 
-                var fileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(fullName);
+                var currentSlice = languageString[lastDot..];
+                languageString = languageString[..lastDot];
 
-                MediaStream mediaStream;
-
-                // The subtitle filename must either be equal to the video filename or start with the video filename followed by a dot
-                if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))
+                if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase)
+                    || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase)
+                    || currentSlice.Equals(".foreign", StringComparison.OrdinalIgnoreCase))
                 {
-                    mediaStream = new MediaStream
-                    {
-                        Index = startIndex++,
-                        Type = MediaStreamType.Subtitle,
-                        IsExternal = true,
-                        Path = fullName
-                    };
+                    continue;
                 }
-                else if (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length
-                         && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.'
-                         && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))
-                {
-                    var isForced = fullName.Contains(".forced.", StringComparison.OrdinalIgnoreCase)
-                                   || fullName.Contains(".foreign.", StringComparison.OrdinalIgnoreCase);
 
-                    var isDefault = fullName.Contains(".default.", StringComparison.OrdinalIgnoreCase);
+                var currentSliceString = currentSlice[1..];
 
-                    // Support xbmc naming conventions - 300.spanish.srt
-                    var languageSpan = fileNameWithoutExtension;
-                    while (languageSpan.Length > 0)
-                    {
-                        var lastDot = languageSpan.LastIndexOf('.');
-                        if (lastDot < videoFileNameWithoutExtension.Length)
-                        {
-                            languageSpan = ReadOnlySpan<char>.Empty;
-                            break;
-                        }
-
-                        var currentSlice = languageSpan[lastDot..];
-                        if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase)
-                            || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase)
-                            || currentSlice.Equals(".foreign", StringComparison.OrdinalIgnoreCase))
-                        {
-                            languageSpan = languageSpan[..lastDot];
-                            continue;
-                        }
-
-                        languageSpan = languageSpan[(lastDot + 1)..];
-                        break;
-                    }
+                // Try to translate to three character code
+                var culture = _localizationManager.FindLanguageInfo(currentSliceString);
 
-                    var language = languageSpan.ToString();
-                    if (string.IsNullOrWhiteSpace(language))
-                    {
-                        language = null;
-                    }
-                    else
+                if (culture == null || mediaStream.Language != null)
+                {
+                    if (mediaStream.Title == null)
                     {
-                        // Try to translate to three character code
-                        // Be flexible and check against both the full and three character versions
-                        var culture = _localization.FindLanguageInfo(language);
-
-                        language = culture == null ? language : culture.ThreeLetterISOLanguageName;
+                        mediaStream.Title = currentSliceString;
                     }
-
-                    mediaStream = new MediaStream
-                    {
-                        Index = startIndex++,
-                        Type = MediaStreamType.Subtitle,
-                        IsExternal = true,
-                        Path = fullName,
-                        Language = language,
-                        IsForced = isForced,
-                        IsDefault = isDefault
-                    };
                 }
                 else
                 {
-                    continue;
+                    mediaStream.Language = culture.ThreeLetterISOLanguageName;
                 }
-
-                mediaStream.Codec = extension.TrimStart('.').ToString().ToLowerInvariant();
-
-                streams.Add(mediaStream);
             }
-        }
-
-        private static bool IsSubtitleExtension(ReadOnlySpan<char> extension)
-        {
-            return extension.Equals(".srt", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".ssa", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".ass", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".sub", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".vtt", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".smi", StringComparison.OrdinalIgnoreCase)
-                   || extension.Equals(".sami", StringComparison.OrdinalIgnoreCase);
-        }
-
-        private static ReadOnlySpan<char> NormalizeFilenameForSubtitleComparison(string filename)
-        {
-            // Try to account for sloppy file naming
-            filename = filename.Replace("_", string.Empty, StringComparison.Ordinal);
-            filename = filename.Replace(" ", string.Empty, StringComparison.Ordinal);
-            return Path.GetFileNameWithoutExtension(filename.AsSpan());
-        }
-
-        private void AddExternalSubtitleStreams(
-            List<MediaStream> streams,
-            string folder,
-            string videoPath,
-            int startIndex,
-            IDirectoryService directoryService,
-            bool clearCache)
-        {
-            var files = directoryService.GetFilePaths(folder, clearCache, true);
 
-            AddExternalSubtitleStreams(streams, videoPath, startIndex, files);
+            return mediaStream;
         }
     }
 }

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

@@ -0,0 +1,40 @@
+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));
+        }
+    }
+}

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

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

+ 0 - 129
tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs

@@ -1,129 +0,0 @@
-#pragma warning disable CA1002 // Do not expose generic lists
-
-using System.Collections.Generic;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Globalization;
-using MediaBrowser.Providers.MediaInfo;
-using Moq;
-using Xunit;
-
-namespace Jellyfin.Providers.Tests.MediaInfo
-{
-    public class SubtitleResolverTests
-    {
-        public static TheoryData<List<MediaStream>, string, int, string[], MediaStream[]> AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData()
-        {
-            var data = new TheoryData<List<MediaStream>, string, int, string[], MediaStream[]>();
-
-            var index = 0;
-            data.Add(
-                new List<MediaStream>(),
-                "/video/My.Video.mkv",
-                index,
-                new[]
-                {
-                    "/video/My.Video.mp3",
-                    "/video/My.Video.png",
-                    "/video/My.Video.srt",
-                    "/video/My.Video.txt",
-                    "/video/My.Video.vtt",
-                    "/video/My.Video.ass",
-                    "/video/My.Video.sub",
-                    "/video/My.Video.ssa",
-                    "/video/My.Video.smi",
-                    "/video/My.Video.sami",
-                    "/video/My.Video.en.srt",
-                    "/video/My.Video.default.en.srt",
-                    "/video/My.Video.default.forced.en.srt",
-                    "/video/My.Video.en.default.forced.srt",
-                    "/video/My.Video.With.Additional.Garbage.en.srt",
-                    "/video/My.Video With Additional Garbage.srt"
-                },
-                new[]
-                {
-                    CreateMediaStream("/video/My.Video.srt", "srt", null, index++),
-                    CreateMediaStream("/video/My.Video.vtt", "vtt", null, index++),
-                    CreateMediaStream("/video/My.Video.ass", "ass", null, index++),
-                    CreateMediaStream("/video/My.Video.sub", "sub", null, index++),
-                    CreateMediaStream("/video/My.Video.ssa", "ssa", null, index++),
-                    CreateMediaStream("/video/My.Video.smi", "smi", null, index++),
-                    CreateMediaStream("/video/My.Video.sami", "sami", null, index++),
-                    CreateMediaStream("/video/My.Video.en.srt", "srt", "en", index++),
-                    CreateMediaStream("/video/My.Video.default.en.srt", "srt", "en", index++, isDefault: true),
-                    CreateMediaStream("/video/My.Video.default.forced.en.srt", "srt", "en", index++, isForced: true, isDefault: true),
-                    CreateMediaStream("/video/My.Video.en.default.forced.srt", "srt", "en", index++, isForced: true, isDefault: true),
-                    CreateMediaStream("/video/My.Video.With.Additional.Garbage.en.srt", "srt", "en", index),
-                });
-
-            return data;
-        }
-
-        [Theory]
-        [MemberData(nameof(AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData))]
-        public void AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles(List<MediaStream> streams, string videoPath, int startIndex, string[] files, MediaStream[] expectedResult)
-        {
-            new SubtitleResolver(Mock.Of<ILocalizationManager>()).AddExternalSubtitleStreams(streams, videoPath, startIndex, files);
-
-            Assert.Equal(expectedResult.Length, streams.Count);
-            for (var i = 0; i < expectedResult.Length; i++)
-            {
-                var expected = expectedResult[i];
-                var actual = streams[i];
-
-                Assert.Equal(expected.Index, actual.Index);
-                Assert.Equal(expected.Type, actual.Type);
-                Assert.Equal(expected.IsExternal, actual.IsExternal);
-                Assert.Equal(expected.Path, actual.Path);
-                Assert.Equal(expected.IsDefault, actual.IsDefault);
-                Assert.Equal(expected.IsForced, actual.IsForced);
-                Assert.Equal(expected.Language, actual.Language);
-            }
-        }
-
-        [Theory]
-        [InlineData("/video/My Video.mkv", "/video/My Video.srt", "srt", null, false, false)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.srt", "srt", null, false, false)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.foreign.srt", "srt", null, true, false)]
-        [InlineData("/video/My Video.mkv", "/video/My Video.forced.srt", "srt", null, true, false)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.default.srt", "srt", null, false, true)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.forced.default.srt", "srt", null, true, true)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.en.srt", "srt", "en", false, false)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.default.en.srt", "srt", "en", false, true)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.default.forced.en.srt", "srt", "en", true, true)]
-        [InlineData("/video/My.Video.mkv", "/video/My.Video.en.default.forced.srt", "srt", "en", true, true)]
-        public void AddExternalSubtitleStreams_GivenSingleFile_ReturnsExpectedSubtitle(string videoPath, string file, string codec, string? language, bool isForced, bool isDefault)
-        {
-            var streams = new List<MediaStream>();
-            var expected = CreateMediaStream(file, codec, language, 0, isForced, isDefault);
-
-            new SubtitleResolver(Mock.Of<ILocalizationManager>()).AddExternalSubtitleStreams(streams, videoPath, 0, new[] { file });
-
-            Assert.Single(streams);
-
-            var actual = streams[0];
-
-            Assert.Equal(expected.Index, actual.Index);
-            Assert.Equal(expected.Type, actual.Type);
-            Assert.Equal(expected.IsExternal, actual.IsExternal);
-            Assert.Equal(expected.Path, actual.Path);
-            Assert.Equal(expected.IsDefault, actual.IsDefault);
-            Assert.Equal(expected.IsForced, actual.IsForced);
-            Assert.Equal(expected.Language, actual.Language);
-        }
-
-        private static MediaStream CreateMediaStream(string path, string codec, string? language, int index, bool isForced = false, bool isDefault = false)
-        {
-            return new()
-            {
-                Index = index,
-                Codec = codec,
-                Type = MediaStreamType.Subtitle,
-                IsExternal = true,
-                Path = path,
-                IsDefault = isDefault,
-                IsForced = isForced,
-                Language = language
-            };
-        }
-    }
-}