diff options
38 files changed, 1227 insertions, 794 deletions
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index eb211050f..a79153e86 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -23,47 +23,60 @@ namespace Emby.Naming.Common { VideoFileExtensions = new[] { - ".m4v", + ".001", + ".3g2", ".3gp", - ".nsv", - ".ts", - ".ty", - ".strm", - ".rm", - ".rmvb", - ".ifo", - ".mov", - ".qt", - ".divx", - ".xvid", - ".bivx", - ".vob", - ".nrg", - ".img", - ".iso", - ".pva", - ".wmv", + ".amv", ".asf", ".asx", - ".ogm", - ".m2v", ".avi", ".bin", + ".bivx", + ".divx", + ".dv", ".dvr-ms", - ".mpg", - ".mpeg", - ".mp4", + ".f4v", + ".fli", + ".flv", + ".ifo", + ".img", + ".iso", + ".m2t", + ".m2ts", + ".m2v", + ".m4v", ".mkv", - ".avc", - ".vp3", - ".svq3", + ".mk3d", + ".mov", + ".mp2", + ".mp4", + ".mpe", + ".mpeg", + ".mpg", + ".mts", + ".mxf", + ".nrg", + ".nsv", ".nuv", + ".ogg", + ".ogm", + ".ogv", + ".pva", + ".qt", + ".rec", + ".rm", + ".rmvb", + ".svq3", + ".tp", + ".ts", + ".ty", ".viv", - ".dv", - ".fli", - ".flv", - ".001", - ".tp" + ".vob", + ".vp3", + ".webm", + ".wmv", + ".wtv", + ".xvid" }; VideoFlagDelimiters = new[] @@ -149,32 +162,20 @@ namespace Emby.Naming.Common SubtitleFileExtensions = new[] { + ".ass", + ".mks", + ".sami", + ".smi", ".srt", ".ssa", - ".ass", - ".sub" - }; - - SubtitleFlagDelimiters = new[] - { - '.' - }; - - SubtitleForcedFlags = new[] - { - "foreign", - "forced" - }; - - SubtitleDefaultFlags = new[] - { - "default" + ".sub", + ".vtt", }; AlbumStackingPrefixes = new[] { - "disc", "cd", + "disc", "disk", "vol", "volume" @@ -182,68 +183,101 @@ namespace Emby.Naming.Common AudioFileExtensions = new[] { - ".nsv", - ".m4a", - ".flac", + ".669", + ".3gp", + ".aa", ".aac", - ".strm", - ".pls", - ".rm", - ".mpa", - ".wav", - ".wma", - ".ogg", - ".opus", - ".mp3", - ".mp2", - ".mod", + ".aax", + ".ac3", + ".act", + ".adp", + ".adplug", + ".adx", + ".afc", ".amf", - ".669", + ".aif", + ".aiff", + ".alac", + ".amr", + ".ape", + ".ast", + ".au", + ".awb", + ".cda", + ".cue", ".dmf", + ".dsf", ".dsm", + ".dsp", + ".dts", + ".dvf", ".far", + ".flac", ".gdm", + ".gsm", + ".gym", + ".hps", ".imf", ".it", ".m15", + ".m4a", + ".m4b", + ".mac", ".med", + ".mka", + ".mmf", + ".mod", + ".mogg", + ".mp2", + ".mp3", + ".mpa", + ".mpc", + ".mpp", + ".mp+", + ".msv", + ".nmf", + ".nsf", + ".nsv", + ".oga", + ".ogg", ".okt", + ".opus", + ".pls", + ".ra", + ".rf64", + ".rm", ".s3m", - ".stm", ".sfx", + ".shn", + ".sid", + ".spc", + ".stm", + ".strm", ".ult", ".uni", - ".xm", - ".sid", - ".ac3", - ".dts", - ".cue", - ".aif", - ".aiff", - ".ape", - ".mac", - ".mpc", - ".mp+", - ".mpp", - ".shn", + ".vox", + ".wav", + ".wma", ".wv", - ".nsf", - ".spc", - ".gym", - ".adplug", - ".adx", - ".dsp", - ".adp", - ".ymf", - ".ast", - ".afc", - ".hps", + ".xm", ".xsp", - ".acc", - ".m4b", - ".oga", - ".dsf", - ".mka" + ".ymf" + }; + + MediaFlagDelimiters = new[] + { + "." + }; + + MediaForcedFlags = new[] + { + "foreign", + "forced" + }; + + MediaDefaultFlags = new[] + { + "default" }; EpisodeExpressions = new[] @@ -648,45 +682,6 @@ namespace Emby.Naming.Common @"^\s*(?<name>[^ ].*?)\s*$" }; - var extensions = VideoFileExtensions.ToList(); - - extensions.AddRange(new[] - { - ".mkv", - ".m2t", - ".m2ts", - ".img", - ".iso", - ".mk3d", - ".ts", - ".rmvb", - ".mov", - ".avi", - ".mpg", - ".mpeg", - ".wmv", - ".mp4", - ".divx", - ".dvr-ms", - ".wtv", - ".ogm", - ".ogv", - ".asf", - ".m4v", - ".flv", - ".f4v", - ".3gp", - ".webm", - ".mts", - ".m2v", - ".rec", - ".mxf" - }); - - VideoFileExtensions = extensions - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - MultipleEpisodeExpressions = new[] { @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$", @@ -718,29 +713,29 @@ namespace Emby.Naming.Common public string[] AudioFileExtensions { get; set; } /// <summary> - /// Gets or sets list of album stacking prefixes. + /// Gets or sets list of external media flag delimiters. /// </summary> - public string[] AlbumStackingPrefixes { get; set; } + public string[] MediaFlagDelimiters { get; set; } /// <summary> - /// Gets or sets list of subtitle file extensions. + /// Gets or sets list of external media forced flags. /// </summary> - public string[] SubtitleFileExtensions { get; set; } + public string[] MediaForcedFlags { get; set; } /// <summary> - /// Gets or sets list of subtitles flag delimiters. + /// Gets or sets list of external media default flags. /// </summary> - public char[] SubtitleFlagDelimiters { get; set; } + public string[] MediaDefaultFlags { get; set; } /// <summary> - /// Gets or sets list of subtitle forced flags. + /// Gets or sets list of album stacking prefixes. /// </summary> - public string[] SubtitleForcedFlags { get; set; } + public string[] AlbumStackingPrefixes { get; set; } /// <summary> - /// Gets or sets list of subtitle default flags. + /// Gets or sets list of subtitle file extensions. /// </summary> - public string[] SubtitleDefaultFlags { get; set; } + public string[] SubtitleFileExtensions { get; set; } /// <summary> /// Gets or sets list of episode regular expressions. diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs new file mode 100644 index 000000000..9d07dc2f9 --- /dev/null +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -0,0 +1,116 @@ +using System; +using System.IO; +using System.Linq; +using Emby.Naming.Common; +using Jellyfin.Extensions; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Globalization; + +namespace Emby.Naming.ExternalFiles +{ + /// <summary> + /// External media file parser class. + /// </summary> + public class ExternalPathParser + { + private readonly NamingOptions _namingOptions; + private readonly DlnaProfileType _type; + private readonly ILocalizationManager _localizationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ExternalPathParser"/> class. + /// </summary> + /// <param name="localizationManager">The localization manager.</param> + /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param> + /// <param name="type">The <see cref="DlnaProfileType"/> of the parsed file.</param> + public ExternalPathParser(NamingOptions namingOptions, ILocalizationManager localizationManager, DlnaProfileType type) + { + _localizationManager = localizationManager; + _namingOptions = namingOptions; + _type = type; + } + + /// <summary> + /// Parse filename and extract information. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="extraString">Part of the filename only containing the extra information.</param> + /// <returns>Returns null or an <see cref="ExternalPathParserResult"/> object if parsing is successful.</returns> + public ExternalPathParserResult? ParseFile(string path, string? extraString) + { + if (path.Length == 0) + { + return null; + } + + var extension = Path.GetExtension(path); + if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) + { + return null; + } + + var pathInfo = new ExternalPathParserResult(path); + + if (string.IsNullOrEmpty(extraString)) + { + return pathInfo; + } + + foreach (var separator in _namingOptions.MediaFlagDelimiters) + { + var languageString = extraString; + var titleString = string.Empty; + int separatorLength = separator.Length; + + while (languageString.Length > 0) + { + int lastSeparator = languageString.LastIndexOf(separator, StringComparison.OrdinalIgnoreCase); + + if (lastSeparator == -1) + { + break; + } + + string currentSlice = languageString[lastSeparator..]; + string currentSliceWithoutSeparator = currentSlice[separatorLength..]; + + if (_namingOptions.MediaDefaultFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase))) + { + pathInfo.IsDefault = true; + extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); + languageString = languageString[..lastSeparator]; + continue; + } + + if (_namingOptions.MediaForcedFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase))) + { + pathInfo.IsForced = true; + extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); + languageString = languageString[..lastSeparator]; + continue; + } + + // Try to translate to three character code + var culture = _localizationManager.FindLanguageInfo(currentSliceWithoutSeparator); + + if (culture != null && pathInfo.Language == null) + { + pathInfo.Language = culture.ThreeLetterISOLanguageName; + extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); + } + else + { + titleString = currentSlice + titleString; + } + + languageString = languageString[..lastSeparator]; + } + + pathInfo.Title = separatorLength <= titleString.Length ? titleString[separatorLength..] : null; + } + + return pathInfo; + } + } +} diff --git a/Emby.Naming/Subtitles/SubtitleInfo.cs b/Emby.Naming/ExternalFiles/ExternalPathParserResult.cs index 1fb2e0dc8..1cc773a2e 100644 --- a/Emby.Naming/Subtitles/SubtitleInfo.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParserResult.cs @@ -1,17 +1,17 @@ -namespace Emby.Naming.Subtitles +namespace Emby.Naming.ExternalFiles { /// <summary> - /// Class holding information about subtitle. + /// Class holding information about external files. /// </summary> - public class SubtitleInfo + public class ExternalPathParserResult { /// <summary> - /// Initializes a new instance of the <see cref="SubtitleInfo"/> class. + /// Initializes a new instance of the <see cref="ExternalPathParserResult"/> class. /// </summary> /// <param name="path">Path to file.</param> - /// <param name="isDefault">Is subtitle default.</param> - /// <param name="isForced">Is subtitle forced.</param> - public SubtitleInfo(string path, bool isDefault, bool isForced) + /// <param name="isDefault">Is default.</param> + /// <param name="isForced">Is forced.</param> + public ExternalPathParserResult(string path, bool isDefault = false, bool isForced = false) { Path = path; IsDefault = isDefault; @@ -31,6 +31,12 @@ namespace Emby.Naming.Subtitles 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> diff --git a/Emby.Naming/Subtitles/SubtitleParser.cs b/Emby.Naming/Subtitles/SubtitleParser.cs deleted file mode 100644 index 5809c512a..000000000 --- a/Emby.Naming/Subtitles/SubtitleParser.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using Emby.Naming.Common; -using Jellyfin.Extensions; - -namespace Emby.Naming.Subtitles -{ - /// <summary> - /// Subtitle Parser class. - /// </summary> - public class SubtitleParser - { - private readonly NamingOptions _options; - - /// <summary> - /// Initializes a new instance of the <see cref="SubtitleParser"/> class. - /// </summary> - /// <param name="options"><see cref="NamingOptions"/> object containing SubtitleFileExtensions, SubtitleDefaultFlags, SubtitleForcedFlags and SubtitleFlagDelimiters.</param> - public SubtitleParser(NamingOptions options) - { - _options = options; - } - - /// <summary> - /// Parse file to determine if is subtitle and <see cref="SubtitleInfo"/>. - /// </summary> - /// <param name="path">Path to file.</param> - /// <returns>Returns null or <see cref="SubtitleInfo"/> object if parsing is successful.</returns> - public SubtitleInfo? ParseFile(string path) - { - if (path.Length == 0) - { - return null; - } - - var extension = Path.GetExtension(path); - if (!_options.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - var flags = GetFlags(path); - var info = new SubtitleInfo( - path, - _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)), - _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase))); - - var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparison.OrdinalIgnoreCase) - && !_options.SubtitleForcedFlags.Contains(i, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - // Should have a name, language and file extension - if (parts.Count >= 3) - { - info.Language = parts[^2]; - } - - return info; - } - - private string[] GetFlags(string path) - { - // Note: the tags need be surrounded be either a space ( ), hyphen -, dot . or underscore _. - - var file = Path.GetFileName(path); - - return file.Split(_options.SubtitleFlagDelimiters, StringSplitOptions.RemoveEmptyEntries); - } - } -} diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 6960ff007..8e8eef867 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -119,5 +119,6 @@ "TaskCleanActivityLogDescription": "ورودیهای قدیمیتر از سن تنظیم شده در سیاهه فعالیت را حذف میکند.", "TaskCleanActivityLog": "پاکسازی سیاهه فعالیت", "Undefined": "تعریف نشده", - "TaskOptimizeDatabase": "بهینه سازی پایگاه داده" + "TaskOptimizeDatabase": "بهینه سازی پایگاه داده", + "TaskOptimizeDatabaseDescription": "فشرده سازی پایگاه داده و باز کردن فضای آزاد.اجرای این گزینه بعد از اسکن کردن کتابخانه یا تغییرات دیگر که روی پایگاه داده تأثیر میگذارند میتواند کارایی را بهبود ببخشد." } diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index a18af27f3..a47650a32 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -140,7 +140,7 @@ namespace Emby.Server.Implementations.TV var currentUser = user; var allNextUp = seriesKeys - .Select(i => GetNextUp(i, currentUser, dtoOptions)); + .Select(i => GetNextUp(i, currentUser, dtoOptions, request.Rewatching)); // If viewing all next up for all series, remove first episodes // But if that returns empty, keep those first episodes (avoid completely empty view) @@ -186,9 +186,9 @@ namespace Emby.Server.Implementations.TV /// Gets the next up. /// </summary> /// <returns>Task{Episode}.</returns> - private Tuple<DateTime, Func<Episode>> GetNextUp(string seriesKey, User user, DtoOptions dtoOptions) + private Tuple<DateTime, Func<Episode>> GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool rewatching) { - var lastWatchedEpisode = _libraryManager.GetItemList(new InternalItemsQuery(user) + var lastQuery = new InternalItemsQuery(user) { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, @@ -202,23 +202,43 @@ namespace Emby.Server.Implementations.TV Fields = new[] { ItemFields.SortName }, EnableImages = false } - }).Cast<Episode>().FirstOrDefault(); + }; + + if (rewatching) + { + // find last watched by date played, not by newest episode watched + lastQuery.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }; + } + + var lastWatchedEpisode = _libraryManager.GetItemList(lastQuery).Cast<Episode>().FirstOrDefault(); Func<Episode> getEpisode = () => { - var nextEpisode = _libraryManager.GetItemList(new InternalItemsQuery(user) + var nextQuery = new InternalItemsQuery(user) { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, IncludeItemTypes = new[] { BaseItemKind.Episode }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, Limit = 1, - IsPlayed = false, + IsPlayed = rewatching, IsVirtualItem = false, ParentIndexNumberNotEquals = 0, MinSortName = lastWatchedEpisode?.SortName, DtoOptions = dtoOptions - }).Cast<Episode>().FirstOrDefault(); + }; + + Episode nextEpisode; + if (rewatching) + { + nextQuery.Limit = 2; + // get watched episode after most recently watched + nextEpisode = _libraryManager.GetItemList(nextQuery).Cast<Episode>().ElementAtOrDefault(1); + } + else + { + nextEpisode = _libraryManager.GetItemList(nextQuery).Cast<Episode>().FirstOrDefault(); + } if (_configurationManager.Configuration.DisplaySpecialsWithinSeasons) { @@ -228,7 +248,7 @@ namespace Emby.Server.Implementations.TV SeriesPresentationUniqueKey = seriesKey, ParentIndexNumber = 0, IncludeItemTypes = new[] { BaseItemKind.Episode }, - IsPlayed = false, + IsPlayed = rewatching, IsVirtualItem = false, DtoOptions = dtoOptions }) diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 9425fe519..5d39d906f 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -68,6 +68,7 @@ namespace Jellyfin.Api.Controllers /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> + /// <param name="rewatching">Whether to get a rewatching next up instead of standard next up.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> [HttpGet("NextUp")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -84,7 +85,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableUserData, [FromQuery] DateTime? nextUpDateCutoff, [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool disableFirstEpisode = false) + [FromQuery] bool disableFirstEpisode = false, + [FromQuery] bool rewatching = false) { var options = new DtoOptions { Fields = fields } .AddClientFields(Request) @@ -100,7 +102,8 @@ namespace Jellyfin.Api.Controllers UserId = userId ?? Guid.Empty, EnableTotalRecordCount = enableTotalRecordCount, DisableFirstEpisode = disableFirstEpisode, - NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue + NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue, + Rewatching = rewatching }, options); diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 3526d56c6..56a54fb1d 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -753,6 +753,8 @@ namespace Jellyfin.Api.Helpers job.HasExited = true; job.ExitCode = process.ExitCode; + ReportTranscodingProgress(job, state, null, null, null, null, null); + _logger.LogDebug("Disposing stream resources"); state.Dispose(); diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index bde10dbbf..cdfde97f6 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1281,11 +1281,6 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -low_power 1"; } - if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) - { - param += " -pix_fmt nv21"; - } - var isVc1 = string.Equals(state.VideoStream?.Codec, "vc1", StringComparison.OrdinalIgnoreCase); var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase); @@ -2695,6 +2690,7 @@ namespace MediaBrowser.Controller.MediaEncoding var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); @@ -2723,6 +2719,10 @@ namespace MediaBrowser.Controller.MediaEncoding { outFormat = "nv12"; } + else if (isV4l2Encoder) + { + outFormat = "yuv420p"; + } // sw scale mainFilters.Add(swScaleFilter); diff --git a/MediaBrowser.Model/Dlna/DlnaProfileType.cs b/MediaBrowser.Model/Dlna/DlnaProfileType.cs index e30ed0f3c..c1a663bf1 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.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs index fa8aa829d..7c65fda1a 100644 --- a/MediaBrowser.Model/Querying/NextUpQuery.cs +++ b/MediaBrowser.Model/Querying/NextUpQuery.cs @@ -14,6 +14,7 @@ namespace MediaBrowser.Model.Querying EnableTotalRecordCount = true; DisableFirstEpisode = false; NextUpDateCutoff = DateTime.MinValue; + Rewatching = false; } /// <summary> @@ -81,5 +82,10 @@ namespace MediaBrowser.Model.Querying /// Gets or sets a value indicating the oldest date for a show to appear in Next Up. /// </summary> public DateTime NextUpDateCutoff { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether getting rewatching next up list. + /// </summary> + public bool Rewatching { get; set; } } } diff --git a/MediaBrowser.Providers/MediaInfo/AudioResolver.cs b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs index 425913501..ff90eeffb 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs @@ -1,176 +1,28 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Emby.Naming.Audio; using Emby.Naming.Common; -using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Providers.MediaInfo { /// <summary> - /// Resolves external audios for videos. + /// Resolves external audio files for <see cref="Video"/>. /// </summary> - public class AudioResolver + public class AudioResolver : MediaInfoResolver { - private readonly ILocalizationManager _localizationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly NamingOptions _namingOptions; - /// <summary> - /// Initializes a new instance of the <see cref="AudioResolver"/> class. + /// Initializes a new instance of the <see cref="AudioResolver"/> class for external audio file processing. /// </summary> /// <param name="localizationManager">The localization manager.</param> /// <param name="mediaEncoder">The media encoder.</param> - /// <param name="namingOptions">The naming options.</param> + /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param> public AudioResolver( ILocalizationManager localizationManager, IMediaEncoder mediaEncoder, NamingOptions namingOptions) - { - _localizationManager = localizationManager; - _mediaEncoder = mediaEncoder; - _namingOptions = namingOptions; - } - - /// <summary> - /// Returns the audio streams found in the external audio files for the given video. - /// </summary> - /// <param name="video">The video to get the external audio streams from.</param> - /// <param name="startIndex">The stream index to start adding audio streams at.</param> - /// <param name="directoryService">The directory service to search for files.</param> - /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> - /// <param name="cancellationToken">The cancellation token to cancel operation.</param> - /// <returns>A list of external audio streams.</returns> - public async IAsyncEnumerable<MediaStream> GetExternalAudioStreams( - Video video, - int startIndex, - IDirectoryService directoryService, - bool clearCache, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (!video.IsFileProtocol) - { - yield break; - } - - IEnumerable<string> paths = GetExternalAudioFiles(video, directoryService, clearCache); - foreach (string path in paths) + : base(localizationManager, mediaEncoder, namingOptions, DlnaProfileType.Audio) { - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path); - Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(path, cancellationToken).ConfigureAwait(false); - - foreach (MediaStream mediaStream in mediaInfo.MediaStreams) - { - mediaStream.Index = startIndex++; - mediaStream.Type = MediaStreamType.Audio; - mediaStream.IsExternal = true; - mediaStream.Path = path; - mediaStream.IsDefault = false; - mediaStream.Title = null; - - if (string.IsNullOrEmpty(mediaStream.Language)) - { - // Try to translate to three character code - // Be flexible and check against both the full and three character versions - var language = StringExtensions.RightPart(fileNameWithoutExtension, '.').ToString(); - - if (language != fileNameWithoutExtension) - { - var culture = _localizationManager.FindLanguageInfo(language); - - language = culture == null ? language : culture.ThreeLetterISOLanguageName; - mediaStream.Language = language; - } - } - - yield return mediaStream; - } - } - } - - /// <summary> - /// Returns the external audio file paths for the given video. - /// </summary> - /// <param name="video">The video to get the external audio file paths from.</param> - /// <param name="directoryService">The directory service to search for files.</param> - /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> - /// <returns>A list of external audio file paths.</returns> - public IEnumerable<string> GetExternalAudioFiles( - Video video, - IDirectoryService directoryService, - bool clearCache) - { - if (!video.IsFileProtocol) - { - yield break; - } - - // Check if video folder exists - string folder = video.ContainingFolderPath; - if (!Directory.Exists(folder)) - { - yield break; - } - - string videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path); - - var files = directoryService.GetFilePaths(folder, clearCache, true); - for (int i = 0; i < files.Count; i++) - { - string file = files[i]; - if (string.Equals(video.Path, file, StringComparison.OrdinalIgnoreCase) - || !AudioFileParser.IsAudioFile(file, _namingOptions) - || Path.GetExtension(file.AsSpan()).Equals(".strm", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); - // The audio filename must either be equal to the video filename or start with the video filename followed by a dot - if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase) - || (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length - && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.' - && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))) - { - yield return file; - } - } - } - - /// <summary> - /// Returns the media info of the given audio file. - /// </summary> - /// <param name="path">The path to the audio file.</param> - /// <param name="cancellationToken">The cancellation token to cancel operation.</param> - /// <returns>The media info for the given audio file.</returns> - private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - return _mediaEncoder.GetMediaInfo( - new MediaInfoRequest - { - MediaType = DlnaProfileType.Audio, - MediaSource = new MediaSourceInfo - { - Path = path, - Protocol = MediaProtocol.File - } - }, - cancellationToken); } } } diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs index 19a435196..560e20dae 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs @@ -19,6 +19,7 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.MediaInfo; @@ -39,11 +40,10 @@ namespace MediaBrowser.Providers.MediaInfo IHasItemChangeMonitor { private readonly ILogger<FFProbeProvider> _logger; - private readonly SubtitleResolver _subtitleResolver; private readonly AudioResolver _audioResolver; + private readonly SubtitleResolver _subtitleResolver; private readonly FFProbeVideoInfo _videoProber; private readonly FFProbeAudioInfo _audioProber; - private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None); public FFProbeProvider( @@ -62,7 +62,7 @@ namespace MediaBrowser.Providers.MediaInfo { _logger = logger; _audioResolver = new AudioResolver(localization, mediaEncoder, namingOptions); - _subtitleResolver = new SubtitleResolver(BaseItem.LocalizationManager); + _subtitleResolver = new SubtitleResolver(localization, mediaEncoder, namingOptions); _videoProber = new FFProbeVideoInfo( _logger, mediaSourceManager, @@ -75,7 +75,8 @@ namespace MediaBrowser.Providers.MediaInfo subtitleManager, chapterManager, libraryManager, - _audioResolver); + _audioResolver, + _subtitleResolver); _audioProber = new FFProbeAudioInfo(mediaSourceManager, mediaEncoder, itemRepo, libraryManager); } @@ -104,7 +105,9 @@ namespace MediaBrowser.Providers.MediaInfo if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder && !video.SubtitleFiles.SequenceEqual( - _subtitleResolver.GetExternalSubtitleFiles(video, directoryService, false), StringComparer.Ordinal)) + _subtitleResolver.GetExternalFiles(video, directoryService, false) + .Select(info => info.Path).ToList(), + StringComparer.Ordinal)) { _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path); return true; @@ -112,7 +115,9 @@ namespace MediaBrowser.Providers.MediaInfo if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder && !video.AudioFiles.SequenceEqual( - _audioResolver.GetExternalAudioFiles(video, directoryService, false), StringComparer.Ordinal)) + _audioResolver.GetExternalFiles(video, directoryService, false) + .Select(info => info.Path).ToList(), + StringComparer.Ordinal)) { _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path); return true; diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 77a849d00..26ff0412b 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,9 +62,11 @@ namespace MediaBrowser.Providers.MediaInfo ISubtitleManager subtitleManager, IChapterManager chapterManager, ILibraryManager libraryManager, - AudioResolver audioResolver) + AudioResolver audioResolver, + SubtitleResolver subtitleResolver) { _logger = logger; + _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; _itemRepo = itemRepo; _blurayExaminer = blurayExaminer; @@ -74,7 +77,7 @@ namespace MediaBrowser.Providers.MediaInfo _chapterManager = chapterManager; _libraryManager = libraryManager; _audioResolver = audioResolver; - _mediaSourceManager = mediaSourceManager; + _subtitleResolver = subtitleResolver; } public async Task<ItemUpdateType> ProbeVideo<T>( @@ -215,7 +218,7 @@ namespace MediaBrowser.Providers.MediaInfo chapters = Array.Empty<ChapterInfo>(); } - await AddExternalSubtitles(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); + await AddExternalSubtitlesAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); await AddExternalAudioAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); @@ -526,16 +529,14 @@ namespace MediaBrowser.Providers.MediaInfo /// <param name="options">The refreshOptions.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - private async Task AddExternalSubtitles( + private async Task AddExternalSubtitlesAsync( Video video, List<MediaStream> currentStreams, MetadataRefreshOptions options, CancellationToken cancellationToken) { - var subtitleResolver = new SubtitleResolver(_localization); - var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1); - var externalSubtitleStreams = subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, false); + var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken); var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh; @@ -589,7 +590,7 @@ namespace MediaBrowser.Providers.MediaInfo // Rescan if (downloadedLanguages.Count > 0) { - externalSubtitleStreams = subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, true); + externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken); } } @@ -612,12 +613,9 @@ namespace MediaBrowser.Providers.MediaInfo CancellationToken cancellationToken) { var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1; - var externalAudioStreams = _audioResolver.GetExternalAudioStreams(video, startIndex, options.DirectoryService, false, cancellationToken); + var externalAudioStreams = await _audioResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken).ConfigureAwait(false); - await foreach (MediaStream externalAudioStream in externalAudioStreams) - { - currentStreams.Add(externalAudioStream); - } + currentStreams = currentStreams.Concat(externalAudioStreams).ToList(); // Select all external audio file paths video.AudioFiles = currentStreams.Where(i => i.Type == MediaStreamType.Audio && i.IsExternal).Select(i => i.Path).Distinct().ToArray(); diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs new file mode 100644 index 000000000..40b45faf5 --- /dev/null +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Common; +using Emby.Naming.ExternalFiles; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.MediaInfo; + +namespace MediaBrowser.Providers.MediaInfo +{ + /// <summary> + /// Resolves external files for <see cref="Video"/>. + /// </summary> + public abstract class MediaInfoResolver + { + /// <summary> + /// The <see cref="CompareOptions"/> instance. + /// </summary> + private const CompareOptions CompareOptions = System.Globalization.CompareOptions.IgnoreCase | System.Globalization.CompareOptions.IgnoreNonSpace | System.Globalization.CompareOptions.IgnoreSymbols; + + /// <summary> + /// The <see cref="CompareInfo"/> instance. + /// </summary> + private readonly CompareInfo _compareInfo = CultureInfo.InvariantCulture.CompareInfo; + + /// <summary> + /// The <see cref="ExternalPathParser"/> instance. + /// </summary> + private readonly ExternalPathParser _externalPathParser; + + /// <summary> + /// The <see cref="IMediaEncoder"/> instance. + /// </summary> + private readonly IMediaEncoder _mediaEncoder; + + /// <summary> + /// The <see cref="NamingOptions"/> instance. + /// </summary> + private readonly NamingOptions _namingOptions; + + /// <summary> + /// The <see cref="DlnaProfileType"/> of the files this resolver should resolve. + /// </summary> + private readonly DlnaProfileType _type; + + /// <summary> + /// Initializes a new instance of the <see cref="MediaInfoResolver"/> class. + /// </summary> + /// <param name="localizationManager">The localization manager.</param> + /// <param name="mediaEncoder">The media encoder.</param> + /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param> + /// <param name="type">The <see cref="DlnaProfileType"/> of the parsed file.</param> + protected MediaInfoResolver( + ILocalizationManager localizationManager, + IMediaEncoder mediaEncoder, + NamingOptions namingOptions, + DlnaProfileType type) + { + _mediaEncoder = mediaEncoder; + _namingOptions = namingOptions; + _type = type; + _externalPathParser = new ExternalPathParser(namingOptions, localizationManager, _type); + } + + /// <summary> + /// Retrieves the external streams for the provided video. + /// </summary> + /// <param name="video">The <see cref="Video"/> object to search external streams for.</param> + /// <param name="startIndex">The stream index to start adding external streams at.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The external streams located.</returns> + public async Task<IReadOnlyList<MediaStream>> GetExternalStreamsAsync( + Video video, + int startIndex, + IDirectoryService directoryService, + bool clearCache, + CancellationToken cancellationToken) + { + if (!video.IsFileProtocol) + { + return Array.Empty<MediaStream>(); + } + + var pathInfos = GetExternalFiles(video, directoryService, clearCache); + + if (!pathInfos.Any()) + { + return Array.Empty<MediaStream>(); + } + + var mediaStreams = new List<MediaStream>(); + + foreach (var pathInfo in pathInfos) + { + var mediaInfo = await GetMediaInfo(pathInfo.Path, _type, cancellationToken).ConfigureAwait(false); + + if (mediaInfo.MediaStreams.Count == 1) + { + MediaStream mediaStream = mediaInfo.MediaStreams[0]; + mediaStream.Index = startIndex++; + mediaStream.IsDefault = pathInfo.IsDefault || mediaStream.IsDefault; + mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced; + + mediaStreams.Add(MergeMetadata(mediaStream, pathInfo)); + } + else + { + foreach (MediaStream mediaStream in mediaInfo.MediaStreams) + { + mediaStream.Index = startIndex++; + + mediaStreams.Add(MergeMetadata(mediaStream, pathInfo)); + } + } + } + + return mediaStreams.AsReadOnly(); + } + + /// <summary> + /// Returns the external file infos for the given video. + /// </summary> + /// <param name="video">The <see cref="Video"/> object to search external files for.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <returns>The external file paths located.</returns> + public IReadOnlyList<ExternalPathParserResult> GetExternalFiles( + Video video, + IDirectoryService directoryService, + bool clearCache) + { + if (!video.IsFileProtocol) + { + return Array.Empty<ExternalPathParserResult>(); + } + + // Check if video folder exists + string folder = video.ContainingFolderPath; + if (!Directory.Exists(folder)) + { + return Array.Empty<ExternalPathParserResult>(); + } + + var externalPathInfos = new List<ExternalPathParserResult>(); + + var files = directoryService.GetFilePaths(folder, clearCache).ToList(); + files.AddRange(directoryService.GetFilePaths(video.GetInternalMetadataPath(), clearCache)); + + if (!files.Any()) + { + return Array.Empty<ExternalPathParserResult>(); + } + + foreach (var file in files) + { + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); + if (_compareInfo.IsPrefix(fileNameWithoutExtension, video.FileNameWithoutExtension, CompareOptions, out int matchLength) + && (fileNameWithoutExtension.Length == matchLength || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[matchLength].ToString()))) + { + var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[matchLength..]); + + if (externalPathInfo != null) + { + externalPathInfos.Add(externalPathInfo); + } + } + } + + return externalPathInfos; + } + + /// <summary> + /// Returns the media info of the given file. + /// </summary> + /// <param name="path">The path to the file.</param> + /// <param name="type">The <see cref="DlnaProfileType"/>.</param> + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + /// <returns>The media info for the given file.</returns> + private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, DlnaProfileType type, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return _mediaEncoder.GetMediaInfo( + new MediaInfoRequest + { + MediaType = type, + MediaSource = new MediaSourceInfo + { + Path = path, + Protocol = MediaProtocol.File + } + }, + cancellationToken); + } + + /// <summary> + /// Merges path metadata into stream metadata. + /// </summary> + /// <param name="mediaStream">The <see cref="MediaStream"/> object.</param> + /// <param name="pathInfo">The <see cref="ExternalPathParserResult"/> object.</param> + /// <returns>The modified mediaStream.</returns> + private MediaStream MergeMetadata(MediaStream mediaStream, ExternalPathParserResult pathInfo) + { + mediaStream.Path = pathInfo.Path; + mediaStream.IsExternal = true; + mediaStream.Title = string.IsNullOrEmpty(mediaStream.Title) ? (string.IsNullOrEmpty(pathInfo.Title) ? null : pathInfo.Title) : mediaStream.Title; + mediaStream.Language = string.IsNullOrEmpty(mediaStream.Language) ? (string.IsNullOrEmpty(pathInfo.Language) ? null : pathInfo.Language) : mediaStream.Language; + + mediaStream.Type = _type switch + { + DlnaProfileType.Audio => MediaStreamType.Audio, + DlnaProfileType.Subtitle => MediaStreamType.Subtitle, + _ => mediaStream.Type + }; + + return mediaStream; + } + } +} diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs index ba284187e..289036fda 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs @@ -1,235 +1,28 @@ -using System; -using System.Collections.Generic; -using System.IO; +using Emby.Naming.Common; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; namespace MediaBrowser.Providers.MediaInfo { /// <summary> - /// Resolves external subtitles for videos. + /// Resolves external subtitle files for <see cref="Video"/>. /// </summary> - public class SubtitleResolver + public class SubtitleResolver : MediaInfoResolver { - private readonly ILocalizationManager _localization; - - /// <summary> - /// Initializes a new instance of the <see cref="SubtitleResolver"/> class. - /// </summary> - /// <param name="localization">The localization manager.</param> - public SubtitleResolver(ILocalizationManager localization) - { - _localization = localization; - } - - /// <summary> - /// Retrieves the external subtitle streams for the provided video. - /// </summary> - /// <param name="video">The video to search from.</param> - /// <param name="startIndex">The stream index to start adding subtitle streams at.</param> - /// <param name="directoryService">The directory service to search for files.</param> - /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> - /// <returns>The external subtitle streams located.</returns> - public List<MediaStream> GetExternalSubtitleStreams( - Video video, - int startIndex, - IDirectoryService directoryService, - bool clearCache) - { - var streams = new List<MediaStream>(); - - if (!video.IsFileProtocol) - { - return streams; - } - - AddExternalSubtitleStreams(streams, video.ContainingFolderPath, video.Path, startIndex, directoryService, clearCache); - - startIndex += streams.Count; - - string folder = video.GetInternalMetadataPath(); - - if (!Directory.Exists(folder)) - { - return streams; - } - - try - { - AddExternalSubtitleStreams(streams, folder, video.Path, startIndex, directoryService, clearCache); - } - catch (IOException) - { - } - - return streams; - } - - /// <summary> - /// Locates the external subtitle files for the provided video. - /// </summary> - /// <param name="video">The video to search from.</param> - /// <param name="directoryService">The directory service to search for files.</param> - /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> - /// <returns>The external subtitle file paths located.</returns> - public IEnumerable<string> GetExternalSubtitleFiles( - Video video, - IDirectoryService directoryService, - bool clearCache) - { - if (!video.IsFileProtocol) - { - yield break; - } - - var streams = GetExternalSubtitleStreams(video, 0, directoryService, clearCache); - - foreach (var stream in streams) - { - yield return stream.Path; - } - } - /// <summary> - /// Extracts the subtitle files from the provided list and adds them to the list of streams. + /// Initializes a new instance of the <see cref="SubtitleResolver"/> class for external subtitle file processing. /// </summary> - /// <param name="streams">The list of streams to add external subtitles to.</param> - /// <param name="videoPath">The path to the video file.</param> - /// <param name="startIndex">The stream index to start adding subtitle streams at.</param> - /// <param name="files">The files to add if they are subtitles.</param> - public void AddExternalSubtitleStreams( - List<MediaStream> streams, - string videoPath, - int startIndex, - IReadOnlyList<string> files) - { - var videoFileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(videoPath); - - for (var i = 0; i < files.Count; i++) + /// <param name="localizationManager">The localization manager.</param> + /// <param name="mediaEncoder">The media encoder.</param> + /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param> + public SubtitleResolver( + ILocalizationManager localizationManager, + IMediaEncoder mediaEncoder, + NamingOptions namingOptions) + : base(localizationManager, mediaEncoder, namingOptions, DlnaProfileType.Subtitle) { - var fullName = files[i]; - var extension = Path.GetExtension(fullName.AsSpan()); - if (!IsSubtitleExtension(extension)) - { - continue; - } - - var fileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(fullName); - - MediaStream mediaStream; - - // The subtitle filename must either be equal to the video filename or start with the video filename followed by a dot - if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) - { - mediaStream = new MediaStream - { - Index = startIndex++, - Type = MediaStreamType.Subtitle, - IsExternal = true, - Path = fullName - }; - } - else if (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length - && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.' - && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) - { - var isForced = fullName.Contains(".forced.", StringComparison.OrdinalIgnoreCase) - || fullName.Contains(".foreign.", StringComparison.OrdinalIgnoreCase); - - var isDefault = fullName.Contains(".default.", StringComparison.OrdinalIgnoreCase); - - // Support xbmc naming conventions - 300.spanish.srt - var languageSpan = fileNameWithoutExtension; - while (languageSpan.Length > 0) - { - var lastDot = languageSpan.LastIndexOf('.'); - if (lastDot < videoFileNameWithoutExtension.Length) - { - languageSpan = ReadOnlySpan<char>.Empty; - break; - } - - var currentSlice = languageSpan[lastDot..]; - if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase) - || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase) - || currentSlice.Equals(".foreign", StringComparison.OrdinalIgnoreCase)) - { - languageSpan = languageSpan[..lastDot]; - continue; - } - - languageSpan = languageSpan[(lastDot + 1)..]; - break; - } - - var language = languageSpan.ToString(); - if (string.IsNullOrWhiteSpace(language)) - { - language = null; - } - else - { - // Try to translate to three character code - // Be flexible and check against both the full and three character versions - var culture = _localization.FindLanguageInfo(language); - - language = culture == null ? language : culture.ThreeLetterISOLanguageName; - } - - mediaStream = new MediaStream - { - Index = startIndex++, - Type = MediaStreamType.Subtitle, - IsExternal = true, - Path = fullName, - Language = language, - IsForced = isForced, - IsDefault = isDefault - }; - } - else - { - continue; - } - - mediaStream.Codec = extension.TrimStart('.').ToString().ToLowerInvariant(); - - streams.Add(mediaStream); - } - } - - private static bool IsSubtitleExtension(ReadOnlySpan<char> extension) - { - return extension.Equals(".srt", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".ssa", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".ass", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".sub", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".vtt", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".smi", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".sami", StringComparison.OrdinalIgnoreCase); - } - - private static ReadOnlySpan<char> NormalizeFilenameForSubtitleComparison(string filename) - { - // Try to account for sloppy file naming - filename = filename.Replace("_", string.Empty, StringComparison.Ordinal); - filename = filename.Replace(" ", string.Empty, StringComparison.Ordinal); - return Path.GetFileNameWithoutExtension(filename.AsSpan()); - } - - private void AddExternalSubtitleStreams( - List<MediaStream> streams, - string folder, - string videoPath, - int startIndex, - IDirectoryService directoryService, - bool clearCache) - { - var files = directoryService.GetFilePaths(folder, clearCache, true); - - AddExternalSubtitleStreams(streams, videoPath, startIndex, files); } } } diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 38fb37f17..e04bfffbb 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -17,7 +17,7 @@ <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.2" /> <PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="3.1.2" /> diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index 7b20823ba..1ad0f4e00 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -12,7 +12,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="3.1.2" /> diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 374a17e2f..f7f9c0361 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -12,7 +12,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> diff --git a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj index d178837b2..a9935bbdb 100644 --- a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj +++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj @@ -7,7 +7,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> diff --git a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj index 725947577..55125eb11 100644 --- a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj +++ b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj @@ -7,7 +7,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> diff --git a/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj b/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj index 7ab34775a..c53dab6d8 100644 --- a/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj @@ -7,7 +7,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> diff --git a/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj b/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj index 639c84240..268631e58 100644 --- a/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj @@ -8,7 +8,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index e7534e308..7a1d88ca6 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -22,7 +22,7 @@ <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> <PackageReference Include="coverlet.collector" Version="3.1.2" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> diff --git a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj index 22db5bea2..9da80c312 100644 --- a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj +++ b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj @@ -7,7 +7,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="3.1.2" /> diff --git a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs new file mode 100644 index 000000000..b396b5440 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs @@ -0,0 +1,111 @@ +using System.Text.RegularExpressions; +using Emby.Naming.Common; +using Emby.Naming.ExternalFiles; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Globalization; +using Moq; +using Xunit; + +namespace Jellyfin.Naming.Tests.ExternalFiles; + +public class ExternalPathParserTests +{ + private readonly ExternalPathParser _audioPathParser; + private readonly ExternalPathParser _subtitlePathParser; + + public ExternalPathParserTests() + { + var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" }); + var frenchCultureDto = new CultureDto("French", "French", "fr", new[] { "fre", "fra" }); + + var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose); + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase))) + .Returns(englishCultureDto); + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"fr.*", RegexOptions.IgnoreCase))) + .Returns(frenchCultureDto); + + _audioPathParser = new ExternalPathParser(new NamingOptions(), localizationManager.Object, DlnaProfileType.Audio); + _subtitlePathParser = new ExternalPathParser(new NamingOptions(), localizationManager.Object, DlnaProfileType.Subtitle); + } + + [Theory] + [InlineData("")] + [InlineData("MyVideo.ass")] + [InlineData("MyVideo.mks")] + [InlineData("MyVideo.sami")] + [InlineData("MyVideo.srt")] + [InlineData("MyVideo.m4v")] + public void ParseFile_AudioExtensionsNotMatched_ReturnsNull(string path) + { + Assert.Null(_audioPathParser.ParseFile(path, string.Empty)); + } + + [Theory] + [InlineData("MyVideo.aa")] + [InlineData("MyVideo.aac")] + [InlineData("MyVideo.flac")] + [InlineData("MyVideo.m4a")] + [InlineData("MyVideo.mka")] + [InlineData("MyVideo.mp3")] + public void ParseFile_AudioExtensionsMatched_ReturnsPath(string path) + { + var actual = _audioPathParser.ParseFile(path, string.Empty); + Assert.NotNull(actual); + Assert.Equal(path, actual!.Path); + } + + [Theory] + [InlineData("")] + [InlineData("MyVideo.aa")] + [InlineData("MyVideo.aac")] + [InlineData("MyVideo.flac")] + [InlineData("MyVideo.mka")] + [InlineData("MyVideo.m4v")] + public void ParseFile_SubtitleExtensionsNotMatched_ReturnsNull(string path) + { + Assert.Null(_subtitlePathParser.ParseFile(path, string.Empty)); + } + + [Theory] + [InlineData("MyVideo.ass")] + [InlineData("MyVideo.mks")] + [InlineData("MyVideo.sami")] + [InlineData("MyVideo.srt")] + [InlineData("MyVideo.vtt")] + public void ParseFile_SubtitleExtensionsMatched_ReturnsPath(string path) + { + var actual = _subtitlePathParser.ParseFile(path, string.Empty); + Assert.NotNull(actual); + Assert.Equal(path, actual!.Path); + } + + [Theory] + [InlineData("", null, null)] + [InlineData(".default", null, null, true, false)] + [InlineData(".forced", null, null, false, true)] + [InlineData(".foreign", null, null, false, true)] + [InlineData(".default.forced", null, null, true, true)] + [InlineData(".forced.default", null, null, true, true)] + [InlineData(".DEFAULT.FORCED", null, null, true, true)] + [InlineData(".en", null, "eng")] + [InlineData(".EN", null, "eng")] + [InlineData(".fr.en", "fr", "eng")] + [InlineData(".en.fr", "en", "fre")] + [InlineData(".title.en.fr", "title.en", "fre")] + [InlineData(".Title Goes Here", "Title Goes Here", null)] + [InlineData(".Title.with.Separator", "Title.with.Separator", null)] + [InlineData(".title.en.default.forced", "title", "eng", true, true)] + [InlineData(".forced.default.en.title", "title", "eng", true, true)] + public void ParseFile_ExtraTokens_ParseToValues(string tokens, string? title, string? language, bool isDefault = false, bool isForced = false) + { + var path = "My.Video" + tokens + ".srt"; + + var actual = _subtitlePathParser.ParseFile(path, tokens); + + Assert.NotNull(actual); + Assert.Equal(title, actual!.Title); + Assert.Equal(language, actual.Language); + Assert.Equal(isDefault, actual.IsDefault); + Assert.Equal(isForced, actual.IsForced); + } +} diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index 59b7e1cbe..cc3d4faa0 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -12,7 +12,8 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> + <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="3.1.2" /> diff --git a/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs b/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs deleted file mode 100644 index 2446660f3..000000000 --- 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.Networking.Tests/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj index 3d3288df6..00aac8965 100644 --- a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj +++ b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj @@ -12,7 +12,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="3.1.2" /> diff --git a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj index 9f571273f..5531049f5 100644 --- a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -13,7 +13,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs new file mode 100644 index 000000000..381d6c72d --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Common; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Providers.MediaInfo; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo; + +public class AudioResolverTests +{ + private readonly AudioResolver _audioResolver; + + public AudioResolverTests() + { + // prep BaseItem and Video for calls made that expect managers + Video.LiveTvManager = Mock.Of<ILiveTvManager>(); + + var applicationPaths = new Mock<IServerApplicationPaths>().Object; + var serverConfig = new Mock<IServerConfigurationManager>(); + serverConfig.Setup(c => c.ApplicationPaths) + .Returns(applicationPaths); + BaseItem.ConfigurationManager = serverConfig.Object; + + // build resolver to test with + var localizationManager = Mock.Of<ILocalizationManager>(); + + var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict); + mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>())) + .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo + { + MediaStreams = new List<MediaStream> + { + new() + } + })); + + _audioResolver = new AudioResolver(localizationManager, mediaEncoder.Object, new NamingOptions()); + } + + [Theory] + [InlineData("My.Video.srt", false, false)] + [InlineData("My.Video.mp3", false, true)] + [InlineData("My.Video.srt", true, false)] + [InlineData("My.Video.mp3", true, true)] + public async void GetExternalStreams_MixedFilenames_PicksAudio(string file, bool metadataDirectory, bool matches) + { + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = MediaInfoResolverTests.VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = MediaInfoResolverTests.GetDirectoryServiceForExternalFile(file, metadataDirectory); + var streams = await _audioResolver.GetExternalStreamsAsync(video, 0, directoryService, false, CancellationToken.None); + + if (matches) + { + Assert.Single(streams); + var actual = streams[0]; + Assert.Equal(MediaStreamType.Audio, actual.Type); + } + else + { + Assert.Empty(streams); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs new file mode 100644 index 000000000..926ec5c91 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Common; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Providers.MediaInfo; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo; + +public class MediaInfoResolverTests +{ + public const string VideoDirectoryPath = "Test Data/Video"; + private const string VideoDirectoryRegex = @"Test Data[/\\]Video"; + private const string MetadataDirectoryPath = "library/00/00000000000000000000000000000000"; + private const string MetadataDirectoryRegex = @"library.*"; + + private readonly ILocalizationManager _localizationManager; + private readonly MediaInfoResolver _subtitleResolver; + + public MediaInfoResolverTests() + { + // prep BaseItem and Video for calls made that expect managers + Video.LiveTvManager = Mock.Of<ILiveTvManager>(); + + var applicationPaths = new Mock<IServerApplicationPaths>().Object; + var serverConfig = new Mock<IServerConfigurationManager>(); + serverConfig.Setup(c => c.ApplicationPaths) + .Returns(applicationPaths); + BaseItem.ConfigurationManager = serverConfig.Object; + + // build resolver to test with + var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" }); + + var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose); + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase))) + .Returns(englishCultureDto); + _localizationManager = localizationManager.Object; + + var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict); + mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>())) + .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo + { + MediaStreams = new List<MediaStream> + { + new() + } + })); + + _subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.Object, new NamingOptions()); + } + + [Theory] + [InlineData("https://url.com/My.Video.mkv")] + [InlineData("non-existent/path")] + public void GetExternalFiles_BadPaths_ReturnsNoSubtitles(string path) + { + // need a media source manager capable of returning something other than file protocol + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*"))) + .Returns(MediaProtocol.Http); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + + var video = new Movie + { + Path = path + }; + + var files = _subtitleResolver.GetExternalFiles(video, Mock.Of<IDirectoryService>(), false); + + Assert.Empty(files); + } + + [Theory] + [InlineData("My.Video.srt", null)] // exact + [InlineData("My.Video.en.srt", "eng")] + [InlineData("MyVideo.en.srt", "eng")] // shorter title + [InlineData("My _ Video.en.srt", "eng")] // longer title + [InlineData("My.Video.en.srt", "eng", true)] + public void GetExternalFiles_FuzzyMatching_MatchesAndParsesToken(string file, string? language, bool metadataDirectory = false) + { + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = GetDirectoryServiceForExternalFile(file, metadataDirectory); + var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList(); + + Assert.Single(streams); + var actual = streams[0]; + Assert.Equal(language, actual.Language); + Assert.Null(actual.Title); + } + + [Theory] + [InlineData("My.Video.mp3")] + [InlineData("My.Video.png")] + [InlineData("My.Video.txt")] + [InlineData("My.Video Sequel.srt")] + [InlineData("Some.Other.Video.srt")] + public void GetExternalFiles_FuzzyMatching_RejectsNonMatches(string file) + { + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = GetDirectoryServiceForExternalFile(file); + var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList(); + + Assert.Empty(streams); + } + + [Theory] + [InlineData("https://url.com/My.Video.mkv")] + [InlineData("non-existent/path")] + [InlineData(VideoDirectoryPath)] // valid but no files found for this test + public async void GetExternalStreams_BadPaths_ReturnsNoSubtitles(string path) + { + // need a media source manager capable of returning something other than file protocol + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*"))) + .Returns(MediaProtocol.Http); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + + var video = new Movie + { + Path = path + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + + var mediaEncoder = Mock.Of<IMediaEncoder>(MockBehavior.Strict); + + var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder, new NamingOptions()); + + var streams = await subtitleResolver.GetExternalStreamsAsync(video, 0, directoryService.Object, false, CancellationToken.None); + + Assert.Empty(streams); + } + + private static TheoryData<string, MediaStream[], MediaStream[]> GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly_Data() + { + var data = new TheoryData<string, MediaStream[], MediaStream[]>(); + + // filename and stream have no metadata set + string file = "My.Video.srt"; + data.Add( + file, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) + }, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) + }); + + // filename has metadata + file = "My.Video.Title1.default.forced.en.srt"; + data.Add( + file, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) + }, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title1", 0, true, true) + }); + + // single stream with metadata + file = "My.Video.mks"; + data.Add( + file, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true) + }, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true) + }); + + // stream wins for title/language, filename wins for flags when conflicting + file = "My.Video.Title2.default.forced.en.srt"; + data.Add( + file, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0) + }, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0, true, true) + }); + + // multiple stream with metadata - filename flags ignored but other data filled in when missing from stream + file = "My.Video.Title3.default.forced.en.srt"; + data.Add( + file, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0, true, true), + CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 1) + }, + new[] + { + CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title3", 0, true, true), + CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 1) + }); + + return data; + } + + [Theory] + [MemberData(nameof(GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly_Data))] + public async void GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly(string file, MediaStream[] inputStreams, MediaStream[] expectedStreams) + { + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict); + mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>())) + .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo + { + MediaStreams = inputStreams.ToList() + })); + + var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.Object, new NamingOptions()); + + var directoryService = GetDirectoryServiceForExternalFile(file); + var streams = await subtitleResolver.GetExternalStreamsAsync(video, 0, directoryService, false, CancellationToken.None); + + Assert.Equal(expectedStreams.Length, streams.Count); + for (var i = 0; i < expectedStreams.Length; i++) + { + var expected = expectedStreams[i]; + var actual = streams[i]; + + Assert.True(actual.IsExternal); + Assert.Equal(expected.Index, actual.Index); + Assert.Equal(expected.Type, actual.Type); + Assert.Equal(expected.Path, actual.Path); + Assert.Equal(expected.IsDefault, actual.IsDefault); + Assert.Equal(expected.IsForced, actual.IsForced); + Assert.Equal(expected.Language, actual.Language); + Assert.Equal(expected.Title, actual.Title); + } + } + + [Theory] + [InlineData(1, 1)] + [InlineData(1, 2)] + [InlineData(2, 1)] + [InlineData(2, 2)] + public async void GetExternalStreams_StreamIndex_HandlesFilesAndContainers(int fileCount, int streamCount) + { + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var files = new string[fileCount]; + for (int i = 0; i < fileCount; i++) + { + files[i] = $"{VideoDirectoryPath}/MyVideo.{i}.srt"; + } + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + .Returns(files); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + + List<MediaStream> GenerateMediaStreams() + { + var mediaStreams = new List<MediaStream>(); + for (int i = 0; i < streamCount; i++) + { + mediaStreams.Add(new()); + } + + return mediaStreams; + } + + var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict); + mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>())) + .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo + { + MediaStreams = GenerateMediaStreams() + })); + + var subtitleResolver = new SubtitleResolver(_localizationManager, mediaEncoder.Object, new NamingOptions()); + + int startIndex = 1; + var streams = await subtitleResolver.GetExternalStreamsAsync(video, startIndex, directoryService.Object, false, CancellationToken.None); + + Assert.Equal(fileCount * streamCount, streams.Count); + for (var i = 0; i < streams.Count; i++) + { + Assert.Equal(startIndex + i, streams[i].Index); + // intentional integer division to ensure correct number of streams come back from each file + Assert.Matches(@$".*\.{i / streamCount}\.srt", streams[i].Path); + } + } + + private static MediaStream CreateMediaStream(string path, string? language, string? title, int index, bool isForced = false, bool isDefault = false) + { + return new MediaStream + { + Index = index, + Type = MediaStreamType.Subtitle, + Path = path, + IsDefault = isDefault, + IsForced = isForced, + Language = language, + Title = title + }; + } + + /// <summary> + /// Provides an <see cref="IDirectoryService"/> that when queried for the test video/metadata directory will return a path including the provided file name. + /// </summary> + /// <param name="file">The name of the file to locate.</param> + /// <param name="useMetadataDirectory"><c>true</c> if the file belongs in the metadata directory.</param> + /// <returns>A mocked <see cref="IDirectoryService"/>.</returns> + public static IDirectoryService GetDirectoryServiceForExternalFile(string file, bool useMetadataDirectory = false) + { + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + if (useMetadataDirectory) + { + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + .Returns(new[] { MetadataDirectoryPath + "/" + file }); + } + else + { + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/" + file }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + } + + return directoryService.Object; + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs index 040ea5d1d..0f1086f59 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs @@ -1,129 +1,79 @@ -#pragma warning disable CA1002 // Do not expose generic lists - using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Common; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Providers.MediaInfo; using Moq; using Xunit; -namespace Jellyfin.Providers.Tests.MediaInfo +namespace Jellyfin.Providers.Tests.MediaInfo; + +public class SubtitleResolverTests { - 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[]>(); + private readonly SubtitleResolver _subtitleResolver; - 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), - }); + public SubtitleResolverTests() + { + // prep BaseItem and Video for calls made that expect managers + Video.LiveTvManager = Mock.Of<ILiveTvManager>(); - return data; - } + var applicationPaths = new Mock<IServerApplicationPaths>().Object; + var serverConfig = new Mock<IServerConfigurationManager>(); + serverConfig.Setup(c => c.ApplicationPaths) + .Returns(applicationPaths); + BaseItem.ConfigurationManager = serverConfig.Object; - [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); + // build resolver to test with + var localizationManager = Mock.Of<ILocalizationManager>(); - Assert.Equal(expectedResult.Length, streams.Count); - for (var i = 0; i < expectedResult.Length; i++) + var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict); + mediaEncoder.Setup(me => me.GetMediaInfo(It.IsAny<MediaInfoRequest>(), It.IsAny<CancellationToken>())) + .Returns<MediaInfoRequest, CancellationToken>((_, _) => Task.FromResult(new MediaBrowser.Model.MediaInfo.MediaInfo { - var expected = expectedResult[i]; - var actual = streams[i]; + MediaStreams = new List<MediaStream> + { + new() + } + })); - 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); - } - } + _subtitleResolver = new SubtitleResolver(localizationManager, mediaEncoder.Object, new NamingOptions()); + } - [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) + [Theory] + [InlineData("My.Video.srt", false, true)] + [InlineData("My.Video.mp3", false, false)] + [InlineData("My.Video.srt", true, true)] + [InlineData("My.Video.mp3", true, false)] + public async void GetExternalStreams_MixedFilenames_PicksSubtitles(string file, bool metadataDirectory, bool matches) + { + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie { - var streams = new List<MediaStream>(); - var expected = CreateMediaStream(file, codec, language, 0, isForced, isDefault); + Path = MediaInfoResolverTests.VideoDirectoryPath + "/My.Video.mkv" + }; - new SubtitleResolver(Mock.Of<ILocalizationManager>()).AddExternalSubtitleStreams(streams, videoPath, 0, new[] { file }); + var directoryService = MediaInfoResolverTests.GetDirectoryServiceForExternalFile(file, metadataDirectory); + var streams = await _subtitleResolver.GetExternalStreamsAsync(video, 0, directoryService, false, CancellationToken.None); + if (matches) + { Assert.Single(streams); - var actual = streams[0]; - - Assert.Equal(expected.Index, actual.Index); - Assert.Equal(expected.Type, actual.Type); - Assert.Equal(expected.IsExternal, actual.IsExternal); - Assert.Equal(expected.Path, actual.Path); - Assert.Equal(expected.IsDefault, actual.IsDefault); - Assert.Equal(expected.IsForced, actual.IsForced); - Assert.Equal(expected.Language, actual.Language); + Assert.Equal(MediaStreamType.Subtitle, actual.Type); } - - private static MediaStream CreateMediaStream(string path, string codec, string? language, int index, bool isForced = false, bool isDefault = false) + else { - return new() - { - Index = index, - Codec = codec, - Type = MediaStreamType.Subtitle, - IsExternal = true, - Path = path, - IsDefault = isDefault, - IsForced = isForced, - Language = language - }; + Assert.Empty(streams); } } } diff --git a/tests/Jellyfin.Providers.Tests/Test Data/Video/My.Video.mkv b/tests/Jellyfin.Providers.Tests/Test Data/Video/My.Video.mkv new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Test Data/Video/My.Video.mkv diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index 3146f277f..066112dcb 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -21,7 +21,7 @@ <ItemGroup> <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> diff --git a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj index 08eea4b15..43e38ea6e 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj +++ b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj @@ -11,7 +11,7 @@ <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.2" /> <PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="Xunit.Priority" Version="1.1.6" /> diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj index 2ab32d6f6..9576f6a11 100644 --- a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj +++ b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj @@ -12,7 +12,7 @@ <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.2" /> <PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="3.1.2" /> diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj index 1bb2115cc..f34dbc922 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj +++ b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj @@ -13,7 +13,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> |
