diff --git a/Emby.Naming/Audio/ExternalAudioFileInfo.cs b/Emby.Naming/Audio/ExternalAudioFileInfo.cs new file mode 100644 index 0000000000..4d02939cbf --- /dev/null +++ b/Emby.Naming/Audio/ExternalAudioFileInfo.cs @@ -0,0 +1,52 @@ +namespace Emby.Naming.Audio +{ + /// + /// Class holding information about external audio files. + /// + public class ExternalAudioFileInfo + { + /// + /// Initializes a new instance of the class. + /// + /// Path to file. + /// Is default. + /// Is forced. + public ExternalAudioFileInfo(string path, bool isDefault, bool isForced) + { + Path = path; + IsDefault = isDefault; + IsForced = isForced; + } + + /// + /// Gets or sets the path. + /// + /// The path. + public string Path { get; set; } + + /// + /// Gets or sets the language. + /// + /// The language. + public string? Language { get; set; } + + /// + /// Gets or sets the title. + /// + /// The title. + public string? Title { get; set; } + + /// + /// Gets or sets a value indicating whether this instance is default. + /// + /// true if this instance is default; otherwise, false. + public bool IsDefault { get; set; } + + + /// + /// Gets or sets a value indicating whether this instance is forced. + /// + /// true if this instance is forced; otherwise, false. + public bool IsForced { get; set; } + } +} diff --git a/Emby.Naming/Audio/ExternalAudioFilePathParser.cs b/Emby.Naming/Audio/ExternalAudioFilePathParser.cs new file mode 100644 index 0000000000..ab5af9fc6a --- /dev/null +++ b/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 +{ + /// + /// External Audio Parser class. + /// + public class ExternalAudioFilePathParser + { + private readonly NamingOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// object containing AudioFileExtensions, ExternalAudioDefaultFlags, ExternalAudioForcedFlags and ExternalAudioFlagDelimiters. + public ExternalAudioFilePathParser(NamingOptions options) + { + _options = options; + } + + /// + /// Parse file to determine if it is a ExternalAudio and . + /// + /// Path to file. + /// Returns null or object if parsing is successful. + 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); + } + } +} diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index eb211050f1..82a3ad2b7b 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/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*(?[^ ].*?)\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 /// public string[] AudioFileExtensions { get; set; } + /// + /// Gets or sets list of external audio flag delimiters. + /// + public char[] ExternalAudioFlagDelimiters { get; set; } + + /// + /// Gets or sets list of external audio forced flags. + /// + public string[] ExternalAudioForcedFlags { get; set; } + + /// + /// Gets or sets list of external audio default flags. + /// + public string[] ExternalAudioDefaultFlags { get; set; } + /// /// Gets or sets list of album stacking prefixes. /// diff --git a/Emby.Naming/Subtitles/SubtitleInfo.cs b/Emby.Naming/Subtitles/SubtitleFileInfo.cs similarity index 82% rename from Emby.Naming/Subtitles/SubtitleInfo.cs rename to Emby.Naming/Subtitles/SubtitleFileInfo.cs index 1fb2e0dc89..ed9ab3ebd4 100644 --- a/Emby.Naming/Subtitles/SubtitleInfo.cs +++ b/Emby.Naming/Subtitles/SubtitleFileInfo.cs @@ -3,15 +3,15 @@ namespace Emby.Naming.Subtitles /// /// Class holding information about subtitle. /// - public class SubtitleInfo + public class SubtitleFileInfo { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Path to file. /// Is subtitle default. /// Is subtitle forced. - 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 /// The language. public string? Language { get; set; } + /// + /// Gets or sets the title. + /// + /// The title. + public string? Title { get; set; } + /// /// Gets or sets a value indicating whether this instance is default. /// diff --git a/Emby.Naming/Subtitles/SubtitleParser.cs b/Emby.Naming/Subtitles/SubtitleFilePathParser.cs similarity index 57% rename from Emby.Naming/Subtitles/SubtitleParser.cs rename to Emby.Naming/Subtitles/SubtitleFilePathParser.cs index 5809c512a8..7b2adf3f56 100644 --- a/Emby.Naming/Subtitles/SubtitleParser.cs +++ b/Emby.Naming/Subtitles/SubtitleFilePathParser.cs @@ -9,25 +9,25 @@ namespace Emby.Naming.Subtitles /// /// Subtitle Parser class. /// - public class SubtitleParser + public class SubtitleFilePathParser { private readonly NamingOptions _options; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// object containing SubtitleFileExtensions, SubtitleDefaultFlags, SubtitleForcedFlags and SubtitleFlagDelimiters. - public SubtitleParser(NamingOptions options) + public SubtitleFilePathParser(NamingOptions options) { _options = options; } /// - /// Parse file to determine if is subtitle and . + /// Parse file to determine if it is a subtitle and . /// /// Path to file. - /// Returns null or object if parsing is successful. - public SubtitleInfo? ParseFile(string path) + /// Returns null or object if parsing is successful. + 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); } diff --git a/MediaBrowser.Model/Dlna/DlnaProfileType.cs b/MediaBrowser.Model/Dlna/DlnaProfileType.cs index e30ed0f3c6..c1a663bf17 100644 --- a/MediaBrowser.Model/Dlna/DlnaProfileType.cs +++ b/MediaBrowser.Model/Dlna/DlnaProfileType.cs @@ -6,6 +6,7 @@ namespace MediaBrowser.Model.Dlna { Audio = 0, Video = 1, - Photo = 2 + Photo = 2, + Subtitle = 3 } } diff --git a/MediaBrowser.Providers/MediaInfo/AudioResolver.cs b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs index 425913501a..745738f75b 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioResolver.cs +++ b/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; /// /// Initializes a new instance of the class. @@ -41,6 +45,7 @@ namespace MediaBrowser.Providers.MediaInfo _localizationManager = localizationManager; _mediaEncoder = mediaEncoder; _namingOptions = namingOptions; + _externalAudioFilePathParser = new ExternalAudioFilePathParser(_namingOptions); } /// @@ -66,37 +71,38 @@ namespace MediaBrowser.Providers.MediaInfo yield break; } - IEnumerable paths = GetExternalAudioFiles(video, directoryService, clearCache); - foreach (string path in paths) - { - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path); - Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(path, cancellationToken).ConfigureAwait(false); + string videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path); - foreach (MediaStream mediaStream in mediaInfo.MediaStreams) + 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 = 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 /// The directory service to search for files. /// True if the directory service cache should be cleared before searching. /// A list of external audio file paths. - public IEnumerable GetExternalAudioFiles( + public IEnumerable 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; + } } } diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs index 19a435196a..3a819ff7c2 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs +++ b/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 _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; diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 77a849d00b..fa02874f20 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/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(); } - 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 /// The refreshOptions. /// The cancellation token. /// Task. - private async Task AddExternalSubtitles( + private async Task AddExternalSubtitlesAsync( Video video, List 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 externalSubtitleStreams = new List(); + + 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); + } } } diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs index ba284187ed..15beea39a1 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs +++ b/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 /// 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; /// /// Initializes a new instance of the class. /// /// The localization manager. - public SubtitleResolver(ILocalizationManager localization) + /// The media encoder. + /// The naming Options. + public SubtitleResolver( + ILocalizationManager localization, + IMediaEncoder mediaEncoder, + NamingOptions namingOptions) { - _localization = localization; + _localizationManager = localization; + _mediaEncoder = mediaEncoder; + _namingOptions = namingOptions; + _subtitleFilePathParser = new SubtitleFilePathParser(_namingOptions); } /// @@ -31,40 +56,58 @@ namespace MediaBrowser.Providers.MediaInfo /// The stream index to start adding subtitle streams at. /// The directory service to search for files. /// True if the directory service cache should be cleared before searching. + /// The cancellation token to cancel operation. /// The external subtitle streams located. - public List GetExternalSubtitleStreams( + public async IAsyncEnumerable GetExternalSubtitleStreams( Video video, int startIndex, IDirectoryService directoryService, - bool clearCache) + bool clearCache, + [EnumeratorCancellation] CancellationToken cancellationToken) { - var streams = new List(); + + cancellationToken.ThrowIfCancellationRequested(); if (!video.IsFileProtocol) { - return streams; + yield break; } - AddExternalSubtitleStreams(streams, video.ContainingFolderPath, video.Path, startIndex, directoryService, clearCache); + var subtitleFileInfos = GetExternalSubtitleFiles(video, directoryService, clearCache); - startIndex += streams.Count; + var videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path); - string folder = video.GetInternalMetadataPath(); - - 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; - return streams; + 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); + } + } + } } /// @@ -74,7 +117,7 @@ namespace MediaBrowser.Providers.MediaInfo /// The directory service to search for files. /// True if the directory service cache should be cleared before searching. /// The external subtitle file paths located. - public IEnumerable GetExternalSubtitleFiles( + public IEnumerable GetExternalSubtitleFiles( Video video, IDirectoryService directoryService, bool clearCache) @@ -84,152 +127,93 @@ namespace MediaBrowser.Providers.MediaInfo yield break; } - var streams = GetExternalSubtitleStreams(video, 0, directoryService, clearCache); - - foreach (var stream in streams) + // Check if video folder exists + string folder = video.ContainingFolderPath; + if (!Directory.Exists(folder)) { - yield return stream.Path; + 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; } } /// - /// 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. /// - /// The list of streams to add external subtitles to. - /// The path to the video file. - /// The stream index to start adding subtitle streams at. - /// The files to add if they are subtitles. - public void AddExternalSubtitleStreams( - List streams, - string videoPath, - int startIndex, - IReadOnlyList files) + /// The path to the subtitle file. + /// The cancellation token to cancel operation. + /// The media info for the given subtitle file. + private Task 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) + { + 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 fileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(fullName); + var currentSliceString = currentSlice[1..]; - MediaStream mediaStream; + // Try to translate to three character code + var culture = _localizationManager.FindLanguageInfo(currentSliceString); - // 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 (culture == null || mediaStream.Language != null) { - mediaStream = new MediaStream + if (mediaStream.Title == null) { - Index = startIndex++, - Type = MediaStreamType.Subtitle, - IsExternal = true, - Path = fullName - }; - } - else if (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length - && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.' - && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) - { - var isForced = fullName.Contains(".forced.", StringComparison.OrdinalIgnoreCase) - || fullName.Contains(".foreign.", StringComparison.OrdinalIgnoreCase); - - var isDefault = fullName.Contains(".default.", StringComparison.OrdinalIgnoreCase); - - // Support xbmc naming conventions - 300.spanish.srt - var languageSpan = fileNameWithoutExtension; - while (languageSpan.Length > 0) - { - var lastDot = languageSpan.LastIndexOf('.'); - if (lastDot < videoFileNameWithoutExtension.Length) - { - languageSpan = ReadOnlySpan.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; + mediaStream.Title = currentSliceString; } - - var language = languageSpan.ToString(); - if (string.IsNullOrWhiteSpace(language)) - { - language = null; - } - else - { - // Try to translate to three character code - // Be flexible and check against both the full and three character versions - var culture = _localization.FindLanguageInfo(language); - - language = culture == null ? language : culture.ThreeLetterISOLanguageName; - } - - mediaStream = new MediaStream - { - Index = startIndex++, - Type = MediaStreamType.Subtitle, - IsExternal = true, - Path = fullName, - Language = language, - IsForced = isForced, - IsDefault = isDefault - }; } else { - continue; + mediaStream.Language = culture.ThreeLetterISOLanguageName; } - - mediaStream.Codec = extension.TrimStart('.').ToString().ToLowerInvariant(); - - streams.Add(mediaStream); } - } - private static bool IsSubtitleExtension(ReadOnlySpan 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 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 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; } } } diff --git a/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleFilePathParserTests.cs b/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleFilePathParserTests.cs new file mode 100644 index 0000000000..5c62d9418b --- /dev/null +++ b/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)); + } + } +} diff --git a/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs b/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs deleted file mode 100644 index 2446660f32..0000000000 --- a/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs +++ /dev/null @@ -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)); - } - } -} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs deleted file mode 100644 index 040ea5d1dc..0000000000 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs +++ /dev/null @@ -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, string, int, string[], MediaStream[]> AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData() - { - var data = new TheoryData, string, int, string[], MediaStream[]>(); - - var index = 0; - data.Add( - new List(), - "/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 streams, string videoPath, int startIndex, string[] files, MediaStream[] expectedResult) - { - new SubtitleResolver(Mock.Of()).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(); - var expected = CreateMediaStream(file, codec, language, 0, isForced, isDefault); - - new SubtitleResolver(Mock.Of()).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 - }; - } - } -}