diff options
Diffstat (limited to 'Emby.Naming')
42 files changed, 1443 insertions, 877 deletions
diff --git a/Emby.Naming/Audio/AlbumParser.cs b/Emby.Naming/Audio/AlbumParser.cs index b63be3a647..bbfdccc902 100644 --- a/Emby.Naming/Audio/AlbumParser.cs +++ b/Emby.Naming/Audio/AlbumParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; @@ -9,15 +6,27 @@ using Emby.Naming.Common; namespace Emby.Naming.Audio { + /// <summary> + /// Helper class to determine if Album is multipart. + /// </summary> public class AlbumParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="AlbumParser"/> class. + /// </summary> + /// <param name="options">Naming options containing AlbumStackingPrefixes.</param> public AlbumParser(NamingOptions options) { _options = options; } + /// <summary> + /// Function that determines if album is multipart. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>True if album is multipart.</returns> public bool IsMultiPart(string path) { var filename = Path.GetFileName(path); diff --git a/Emby.Naming/Audio/AudioFileParser.cs b/Emby.Naming/Audio/AudioFileParser.cs index 6b2f4be93e..2b610ec796 100644 --- a/Emby.Naming/Audio/AudioFileParser.cs +++ b/Emby.Naming/Audio/AudioFileParser.cs @@ -1,19 +1,25 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.IO; -using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Audio { + /// <summary> + /// Static helper class to determine if file at path is audio file. + /// </summary> public static class AudioFileParser { + /// <summary> + /// Static helper method to determine if file at path is audio file. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="options"><see cref="NamingOptions"/> containing AudioFileExtensions.</param> + /// <returns>True if file at path is audio file.</returns> public static bool IsAudioFile(string path, NamingOptions options) { - var extension = Path.GetExtension(path); - return options.AudioFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(path.AsSpan()); + return options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Naming/AudioBook/AudioBookFileInfo.cs b/Emby.Naming/AudioBook/AudioBookFileInfo.cs index c4863b50ab..862e396677 100644 --- a/Emby.Naming/AudioBook/AudioBookFileInfo.cs +++ b/Emby.Naming/AudioBook/AudioBookFileInfo.cs @@ -8,6 +8,21 @@ namespace Emby.Naming.AudioBook public class AudioBookFileInfo : IComparable<AudioBookFileInfo> { /// <summary> + /// Initializes a new instance of the <see cref="AudioBookFileInfo"/> class. + /// </summary> + /// <param name="path">Path to audiobook file.</param> + /// <param name="container">File type.</param> + /// <param name="partNumber">Number of part this file represents.</param> + /// <param name="chapterNumber">Number of chapter this file represents.</param> + public AudioBookFileInfo(string path, string container, int? partNumber = default, int? chapterNumber = default) + { + Path = path; + Container = container; + PartNumber = partNumber; + ChapterNumber = chapterNumber; + } + + /// <summary> /// Gets or sets the path. /// </summary> /// <value>The path.</value> @@ -31,14 +46,8 @@ namespace Emby.Naming.AudioBook /// <value>The chapter number.</value> public int? ChapterNumber { get; set; } - /// <summary> - /// Gets or sets a value indicating whether this instance is a directory. - /// </summary> - /// <value>The type.</value> - public bool IsDirectory { get; set; } - /// <inheritdoc /> - public int CompareTo(AudioBookFileInfo other) + public int CompareTo(AudioBookFileInfo? other) { if (ReferenceEquals(this, other)) { diff --git a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs index 3c874c62ca..7b4429ab15 100644 --- a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs +++ b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 - -using System; using System.Globalization; using System.IO; using System.Text.RegularExpressions; @@ -8,23 +5,30 @@ using Emby.Naming.Common; namespace Emby.Naming.AudioBook { + /// <summary> + /// Parser class to extract part and/or chapter number from audiobook filename. + /// </summary> public class AudioBookFilePathParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="AudioBookFilePathParser"/> class. + /// </summary> + /// <param name="options">Naming options containing AudioBookPartsExpressions.</param> public AudioBookFilePathParser(NamingOptions options) { _options = options; } + /// <summary> + /// Based on regex determines if filename includes part/chapter number. + /// </summary> + /// <param name="path">Path to audiobook file.</param> + /// <returns>Returns <see cref="AudioBookFilePathParser"/> object.</returns> public AudioBookFilePathParserResult Parse(string path) { - if (path == null) - { - throw new ArgumentNullException(nameof(path)); - } - - var result = new AudioBookFilePathParserResult(); + AudioBookFilePathParserResult result = default; var fileName = Path.GetFileNameWithoutExtension(path); foreach (var expression in _options.AudioBookPartsExpressions) { @@ -50,28 +54,13 @@ namespace Emby.Naming.AudioBook { if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) { - result.ChapterNumber = intValue; + result.PartNumber = intValue; } } } } } - /*var matches = _iRegexProvider.GetRegex("\\d+", RegexOptions.IgnoreCase).Matches(fileName); - if (matches.Count > 0) - { - if (!result.ChapterNumber.HasValue) - { - result.ChapterNumber = int.Parse(matches[0].Groups[0].Value); - } - - if (matches.Count > 1) - { - result.PartNumber = int.Parse(matches[matches.Count - 1].Groups[0].Value); - } - }*/ - result.Success = result.PartNumber.HasValue || result.ChapterNumber.HasValue; - return result; } } diff --git a/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs b/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs index e28a58db78..48ab8b57dc 100644 --- a/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs +++ b/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs @@ -1,13 +1,18 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.AudioBook { - public class AudioBookFilePathParserResult + /// <summary> + /// Data object for passing result of audiobook part/chapter extraction. + /// </summary> + public struct AudioBookFilePathParserResult { + /// <summary> + /// Gets or sets optional number of path extracted from audiobook filename. + /// </summary> public int? PartNumber { get; set; } + /// <summary> + /// Gets or sets optional number of chapter extracted from audiobook filename. + /// </summary> public int? ChapterNumber { get; set; } - - public bool Success { get; set; } } } diff --git a/Emby.Naming/AudioBook/AudioBookInfo.cs b/Emby.Naming/AudioBook/AudioBookInfo.cs index b0b5bd881f..acd8905af6 100644 --- a/Emby.Naming/AudioBook/AudioBookInfo.cs +++ b/Emby.Naming/AudioBook/AudioBookInfo.cs @@ -10,11 +10,18 @@ namespace Emby.Naming.AudioBook /// <summary> /// Initializes a new instance of the <see cref="AudioBookInfo" /> class. /// </summary> - public AudioBookInfo() + /// <param name="name">Name of audiobook.</param> + /// <param name="year">Year of audiobook release.</param> + /// <param name="files">List of files composing the actual audiobook.</param> + /// <param name="extras">List of extra files.</param> + /// <param name="alternateVersions">Alternative version of files.</param> + public AudioBookInfo(string name, int? year, IReadOnlyList<AudioBookFileInfo> files, IReadOnlyList<AudioBookFileInfo> extras, IReadOnlyList<AudioBookFileInfo> alternateVersions) { - Files = new List<AudioBookFileInfo>(); - Extras = new List<AudioBookFileInfo>(); - AlternateVersions = new List<AudioBookFileInfo>(); + Name = name; + Year = year; + Files = files; + Extras = extras; + AlternateVersions = alternateVersions; } /// <summary> @@ -32,18 +39,18 @@ namespace Emby.Naming.AudioBook /// Gets or sets the files. /// </summary> /// <value>The files.</value> - public List<AudioBookFileInfo> Files { get; set; } + public IReadOnlyList<AudioBookFileInfo> Files { get; set; } /// <summary> /// Gets or sets the extras. /// </summary> /// <value>The extras.</value> - public List<AudioBookFileInfo> Extras { get; set; } + public IReadOnlyList<AudioBookFileInfo> Extras { get; set; } /// <summary> /// Gets or sets the alternate versions. /// </summary> /// <value>The alternate versions.</value> - public List<AudioBookFileInfo> AlternateVersions { get; set; } + public IReadOnlyList<AudioBookFileInfo> AlternateVersions { get; set; } } } diff --git a/Emby.Naming/AudioBook/AudioBookListResolver.cs b/Emby.Naming/AudioBook/AudioBookListResolver.cs index f4ba11a0d1..1e4a8d2edc 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -1,6 +1,6 @@ -#pragma warning disable CS1591 - +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; @@ -8,40 +8,145 @@ using MediaBrowser.Model.IO; namespace Emby.Naming.AudioBook { + /// <summary> + /// Class used to resolve Name, Year, alternative files and extras from stack of files. + /// </summary> public class AudioBookListResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="AudioBookListResolver"/> class. + /// </summary> + /// <param name="options">Naming options passed along to <see cref="AudioBookResolver"/> and <see cref="AudioBookNameParser"/>.</param> public AudioBookListResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Resolves Name, Year and differentiate alternative files and extras from regular audiobook files. + /// </summary> + /// <param name="files">List of files related to audiobook.</param> + /// <returns>Returns IEnumerable of <see cref="AudioBookInfo"/>.</returns> public IEnumerable<AudioBookInfo> Resolve(IEnumerable<FileSystemMetadata> files) { var audioBookResolver = new AudioBookResolver(_options); + // File with empty fullname will be sorted out here. var audiobookFileInfos = files - .Select(i => audioBookResolver.Resolve(i.FullName, i.IsDirectory)) - .Where(i => i != null) + .Select(i => audioBookResolver.Resolve(i.FullName)) + .OfType<AudioBookFileInfo>() .ToList(); - // Filter out all extras, otherwise they could cause stacks to not be resolved - // See the unit test TestStackedWithTrailer - var metadata = audiobookFileInfos - .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = new StackResolver(_options) - .ResolveAudioBooks(metadata); + .ResolveAudioBooks(audiobookFileInfos); foreach (var stack in stackResult) { - var stackFiles = stack.Files.Select(i => audioBookResolver.Resolve(i, stack.IsDirectoryStack)).ToList(); + var stackFiles = stack.Files + .Select(i => audioBookResolver.Resolve(i)) + .OfType<AudioBookFileInfo>() + .ToList(); + stackFiles.Sort(); - var info = new AudioBookInfo { Files = stackFiles, Name = stack.Name }; + + var nameParserResult = new AudioBookNameParser(_options).Parse(stack.Name); + + FindExtraAndAlternativeFiles(ref stackFiles, out var extras, out var alternativeVersions, nameParserResult); + + var info = new AudioBookInfo( + nameParserResult.Name, + nameParserResult.Year, + stackFiles, + extras, + alternativeVersions); yield return info; } } + + private void FindExtraAndAlternativeFiles(ref List<AudioBookFileInfo> stackFiles, out List<AudioBookFileInfo> extras, out List<AudioBookFileInfo> alternativeVersions, AudioBookNameParserResult nameParserResult) + { + extras = new List<AudioBookFileInfo>(); + alternativeVersions = new List<AudioBookFileInfo>(); + + var haveChaptersOrPages = stackFiles.Any(x => x.ChapterNumber != null || x.PartNumber != null); + var groupedBy = stackFiles.GroupBy(file => new { file.ChapterNumber, file.PartNumber }); + var nameWithReplacedDots = nameParserResult.Name.Replace(' ', '.'); + + foreach (var group in groupedBy) + { + if (group.Key.ChapterNumber == null && group.Key.PartNumber == null) + { + if (group.Count() > 1 || haveChaptersOrPages) + { + var ex = new List<AudioBookFileInfo>(); + var alt = new List<AudioBookFileInfo>(); + + foreach (var audioFile in group) + { + var name = Path.GetFileNameWithoutExtension(audioFile.Path); + if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) || + name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) || + name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) + { + alt.Add(audioFile); + } + else + { + ex.Add(audioFile); + } + } + + if (ex.Count > 0) + { + var extra = ex + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .ToList(); + + stackFiles = stackFiles.Except(extra).ToList(); + extras.AddRange(extra); + } + + if (alt.Count > 0) + { + var alternatives = alt + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .ToList(); + + var main = FindMainAudioBookFile(alternatives, nameParserResult.Name); + alternatives.Remove(main); + stackFiles = stackFiles.Except(alternatives).ToList(); + alternativeVersions.AddRange(alternatives); + } + } + } + else if (group.Count() > 1) + { + var alternatives = group + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .Skip(1) + .ToList(); + + stackFiles = stackFiles.Except(alternatives).ToList(); + alternativeVersions.AddRange(alternatives); + } + } + } + + private AudioBookFileInfo FindMainAudioBookFile(List<AudioBookFileInfo> files, string name) + { + var main = files.Find(x => Path.GetFileNameWithoutExtension(x.Path).Equals(name, StringComparison.OrdinalIgnoreCase)); + main ??= files.FirstOrDefault(x => Path.GetFileNameWithoutExtension(x.Path).Equals("audiobook", StringComparison.OrdinalIgnoreCase)); + main ??= files.OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .First(); + + return main; + } } } diff --git a/Emby.Naming/AudioBook/AudioBookNameParser.cs b/Emby.Naming/AudioBook/AudioBookNameParser.cs new file mode 100644 index 0000000000..120482bc2c --- /dev/null +++ b/Emby.Naming/AudioBook/AudioBookNameParser.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Emby.Naming.Common; + +namespace Emby.Naming.AudioBook +{ + /// <summary> + /// Helper class to retrieve name and year from audiobook previously retrieved name. + /// </summary> + public class AudioBookNameParser + { + private readonly NamingOptions _options; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioBookNameParser"/> class. + /// </summary> + /// <param name="options">Naming options containing AudioBookNamesExpressions.</param> + public AudioBookNameParser(NamingOptions options) + { + _options = options; + } + + /// <summary> + /// Parse name and year from previously determined name of audiobook. + /// </summary> + /// <param name="name">Name of the audiobook.</param> + /// <returns>Returns <see cref="AudioBookNameParserResult"/> object.</returns> + public AudioBookNameParserResult Parse(string name) + { + AudioBookNameParserResult result = default; + foreach (var expression in _options.AudioBookNamesExpressions) + { + var match = new Regex(expression, RegexOptions.IgnoreCase).Match(name); + if (match.Success) + { + if (result.Name == null) + { + var value = match.Groups["name"]; + if (value.Success) + { + result.Name = value.Value; + } + } + + if (!result.Year.HasValue) + { + var value = match.Groups["year"]; + if (value.Success) + { + if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + { + result.Year = intValue; + } + } + } + } + } + + if (string.IsNullOrEmpty(result.Name)) + { + result.Name = name; + } + + return result; + } + } +} diff --git a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs new file mode 100644 index 0000000000..3f2d7b2b0b --- /dev/null +++ b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs @@ -0,0 +1,18 @@ +namespace Emby.Naming.AudioBook +{ + /// <summary> + /// Data object used to pass result of name and year parsing. + /// </summary> + public struct AudioBookNameParserResult + { + /// <summary> + /// Gets or sets name of audiobook. + /// </summary> + public string Name { get; set; } + + /// <summary> + /// Gets or sets optional year of release. + /// </summary> + public int? Year { get; set; } + } +} diff --git a/Emby.Naming/AudioBook/AudioBookResolver.cs b/Emby.Naming/AudioBook/AudioBookResolver.cs index 5466b46379..f6ad3601d7 100644 --- a/Emby.Naming/AudioBook/AudioBookResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookResolver.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -7,35 +5,32 @@ using Emby.Naming.Common; namespace Emby.Naming.AudioBook { + /// <summary> + /// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file. + /// </summary> public class AudioBookResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="AudioBookResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> containing AudioFileExtensions and also used to pass to AudioBookFilePathParser.</param> public AudioBookResolver(NamingOptions options) { _options = options; } - public AudioBookFileInfo ParseFile(string path) - { - return Resolve(path, false); - } - - public AudioBookFileInfo ParseDirectory(string path) + /// <summary> + /// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file. + /// </summary> + /// <param name="path">Path to audiobook file.</param> + /// <returns>Returns <see cref="AudioBookResolver"/> object.</returns> + public AudioBookFileInfo? Resolve(string path) { - return Resolve(path, true); - } - - public AudioBookFileInfo Resolve(string path, bool isDirectory = false) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - // TODO - if (isDirectory) + if (path.Length == 0 || Path.GetFileNameWithoutExtension(path).Length == 0) { + // Return null to indicate this path will not be used, instead of stopping whole process with exception return null; } @@ -51,14 +46,11 @@ namespace Emby.Naming.AudioBook var parsingResult = new AudioBookFilePathParser(_options).Parse(path); - return new AudioBookFileInfo - { - Path = path, - Container = container, - PartNumber = parsingResult.PartNumber, - ChapterNumber = parsingResult.ChapterNumber, - IsDirectory = isDirectory - }; + return new AudioBookFileInfo( + path, + container, + chapterNumber: parsingResult.ChapterNumber, + partNumber: parsingResult.PartNumber); } } } diff --git a/Emby.Naming/Common/EpisodeExpression.cs b/Emby.Naming/Common/EpisodeExpression.cs index ed6ba8881c..19d3c7aab0 100644 --- a/Emby.Naming/Common/EpisodeExpression.cs +++ b/Emby.Naming/Common/EpisodeExpression.cs @@ -1,28 +1,32 @@ -#pragma warning disable CS1591 - using System; using System.Text.RegularExpressions; namespace Emby.Naming.Common { + /// <summary> + /// Regular expressions for parsing TV Episodes. + /// </summary> public class EpisodeExpression { private string _expression; - private Regex _regex; + private Regex? _regex; - public EpisodeExpression(string expression, bool byDate) + /// <summary> + /// Initializes a new instance of the <see cref="EpisodeExpression"/> class. + /// </summary> + /// <param name="expression">Regular expressions.</param> + /// <param name="byDate">True if date is expected.</param> + public EpisodeExpression(string expression, bool byDate = false) { - Expression = expression; + _expression = expression; IsByDate = byDate; DateTimeFormats = Array.Empty<string>(); SupportsAbsoluteEpisodeNumbers = true; } - public EpisodeExpression(string expression) - : this(expression, false) - { - } - + /// <summary> + /// Gets or sets raw expressions string. + /// </summary> public string Expression { get => _expression; @@ -33,16 +37,34 @@ namespace Emby.Naming.Common } } + /// <summary> + /// Gets or sets a value indicating whether gets or sets property indicating if date can be find in expression. + /// </summary> public bool IsByDate { get; set; } + /// <summary> + /// Gets or sets a value indicating whether gets or sets property indicating if expression is optimistic. + /// </summary> public bool IsOptimistic { get; set; } + /// <summary> + /// Gets or sets a value indicating whether gets or sets property indicating if expression is named. + /// </summary> public bool IsNamed { get; set; } + /// <summary> + /// Gets or sets a value indicating whether gets or sets property indicating if expression supports episodes with absolute numbers. + /// </summary> public bool SupportsAbsoluteEpisodeNumbers { get; set; } + /// <summary> + /// Gets or sets optional list of date formats used for date parsing. + /// </summary> public string[] DateTimeFormats { get; set; } + /// <summary> + /// Gets a <see cref="Regex"/> expressions objects (creates it if null). + /// </summary> public Regex Regex => _regex ??= new Regex(Expression, RegexOptions.IgnoreCase | RegexOptions.Compiled); } } diff --git a/Emby.Naming/Common/MediaType.cs b/Emby.Naming/Common/MediaType.cs index 148833765f..dc9784c6da 100644 --- a/Emby.Naming/Common/MediaType.cs +++ b/Emby.Naming/Common/MediaType.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Common { + /// <summary> + /// Type of audiovisual media. + /// </summary> public enum MediaType { /// <summary> diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index d1e17f4169..915ce42cc9 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -1,15 +1,21 @@ -#pragma warning disable CS1591 - using System; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Video; using MediaBrowser.Model.Entities; +// ReSharper disable StringLiteralTypo + namespace Emby.Naming.Common { + /// <summary> + /// Big ugly class containing lot of different naming options that should be split and injected instead of passes everywhere. + /// </summary> public class NamingOptions { + /// <summary> + /// Initializes a new instance of the <see cref="NamingOptions"/> class. + /// </summary> public NamingOptions() { VideoFileExtensions = new[] @@ -75,74 +81,63 @@ namespace Emby.Naming.Common StubTypes = new[] { - new StubTypeRule - { - StubType = "dvd", - Token = "dvd" - }, - new StubTypeRule - { - StubType = "hddvd", - Token = "hddvd" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bluray" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "brrip" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bd25" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bd50" - }, - new StubTypeRule - { - StubType = "vhs", - Token = "vhs" - }, - new StubTypeRule - { - StubType = "tv", - Token = "HDTV" - }, - new StubTypeRule - { - StubType = "tv", - Token = "PDTV" - }, - new StubTypeRule - { - StubType = "tv", - Token = "DSR" - } + new StubTypeRule( + stubType: "dvd", + token: "dvd"), + + new StubTypeRule( + stubType: "hddvd", + token: "hddvd"), + + new StubTypeRule( + stubType: "bluray", + token: "bluray"), + + new StubTypeRule( + stubType: "bluray", + token: "brrip"), + + new StubTypeRule( + stubType: "bluray", + token: "bd25"), + + new StubTypeRule( + stubType: "bluray", + token: "bd50"), + + new StubTypeRule( + stubType: "vhs", + token: "vhs"), + + new StubTypeRule( + stubType: "tv", + token: "HDTV"), + + new StubTypeRule( + stubType: "tv", + token: "PDTV"), + + new StubTypeRule( + stubType: "tv", + token: "DSR") }; VideoFileStackingExpressions = new[] { - "(.*?)([ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(.*?)(\\.[^.]+)$", - "(.*?)([ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(.*?)(\\.[^.]+)$", - "(.*?)([ ._-]*[a-d])(.*?)(\\.[^.]+)$" + "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(?<ignore>.*?)(?<extension>\\.[^.]+)$", + "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$", + "(?<title>.*?)(?<volume>[ ._-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$" }; CleanDateTimes = new[] { - @"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*", - @"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*" + @"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*", + @"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*" }; CleanStrings = new[] { - @"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|x264|h264|xvid|xvidvd|xxx|www.www|\[.*\])([ _\,\.\(\)\[\]\-]|$)", + @"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", @"(\[.*\])" }; @@ -255,7 +250,7 @@ namespace Emby.Naming.Common }, // <!-- foo.ep01, foo.EP_01 --> new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), - new EpisodeExpression("([0-9]{4})[\\.-]([0-9]{2})[\\.-]([0-9]{2})", true) + new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true) { DateTimeFormats = new[] { @@ -264,7 +259,7 @@ namespace Emby.Naming.Common "yyyy_MM_dd" } }, - new EpisodeExpression("([0-9]{2})[\\.-]([0-9]{2})[\\.-]([0-9]{4})", true) + new EpisodeExpression(@"(?<day>[0-9]{2})[.-](?<month>[0-9]{2})[.-](?<year>[0-9]{4})", true) { DateTimeFormats = new[] { @@ -282,11 +277,22 @@ namespace Emby.Naming.Common IsNamed = true }, - new EpisodeExpression("[\\\\/\\._ \\[\\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\\.[1-9])(?![0-9]))?)([^\\\\/]*)$") + new EpisodeExpression(@"[\\\/\._ \[\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\.[1-9])(?![0-9]))?)([^\\\/]*)$") { SupportsAbsoluteEpisodeNumbers = true }, - new EpisodeExpression(@"[\\\\/\\._ -](?<seriesname>(?![0-9]+[0-9][0-9])([^\\\/])*)[\\\\/\\._ -](?<seasonnumber>[0-9]+)(?<epnumber>[0-9][0-9](?:(?:[a-i]|\\.[1-9])(?![0-9]))?)([\\._ -][^\\\\/]*)$") + + // Not a Kodi rule as well, but below rule also causes false positives for triple-digit episode names + // [bar] Foo - 1 [baz] special case of below expression to prevent false positives with digits in the series name + new EpisodeExpression(@".*[\\\/]?.*?(\[.*?\])+.*?(?<seriesname>[-\w\s]+?)[\s_]*-[\s_]*(?<epnumber>[0-9]+).*$") + { + IsNamed = true + }, + + // /server/anything_102.mp4 + // /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv + // /server/anything_1996.11.14.mp4 + new EpisodeExpression(@"[\\/._ -](?<seriesname>(?![0-9]+[0-9][0-9])([^\\\/_])*)[\\\/._ -](?<seasonnumber>[0-9]+)(?<epnumber>[0-9][0-9](?:(?:[a-i]|\.[1-9])(?![0-9]))?)([._ -][^\\\/]*)$") { IsOptimistic = true, IsNamed = true, @@ -299,11 +305,12 @@ namespace Emby.Naming.Common // *** End Kodi Standard Naming - // [bar] Foo - 1 [baz] - new EpisodeExpression(@".*?(\[.*?\])+.*?(?<seriesname>[\w\s]+?)[-\s_]+(?<epnumber>[0-9]+).*$") + // "Episode 16", "Episode 16 - Title" + new EpisodeExpression(@"[Ee]pisode (?<epnumber>[0-9]+)(-(?<endingepnumber>[0-9]+))?[^\\\/]*$") { IsNamed = true }, + new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]+)[xX](?<epnumber>[0-9]+)[^\\\/]*$") { IsNamed = true @@ -361,12 +368,6 @@ namespace Emby.Naming.Common IsOptimistic = true, IsNamed = true }, - // "Episode 16", "Episode 16 - Title" - new EpisodeExpression(@".*[\\\/][^\\\/]* (?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*[^\\\/]*$") - { - IsOptimistic = true, - IsNamed = true - } }; EpisodeWithoutSeasonExpressions = new[] @@ -381,247 +382,193 @@ namespace Emby.Naming.Common VideoExtraRules = new[] { - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Filename, - Token = "trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = "-trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = ".trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = "_trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = " trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Filename, - Token = "sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = "-sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = ".sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = "_sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = " sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.ThemeSong, - RuleType = ExtraRuleType.Filename, - Token = "theme", - MediaType = MediaType.Audio - }, - new ExtraRule - { - ExtraType = ExtraType.Scene, - RuleType = ExtraRuleType.Suffix, - Token = "-scene", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-clip", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Interview, - RuleType = ExtraRuleType.Suffix, - Token = "-interview", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.BehindTheScenes, - RuleType = ExtraRuleType.Suffix, - Token = "-behindthescenes", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.DeletedScene, - RuleType = ExtraRuleType.Suffix, - Token = "-deleted", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-featurette", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-short", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.BehindTheScenes, - RuleType = ExtraRuleType.DirectoryName, - Token = "behind the scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.DeletedScene, - RuleType = ExtraRuleType.DirectoryName, - Token = "deleted scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Interview, - RuleType = ExtraRuleType.DirectoryName, - Token = "interviews", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Scene, - RuleType = ExtraRuleType.DirectoryName, - Token = "scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.DirectoryName, - Token = "samples", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.DirectoryName, - Token = "shorts", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.DirectoryName, - Token = "featurettes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Unknown, - RuleType = ExtraRuleType.DirectoryName, - Token = "extras", - MediaType = MediaType.Video, - }, + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Filename, + "trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + "-trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + ".trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + "_trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + " trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Filename, + "sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + "-sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + ".sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + "_sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + " sample", + MediaType.Video), + + new ExtraRule( + ExtraType.ThemeSong, + ExtraRuleType.Filename, + "theme", + MediaType.Audio), + + new ExtraRule( + ExtraType.Scene, + ExtraRuleType.Suffix, + "-scene", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-clip", + MediaType.Video), + + new ExtraRule( + ExtraType.Interview, + ExtraRuleType.Suffix, + "-interview", + MediaType.Video), + + new ExtraRule( + ExtraType.BehindTheScenes, + ExtraRuleType.Suffix, + "-behindthescenes", + MediaType.Video), + + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.Suffix, + "-deleted", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-featurette", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-short", + MediaType.Video), + + new ExtraRule( + ExtraType.BehindTheScenes, + ExtraRuleType.DirectoryName, + "behind the scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.DirectoryName, + "deleted scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.Interview, + ExtraRuleType.DirectoryName, + "interviews", + MediaType.Video), + + new ExtraRule( + ExtraType.Scene, + ExtraRuleType.DirectoryName, + "scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.DirectoryName, + "samples", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "shorts", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "featurettes", + MediaType.Video), + + new ExtraRule( + ExtraType.Unknown, + ExtraRuleType.DirectoryName, + "extras", + MediaType.Video), }; Format3DRules = new[] { // Kodi rules: - new Format3DRule - { - PreceedingToken = "3d", - Token = "hsbs" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "sbs" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "htab" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "tab" - }, - // Media Browser rules: - new Format3DRule - { - Token = "fsbs" - }, - new Format3DRule - { - Token = "hsbs" - }, - new Format3DRule - { - Token = "sbs" - }, - new Format3DRule - { - Token = "ftab" - }, - new Format3DRule - { - Token = "htab" - }, - new Format3DRule - { - Token = "tab" - }, - new Format3DRule - { - Token = "sbs3d" - }, - new Format3DRule - { - Token = "mvc" - } + new Format3DRule( + precedingToken: "3d", + token: "hsbs"), + + new Format3DRule( + precedingToken: "3d", + token: "sbs"), + + new Format3DRule( + precedingToken: "3d", + token: "htab"), + + new Format3DRule( + precedingToken: "3d", + token: "tab"), + + // Media Browser rules: + new Format3DRule("fsbs"), + new Format3DRule("hsbs"), + new Format3DRule("sbs"), + new Format3DRule("ftab"), + new Format3DRule("htab"), + new Format3DRule("tab"), + new Format3DRule("sbs3d"), + new Format3DRule("mvc") }; + AudioBookPartsExpressions = new[] { // Detect specified chapters, like CH 01 @@ -631,13 +578,20 @@ namespace Emby.Naming.Common // Chapter is often beginning of filename "^(?<chapter>[0-9]+)", // Part if often ending of filename - "(?<part>[0-9]+)$", + @"(?<!ch(?:apter) )(?<part>[0-9]+)$", // Sometimes named as 0001_005 (chapter_part) "(?<chapter>[0-9]+)_(?<part>[0-9]+)", // Some audiobooks are ripped from cd's, and will be named by disk number. @"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)" }; + AudioBookNamesExpressions = new[] + { + // Detect year usually in brackets after name Batman (2020) + @"^(?<name>.+?)\s*\(\s*(?<year>[0-9]{4})\s*\)\s*$", + @"^\s*(?<name>[^ ].*?)\s*$" + }; + var extensions = VideoFileExtensions.ToList(); extensions.AddRange(new[] @@ -673,7 +627,7 @@ namespace Emby.Naming.Common ".mxf" }); - MultipleEpisodeExpressions = new string[] + MultipleEpisodeExpressions = new[] { @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$", @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[xX][eE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$", @@ -697,56 +651,139 @@ namespace Emby.Naming.Common Compile(); } + /// <summary> + /// Gets or sets list of audio file extensions. + /// </summary> public string[] AudioFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of album stacking prefixes. + /// </summary> public string[] AlbumStackingPrefixes { get; set; } + /// <summary> + /// Gets or sets list of subtitle file extensions. + /// </summary> public string[] SubtitleFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of subtitles flag delimiters. + /// </summary> public char[] SubtitleFlagDelimiters { get; set; } + /// <summary> + /// Gets or sets list of subtitle forced flags. + /// </summary> public string[] SubtitleForcedFlags { get; set; } + /// <summary> + /// Gets or sets list of subtitle default flags. + /// </summary> public string[] SubtitleDefaultFlags { get; set; } + /// <summary> + /// Gets or sets list of episode regular expressions. + /// </summary> public EpisodeExpression[] EpisodeExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw episode without season regular expressions strings. + /// </summary> public string[] EpisodeWithoutSeasonExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw multi-part episodes regular expressions strings. + /// </summary> public string[] EpisodeMultiPartExpressions { get; set; } + /// <summary> + /// Gets or sets list of video file extensions. + /// </summary> public string[] VideoFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of video stub file extensions. + /// </summary> public string[] StubFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of raw audiobook parts regular expressions strings. + /// </summary> public string[] AudioBookPartsExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw audiobook names regular expressions strings. + /// </summary> + public string[] AudioBookNamesExpressions { get; set; } + + /// <summary> + /// Gets or sets list of stub type rules. + /// </summary> public StubTypeRule[] StubTypes { get; set; } + /// <summary> + /// Gets or sets list of video flag delimiters. + /// </summary> public char[] VideoFlagDelimiters { get; set; } + /// <summary> + /// Gets or sets list of 3D Format rules. + /// </summary> public Format3DRule[] Format3DRules { get; set; } + /// <summary> + /// Gets or sets list of raw video file-stacking expressions strings. + /// </summary> public string[] VideoFileStackingExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw clean DateTimes regular expressions strings. + /// </summary> public string[] CleanDateTimes { get; set; } + /// <summary> + /// Gets or sets list of raw clean strings regular expressions strings. + /// </summary> public string[] CleanStrings { get; set; } + /// <summary> + /// Gets or sets list of multi-episode regular expressions. + /// </summary> public EpisodeExpression[] MultipleEpisodeExpressions { get; set; } + /// <summary> + /// Gets or sets list of extra rules for videos. + /// </summary> public ExtraRule[] VideoExtraRules { get; set; } - public Regex[] VideoFileStackingRegexes { get; private set; } - - public Regex[] CleanDateTimeRegexes { get; private set; } - - public Regex[] CleanStringRegexes { get; private set; } - - public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } - - public Regex[] EpisodeMultiPartRegexes { get; private set; } - + /// <summary> + /// Gets list of video file-stack regular expressions. + /// </summary> + public Regex[] VideoFileStackingRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Gets list of clean datetime regular expressions. + /// </summary> + public Regex[] CleanDateTimeRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Gets list of clean string regular expressions. + /// </summary> + public Regex[] CleanStringRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Gets list of episode without season regular expressions. + /// </summary> + public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Gets list of multi-part episode regular expressions. + /// </summary> + public Regex[] EpisodeMultiPartRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Compiles raw regex strings into regexes. + /// </summary> public void Compile() { VideoFileStackingRegexes = VideoFileStackingExpressions.Select(Compile).ToArray(); diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 5e2c6e3e36..07d879e96a 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> <PropertyGroup> @@ -6,38 +6,47 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <PublishRepositoryUrl>true</PublishRepositoryUrl> + <EmbedUntrackedSources>true</EmbedUntrackedSources> + <IncludeSymbols>true</IncludeSymbols> + <SymbolPackageFormat>snupkg</SymbolPackageFormat> + <AnalysisMode>AllDisabledByDefault</AnalysisMode> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> + <!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. --> + <AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder> </PropertyGroup> <ItemGroup> - <Compile Include="..\SharedVersion.cs" /> + <Compile Include="../SharedVersion.cs" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> + <ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" /> + <ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" /> </ItemGroup> <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Naming</PackageId> - <VersionPrefix>10.7.0</VersionPrefix> + <VersionPrefix>10.8.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> + </ItemGroup> + <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <!-- TODO: <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> --> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/Emby.Naming/Subtitles/SubtitleInfo.cs b/Emby.Naming/Subtitles/SubtitleInfo.cs index f39c496b7a..1fb2e0dc89 100644 --- a/Emby.Naming/Subtitles/SubtitleInfo.cs +++ b/Emby.Naming/Subtitles/SubtitleInfo.cs @@ -1,10 +1,24 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Subtitles { + /// <summary> + /// Class holding information about subtitle. + /// </summary> public class SubtitleInfo { /// <summary> + /// Initializes a new instance of the <see cref="SubtitleInfo"/> 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) + { + Path = path; + IsDefault = isDefault; + IsForced = isForced; + } + + /// <summary> /// Gets or sets the path. /// </summary> /// <value>The path.</value> @@ -14,7 +28,7 @@ namespace Emby.Naming.Subtitles /// Gets or sets the language. /// </summary> /// <value>The language.</value> - public string Language { get; set; } + public string? Language { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance is default. diff --git a/Emby.Naming/Subtitles/SubtitleParser.cs b/Emby.Naming/Subtitles/SubtitleParser.cs index 24e59f90a3..a19340ef69 100644 --- a/Emby.Naming/Subtitles/SubtitleParser.cs +++ b/Emby.Naming/Subtitles/SubtitleParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -8,20 +5,32 @@ using Emby.Naming.Common; 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) { - throw new ArgumentException("File path can't be empty.", nameof(path)); + return null; } var extension = Path.GetExtension(path); @@ -31,12 +40,10 @@ namespace Emby.Naming.Subtitles } var flags = GetFlags(path); - var info = new SubtitleInfo - { - Path = path, - IsDefault = _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)), - IsForced = _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)) - }; + var info = new SubtitleInfo( + path, + _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)), + _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase))); var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparer.OrdinalIgnoreCase) && !_options.SubtitleForcedFlags.Contains(i, StringComparer.OrdinalIgnoreCase)) @@ -53,7 +60,7 @@ namespace Emby.Naming.Subtitles private string[] GetFlags(string path) { - // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _. + // Note: the tags need be surrounded be either a space ( ), hyphen -, dot . or underscore _. var file = Path.GetFileName(path); diff --git a/Emby.Naming/TV/EpisodeInfo.cs b/Emby.Naming/TV/EpisodeInfo.cs index 250df4e2d3..a8920b36ae 100644 --- a/Emby.Naming/TV/EpisodeInfo.cs +++ b/Emby.Naming/TV/EpisodeInfo.cs @@ -1,10 +1,20 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Holder object for Episode information. + /// </summary> public class EpisodeInfo { /// <summary> + /// Initializes a new instance of the <see cref="EpisodeInfo"/> class. + /// </summary> + /// <param name="path">Path to the file.</param> + public EpisodeInfo(string path) + { + Path = path; + } + + /// <summary> /// Gets or sets the path. /// </summary> /// <value>The path.</value> @@ -14,19 +24,19 @@ namespace Emby.Naming.TV /// Gets or sets the container. /// </summary> /// <value>The container.</value> - public string Container { get; set; } + public string? Container { get; set; } /// <summary> /// Gets or sets the name of the series. /// </summary> /// <value>The name of the series.</value> - public string SeriesName { get; set; } + public string? SeriesName { get; set; } /// <summary> /// Gets or sets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } + public string? Format3D { get; set; } /// <summary> /// Gets or sets a value indicating whether [is3 d]. @@ -44,20 +54,41 @@ namespace Emby.Naming.TV /// Gets or sets the type of the stub. /// </summary> /// <value>The type of the stub.</value> - public string StubType { get; set; } + public string? StubType { get; set; } + /// <summary> + /// Gets or sets optional season number. + /// </summary> public int? SeasonNumber { get; set; } + /// <summary> + /// Gets or sets optional episode number. + /// </summary> public int? EpisodeNumber { get; set; } - public int? EndingEpsiodeNumber { get; set; } + /// <summary> + /// Gets or sets optional ending episode number. For multi-episode files 1-13. + /// </summary> + public int? EndingEpisodeNumber { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Year { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Month { get; set; } + /// <summary> + /// Gets or sets optional day of release. + /// </summary> public int? Day { get; set; } + /// <summary> + /// Gets or sets a value indicating whether by date expression was used. + /// </summary> public bool IsByDate { get; set; } } } diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index a6af689c72..6d0597356b 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.Collections.Generic; using System.Globalization; @@ -9,15 +6,32 @@ using Emby.Naming.Common; namespace Emby.Naming.TV { + /// <summary> + /// Used to parse information about episode from path. + /// </summary> public class EpisodePathParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="EpisodePathParser"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing EpisodeExpressions and MultipleEpisodeExpressions.</param> public EpisodePathParser(NamingOptions options) { _options = options; } + /// <summary> + /// Parses information about episode from path. + /// </summary> + /// <param name="path">Path.</param> + /// <param name="isDirectory">Is path for a directory or file.</param> + /// <param name="isNamed">Do we want to use IsNamed expressions.</param> + /// <param name="isOptimistic">Do we want to use Optimistic expressions.</param> + /// <param name="supportsAbsoluteNumbers">Do we want to use expressions supporting absolute episode numbers.</param> + /// <param name="fillExtendedInfo">Should we attempt to retrieve extended information.</param> + /// <returns>Returns <see cref="EpisodePathParserResult"/> object.</returns> public EpisodePathParserResult Parse( string path, bool isDirectory, @@ -146,7 +160,7 @@ namespace Emby.Naming.TV { if (int.TryParse(endingNumberGroup.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { - result.EndingEpsiodeNumber = num; + result.EndingEpisodeNumber = num; } } } @@ -186,7 +200,7 @@ namespace Emby.Naming.TV private void FillAdditional(string path, EpisodePathParserResult info) { - var expressions = _options.MultipleEpisodeExpressions.ToList(); + var expressions = _options.MultipleEpisodeExpressions.Where(i => i.IsNamed).ToList(); if (string.IsNullOrEmpty(info.SeriesName)) { @@ -200,11 +214,6 @@ namespace Emby.Naming.TV { foreach (var i in expressions) { - if (!i.IsNamed) - { - continue; - } - var result = Parse(path, i); if (!result.Success) @@ -217,13 +226,13 @@ namespace Emby.Naming.TV info.SeriesName = result.SeriesName; } - if (!info.EndingEpsiodeNumber.HasValue && info.EpisodeNumber.HasValue) + if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue) { - info.EndingEpsiodeNumber = result.EndingEpsiodeNumber; + info.EndingEpisodeNumber = result.EndingEpisodeNumber; } if (!string.IsNullOrEmpty(info.SeriesName) - && (!info.EpisodeNumber.HasValue || info.EndingEpsiodeNumber.HasValue)) + && (!info.EpisodeNumber.HasValue || info.EndingEpisodeNumber.HasValue)) { break; } diff --git a/Emby.Naming/TV/EpisodePathParserResult.cs b/Emby.Naming/TV/EpisodePathParserResult.cs index 05f921edc9..233d5a4f6c 100644 --- a/Emby.Naming/TV/EpisodePathParserResult.cs +++ b/Emby.Naming/TV/EpisodePathParserResult.cs @@ -1,25 +1,54 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Holder object for <see cref="EpisodePathParser"/> result. + /// </summary> public class EpisodePathParserResult { + /// <summary> + /// Gets or sets optional season number. + /// </summary> public int? SeasonNumber { get; set; } + /// <summary> + /// Gets or sets optional episode number. + /// </summary> public int? EpisodeNumber { get; set; } - public int? EndingEpsiodeNumber { get; set; } + /// <summary> + /// Gets or sets optional ending episode number. For multi-episode files 1-13. + /// </summary> + public int? EndingEpisodeNumber { get; set; } - public string SeriesName { get; set; } + /// <summary> + /// Gets or sets the name of the series. + /// </summary> + /// <value>The name of the series.</value> + public string? SeriesName { get; set; } + /// <summary> + /// Gets or sets a value indicating whether parsing was successful. + /// </summary> public bool Success { get; set; } + /// <summary> + /// Gets or sets a value indicating whether by date expression was used. + /// </summary> public bool IsByDate { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Year { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Month { get; set; } + /// <summary> + /// Gets or sets optional day of release. + /// </summary> public int? Day { get; set; } } } diff --git a/Emby.Naming/TV/EpisodeResolver.cs b/Emby.Naming/TV/EpisodeResolver.cs index 6994f69fc4..5e952e47b7 100644 --- a/Emby.Naming/TV/EpisodeResolver.cs +++ b/Emby.Naming/TV/EpisodeResolver.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.IO; using System.Linq; @@ -9,15 +6,32 @@ using Emby.Naming.Video; namespace Emby.Naming.TV { + /// <summary> + /// Used to resolve information about episode from path. + /// </summary> public class EpisodeResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="EpisodeResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions and passed to <see cref="StubResolver"/>, <see cref="Format3DParser"/> and <see cref="EpisodePathParser"/>.</param> public EpisodeResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Resolve information about episode from path. + /// </summary> + /// <param name="path">Path.</param> + /// <param name="isDirectory">Is path for a directory or file.</param> + /// <param name="isNamed">Do we want to use IsNamed expressions.</param> + /// <param name="isOptimistic">Do we want to use Optimistic expressions.</param> + /// <param name="supportsAbsoluteNumbers">Do we want to use expressions supporting absolute episode numbers.</param> + /// <param name="fillExtendedInfo">Should we attempt to retrieve extended information.</param> + /// <returns>Returns null or <see cref="EpisodeInfo"/> object if successful.</returns> public EpisodeInfo? Resolve( string path, bool isDirectory, @@ -48,18 +62,21 @@ namespace Emby.Naming.TV container = extension.TrimStart('.'); } - var flags = new FlagParser(_options).GetFlags(path); - var format3DResult = new Format3DParser(_options).Parse(flags); + var format3DResult = Format3DParser.Parse(path, _options); var parsingResult = new EpisodePathParser(_options) .Parse(path, isDirectory, isNamed, isOptimistic, supportsAbsoluteNumbers, fillExtendedInfo); - return new EpisodeInfo + if (!parsingResult.Success && !isStub) + { + return null; + } + + return new EpisodeInfo(path) { - Path = path, Container = container, IsStub = isStub, - EndingEpsiodeNumber = parsingResult.EndingEpsiodeNumber, + EndingEpisodeNumber = parsingResult.EndingEpisodeNumber, EpisodeNumber = parsingResult.EpisodeNumber, SeasonNumber = parsingResult.SeasonNumber, SeriesName = parsingResult.SeriesName, diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index d2e324dda5..6236f86c43 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -1,11 +1,12 @@ -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; namespace Emby.Naming.TV { + /// <summary> + /// Class to parse season paths. + /// </summary> public static class SeasonPathParser { /// <summary> @@ -23,6 +24,13 @@ namespace Emby.Naming.TV "stagione" }; + /// <summary> + /// Attempts to parse season number from path. + /// </summary> + /// <param name="path">Path to season.</param> + /// <param name="supportSpecialAliases">Support special aliases when parsing.</param> + /// <param name="supportNumericSeasonFolders">Support numeric season folders when parsing.</param> + /// <returns>Returns <see cref="SeasonPathParserResult"/> object.</returns> public static SeasonPathParserResult Parse(string path, bool supportSpecialAliases, bool supportNumericSeasonFolders) { var result = new SeasonPathParserResult(); @@ -52,7 +60,7 @@ namespace Emby.Naming.TV bool supportSpecialAliases, bool supportNumericSeasonFolders) { - var filename = Path.GetFileName(path) ?? string.Empty; + string filename = Path.GetFileName(path); if (supportSpecialAliases) { @@ -101,9 +109,9 @@ namespace Emby.Naming.TV } var parts = filename.Split(new[] { '.', '_', ' ', '-' }, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < parts.Length; i++) + foreach (var part in parts) { - if (TryGetSeasonNumberFromPart(parts[i], out int seasonNumber)) + if (TryGetSeasonNumberFromPart(part, out int seasonNumber)) { return (seasonNumber, true); } @@ -139,7 +147,7 @@ namespace Emby.Naming.TV var numericStart = -1; var length = 0; - var hasOpenParenth = false; + var hasOpenParenthesis = false; var isSeasonFolder = true; // Find out where the numbers start, and then keep going until they end @@ -147,7 +155,7 @@ namespace Emby.Naming.TV { if (char.IsNumber(path[i])) { - if (!hasOpenParenth) + if (!hasOpenParenthesis) { if (numericStart == -1) { @@ -167,11 +175,11 @@ namespace Emby.Naming.TV var currentChar = path[i]; if (currentChar == '(') { - hasOpenParenth = true; + hasOpenParenthesis = true; } else if (currentChar == ')') { - hasOpenParenth = false; + hasOpenParenthesis = false; } } diff --git a/Emby.Naming/TV/SeasonPathParserResult.cs b/Emby.Naming/TV/SeasonPathParserResult.cs index a142fafea0..b4b6f236a7 100644 --- a/Emby.Naming/TV/SeasonPathParserResult.cs +++ b/Emby.Naming/TV/SeasonPathParserResult.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Data object to pass result of <see cref="SeasonPathParser"/>. + /// </summary> public class SeasonPathParserResult { /// <summary> @@ -16,6 +17,10 @@ namespace Emby.Naming.TV /// <value><c>true</c> if success; otherwise, <c>false</c>.</value> public bool Success { get; set; } + /// <summary> + /// Gets or sets a value indicating whether "Is season folder". + /// Seems redundant and barely used. + /// </summary> public bool IsSeasonFolder { get; set; } } } diff --git a/Emby.Naming/Video/CleanDateTimeParser.cs b/Emby.Naming/Video/CleanDateTimeParser.cs index 579c9e91e1..0ee633dcc6 100644 --- a/Emby.Naming/Video/CleanDateTimeParser.cs +++ b/Emby.Naming/Video/CleanDateTimeParser.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; @@ -12,9 +9,20 @@ namespace Emby.Naming.Video /// </summary> public static class CleanDateTimeParser { + /// <summary> + /// Attempts to clean the name. + /// </summary> + /// <param name="name">Name of video.</param> + /// <param name="cleanDateTimeRegexes">Optional list of regexes to clean the name.</param> + /// <returns>Returns <see cref="CleanDateTimeResult"/> object.</returns> public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes) { CleanDateTimeResult result = new CleanDateTimeResult(name); + if (string.IsNullOrEmpty(name)) + { + return result; + } + var len = cleanDateTimeRegexes.Count; for (int i = 0; i < len; i++) { diff --git a/Emby.Naming/Video/CleanDateTimeResult.cs b/Emby.Naming/Video/CleanDateTimeResult.cs index 57eeaa7e32..c675a19d0f 100644 --- a/Emby.Naming/Video/CleanDateTimeResult.cs +++ b/Emby.Naming/Video/CleanDateTimeResult.cs @@ -1,22 +1,21 @@ -#pragma warning disable CS1591 -#nullable enable - namespace Emby.Naming.Video { + /// <summary> + /// Holder structure for name and year. + /// </summary> public readonly struct CleanDateTimeResult { - public CleanDateTimeResult(string name, int? year) + /// <summary> + /// Initializes a new instance of the <see cref="CleanDateTimeResult"/> struct. + /// </summary> + /// <param name="name">Name of video.</param> + /// <param name="year">Year of release.</param> + public CleanDateTimeResult(string name, int? year = null) { Name = name; Year = year; } - public CleanDateTimeResult(string name) - { - Name = name; - Year = null; - } - /// <summary> /// Gets the name. /// </summary> diff --git a/Emby.Naming/Video/CleanStringParser.cs b/Emby.Naming/Video/CleanStringParser.cs index 3f584d5847..4eef3ebc5e 100644 --- a/Emby.Naming/Video/CleanStringParser.cs +++ b/Emby.Naming/Video/CleanStringParser.cs @@ -1,8 +1,6 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; namespace Emby.Naming.Video @@ -12,8 +10,21 @@ namespace Emby.Naming.Video /// </summary> public static class CleanStringParser { - public static bool TryClean(string name, IReadOnlyList<Regex> expressions, out ReadOnlySpan<char> newName) + /// <summary> + /// Attempts to extract clean name with regular expressions. + /// </summary> + /// <param name="name">Name of file.</param> + /// <param name="expressions">List of regex to parse name and year from.</param> + /// <param name="newName">Parsing result string.</param> + /// <returns>True if parsing was successful.</returns> + public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out ReadOnlySpan<char> newName) { + if (string.IsNullOrEmpty(name)) + { + newName = ReadOnlySpan<char>.Empty; + return false; + } + var len = expressions.Count; for (int i = 0; i < len; i++) { @@ -37,7 +48,7 @@ namespace Emby.Naming.Video return true; } - newName = string.Empty; + newName = ReadOnlySpan<char>.Empty; return false; } } diff --git a/Emby.Naming/Video/ExtraResolver.cs b/Emby.Naming/Video/ExtraResolver.cs index fc0424faab..a32af002cc 100644 --- a/Emby.Naming/Video/ExtraResolver.cs +++ b/Emby.Naming/Video/ExtraResolver.cs @@ -1,92 +1,100 @@ -#pragma warning disable CS1591 - using System; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Audio; using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Resolve if file is extra for video. + /// </summary> public class ExtraResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="ExtraResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoExtraRules and passed to <see cref="AudioFileParser"/> and <see cref="VideoResolver"/>.</param> public ExtraResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Attempts to resolve if file is extra. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>Returns <see cref="ExtraResult"/> object.</returns> public ExtraResult GetExtraInfo(string path) { - return _options.VideoExtraRules - .Select(i => GetExtraInfo(path, i)) - .FirstOrDefault(i => i.ExtraType != null) ?? new ExtraResult(); - } - - private ExtraResult GetExtraInfo(string path, ExtraRule rule) - { var result = new ExtraResult(); - if (rule.MediaType == MediaType.Audio) + for (var i = 0; i < _options.VideoExtraRules.Length; i++) { - if (!AudioFileParser.IsAudioFile(path, _options)) + var rule = _options.VideoExtraRules[i]; + if (rule.MediaType == MediaType.Audio) { - return result; + if (!AudioFileParser.IsAudioFile(path, _options)) + { + continue; + } } - } - else if (rule.MediaType == MediaType.Video) - { - if (!new VideoResolver(_options).IsVideoFile(path)) + else if (rule.MediaType == MediaType.Video) { - return result; + if (!VideoResolver.IsVideoFile(path, _options)) + { + continue; + } } - } - else - { - return result; - } - - if (rule.RuleType == ExtraRuleType.Filename) - { - var filename = Path.GetFileNameWithoutExtension(path); - if (string.Equals(filename, rule.Token, StringComparison.OrdinalIgnoreCase)) + var pathSpan = path.AsSpan(); + if (rule.RuleType == ExtraRuleType.Filename) { - result.ExtraType = rule.ExtraType; - result.Rule = rule; - } - } - else if (rule.RuleType == ExtraRuleType.Suffix) - { - var filename = Path.GetFileNameWithoutExtension(path); + var filename = Path.GetFileNameWithoutExtension(pathSpan); - if (filename.IndexOf(rule.Token, StringComparison.OrdinalIgnoreCase) > 0) + if (filename.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)) + { + result.ExtraType = rule.ExtraType; + result.Rule = rule; + } + } + else if (rule.RuleType == ExtraRuleType.Suffix) { - result.ExtraType = rule.ExtraType; - result.Rule = rule; + var filename = Path.GetFileNameWithoutExtension(pathSpan); + + if (filename.Contains(rule.Token, StringComparison.OrdinalIgnoreCase)) + { + result.ExtraType = rule.ExtraType; + result.Rule = rule; + } } - } - else if (rule.RuleType == ExtraRuleType.Regex) - { - var filename = Path.GetFileName(path); + else if (rule.RuleType == ExtraRuleType.Regex) + { + var filename = Path.GetFileName(path); - var regex = new Regex(rule.Token, RegexOptions.IgnoreCase); + var regex = new Regex(rule.Token, RegexOptions.IgnoreCase); - if (regex.IsMatch(filename)) + if (regex.IsMatch(filename)) + { + result.ExtraType = rule.ExtraType; + result.Rule = rule; + } + } + else if (rule.RuleType == ExtraRuleType.DirectoryName) { - result.ExtraType = rule.ExtraType; - result.Rule = rule; + var directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan)); + if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)) + { + result.ExtraType = rule.ExtraType; + result.Rule = rule; + } } - } - else if (rule.RuleType == ExtraRuleType.DirectoryName) - { - var directoryName = Path.GetFileName(Path.GetDirectoryName(path)); - if (string.Equals(directoryName, rule.Token, StringComparison.OrdinalIgnoreCase)) + + if (result.ExtraType != null) { - result.ExtraType = rule.ExtraType; - result.Rule = rule; + return result; } } diff --git a/Emby.Naming/Video/ExtraResult.cs b/Emby.Naming/Video/ExtraResult.cs index 15db32e876..243fc2b415 100644 --- a/Emby.Naming/Video/ExtraResult.cs +++ b/Emby.Naming/Video/ExtraResult.cs @@ -1,9 +1,10 @@ -#pragma warning disable CS1591 - using MediaBrowser.Model.Entities; namespace Emby.Naming.Video { + /// <summary> + /// Holder object for passing results from ExtraResolver. + /// </summary> public class ExtraResult { /// <summary> @@ -16,6 +17,6 @@ namespace Emby.Naming.Video /// Gets or sets the rule. /// </summary> /// <value>The rule.</value> - public ExtraRule Rule { get; set; } + public ExtraRule? Rule { get; set; } } } diff --git a/Emby.Naming/Video/ExtraRule.cs b/Emby.Naming/Video/ExtraRule.cs index 7c9702e244..e267ac55fc 100644 --- a/Emby.Naming/Video/ExtraRule.cs +++ b/Emby.Naming/Video/ExtraRule.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using MediaBrowser.Model.Entities; using MediaType = Emby.Naming.Common.MediaType; @@ -11,6 +9,21 @@ namespace Emby.Naming.Video public class ExtraRule { /// <summary> + /// Initializes a new instance of the <see cref="ExtraRule"/> class. + /// </summary> + /// <param name="extraType">Type of extra.</param> + /// <param name="ruleType">Type of rule.</param> + /// <param name="token">Token.</param> + /// <param name="mediaType">Media type.</param> + public ExtraRule(ExtraType extraType, ExtraRuleType ruleType, string token, MediaType mediaType) + { + Token = token; + ExtraType = extraType; + RuleType = ruleType; + MediaType = mediaType; + } + + /// <summary> /// Gets or sets the token to use for matching against the file path. /// </summary> public string Token { get; set; } diff --git a/Emby.Naming/Video/ExtraRuleType.cs b/Emby.Naming/Video/ExtraRuleType.cs index e89876f4ae..3243195057 100644 --- a/Emby.Naming/Video/ExtraRuleType.cs +++ b/Emby.Naming/Video/ExtraRuleType.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Extra rules type to determine against what <see cref="ExtraRule.Token"/> should be matched. + /// </summary> public enum ExtraRuleType { /// <summary> @@ -22,6 +23,6 @@ namespace Emby.Naming.Video /// <summary> /// Match <see cref="ExtraRule.Token"/> against the name of the directory containing the file. /// </summary> - DirectoryName = 3, + DirectoryName = 3 } } diff --git a/Emby.Naming/Video/FileStack.cs b/Emby.Naming/Video/FileStack.cs index 3ef190b865..6519db57c3 100644 --- a/Emby.Naming/Video/FileStack.cs +++ b/Emby.Naming/Video/FileStack.cs @@ -1,24 +1,43 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; namespace Emby.Naming.Video { + /// <summary> + /// Object holding list of files paths with additional information. + /// </summary> public class FileStack { + /// <summary> + /// Initializes a new instance of the <see cref="FileStack"/> class. + /// </summary> public FileStack() { Files = new List<string>(); } - public string Name { get; set; } + /// <summary> + /// Gets or sets name of file stack. + /// </summary> + public string Name { get; set; } = string.Empty; + /// <summary> + /// Gets or sets list of paths in stack. + /// </summary> public List<string> Files { get; set; } + /// <summary> + /// Gets or sets a value indicating whether stack is directory stack. + /// </summary> public bool IsDirectoryStack { get; set; } + /// <summary> + /// Helper function to determine if path is in the stack. + /// </summary> + /// <param name="file">Path of desired file.</param> + /// <param name="isDirectory">Requested type of stack.</param> + /// <returns>True if file is in the stack.</returns> public bool ContainsFile(string file, bool isDirectory) { if (IsDirectoryStack == isDirectory) diff --git a/Emby.Naming/Video/FlagParser.cs b/Emby.Naming/Video/FlagParser.cs deleted file mode 100644 index a8bd9d5c5d..0000000000 --- a/Emby.Naming/Video/FlagParser.cs +++ /dev/null @@ -1,37 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using Emby.Naming.Common; - -namespace Emby.Naming.Video -{ - public class FlagParser - { - private readonly NamingOptions _options; - - public FlagParser(NamingOptions options) - { - _options = options; - } - - public string[] GetFlags(string path) - { - return GetFlags(path, _options.VideoFlagDelimiters); - } - - public string[] GetFlags(string path, char[] delimeters) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _. - - var file = Path.GetFileName(path); - - return file.Split(delimeters, StringSplitOptions.RemoveEmptyEntries); - } - } -} diff --git a/Emby.Naming/Video/Format3DParser.cs b/Emby.Naming/Video/Format3DParser.cs index 51c26af863..0890899894 100644 --- a/Emby.Naming/Video/Format3DParser.cs +++ b/Emby.Naming/Video/Format3DParser.cs @@ -1,35 +1,37 @@ -#pragma warning disable CS1591 - using System; -using System.Linq; using Emby.Naming.Common; namespace Emby.Naming.Video { - public class Format3DParser + /// <summary> + /// Parse 3D format related flags. + /// </summary> + public static class Format3DParser { - private readonly NamingOptions _options; - - public Format3DParser(NamingOptions options) + // Static default result to save on allocation costs. + private static readonly Format3DResult _defaultResult = new (false, null); + + /// <summary> + /// Parse 3D format related flags. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="namingOptions">The naming options.</param> + /// <returns>Returns <see cref="Format3DResult"/> object.</returns> + public static Format3DResult Parse(ReadOnlySpan<char> path, NamingOptions namingOptions) { - _options = options; - } + int oldLen = namingOptions.VideoFlagDelimiters.Length; + Span<char> delimiters = stackalloc char[oldLen + 1]; + namingOptions.VideoFlagDelimiters.AsSpan().CopyTo(delimiters); + delimiters[oldLen] = ' '; - public Format3DResult Parse(string path) - { - int oldLen = _options.VideoFlagDelimiters.Length; - var delimeters = new char[oldLen + 1]; - _options.VideoFlagDelimiters.CopyTo(delimeters, 0); - delimeters[oldLen] = ' '; - - return Parse(new FlagParser(_options).GetFlags(path, delimeters)); + return Parse(path, delimiters, namingOptions); } - internal Format3DResult Parse(string[] videoFlags) + private static Format3DResult Parse(ReadOnlySpan<char> path, ReadOnlySpan<char> delimiters, NamingOptions namingOptions) { - foreach (var rule in _options.Format3DRules) + foreach (var rule in namingOptions.Format3DRules) { - var result = Parse(videoFlags, rule); + var result = Parse(path, rule, delimiters); if (result.Is3D) { @@ -37,51 +39,43 @@ namespace Emby.Naming.Video } } - return new Format3DResult(); + return _defaultResult; } - private static Format3DResult Parse(string[] videoFlags, Format3DRule rule) + private static Format3DResult Parse(ReadOnlySpan<char> path, Format3DRule rule, ReadOnlySpan<char> delimiters) { - var result = new Format3DResult(); + bool is3D = false; + string? format3D = null; - if (string.IsNullOrEmpty(rule.PreceedingToken)) + // If there's no preceding token we just consider it found + var foundPrefix = string.IsNullOrEmpty(rule.PrecedingToken); + while (path.Length > 0) { - result.Format3D = new[] { rule.Token }.FirstOrDefault(i => videoFlags.Contains(i, StringComparer.OrdinalIgnoreCase)); - result.Is3D = !string.IsNullOrEmpty(result.Format3D); - - if (result.Is3D) + var index = path.IndexOfAny(delimiters); + if (index == -1) { - result.Tokens.Add(rule.Token); + index = path.Length - 1; } - } - else - { - var foundPrefix = false; - string format = null; - foreach (var flag in videoFlags) - { - if (foundPrefix) - { - result.Tokens.Add(rule.PreceedingToken); + var currentSlice = path[..index]; + path = path[(index + 1)..]; - if (string.Equals(rule.Token, flag, StringComparison.OrdinalIgnoreCase)) - { - format = flag; - result.Tokens.Add(rule.Token); - } + if (!foundPrefix) + { + foundPrefix = currentSlice.Equals(rule.PrecedingToken, StringComparison.OrdinalIgnoreCase); + continue; + } - break; - } + is3D = foundPrefix && currentSlice.Equals(rule.Token, StringComparison.OrdinalIgnoreCase); - foundPrefix = string.Equals(flag, rule.PreceedingToken, StringComparison.OrdinalIgnoreCase); + if (is3D) + { + format3D = rule.Token; + break; } - - result.Is3D = foundPrefix && !string.IsNullOrEmpty(format); - result.Format3D = format; } - return result; + return is3D ? new Format3DResult(true, format3D) : _defaultResult; } } } diff --git a/Emby.Naming/Video/Format3DResult.cs b/Emby.Naming/Video/Format3DResult.cs index fa0e9d3b80..aac959c133 100644 --- a/Emby.Naming/Video/Format3DResult.cs +++ b/Emby.Naming/Video/Format3DResult.cs @@ -1,32 +1,31 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; - namespace Emby.Naming.Video { + /// <summary> + /// Helper object to return data from <see cref="Format3DParser"/>. + /// </summary> public class Format3DResult { - public Format3DResult() + /// <summary> + /// Initializes a new instance of the <see cref="Format3DResult"/> class. + /// </summary> + /// <param name="is3D">A value indicating whether the parsed string contains 3D tokens.</param> + /// <param name="format3D">The 3D format. Value might be null if [is3D] is <c>false</c>.</param> + public Format3DResult(bool is3D, string? format3D) { - Tokens = new List<string>(); + Is3D = is3D; + Format3D = format3D; } /// <summary> - /// Gets or sets a value indicating whether [is3 d]. + /// Gets a value indicating whether [is3 d]. /// </summary> /// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value> - public bool Is3D { get; set; } + public bool Is3D { get; } /// <summary> - /// Gets or sets the format3 d. + /// Gets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } - - /// <summary> - /// Gets or sets the tokens. - /// </summary> - /// <value>The tokens.</value> - public List<string> Tokens { get; set; } + public string? Format3D { get; } } } diff --git a/Emby.Naming/Video/Format3DRule.cs b/Emby.Naming/Video/Format3DRule.cs index 310ec84e8f..e562691df9 100644 --- a/Emby.Naming/Video/Format3DRule.cs +++ b/Emby.Naming/Video/Format3DRule.cs @@ -1,19 +1,31 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Data holder class for 3D format rule. + /// </summary> public class Format3DRule { /// <summary> + /// Initializes a new instance of the <see cref="Format3DRule"/> class. + /// </summary> + /// <param name="token">Token.</param> + /// <param name="precedingToken">Token present before current token.</param> + public Format3DRule(string token, string? precedingToken = null) + { + Token = token; + PrecedingToken = precedingToken; + } + + /// <summary> /// Gets or sets the token. /// </summary> /// <value>The token.</value> public string Token { get; set; } /// <summary> - /// Gets or sets the preceeding token. + /// Gets or sets the preceding token. /// </summary> - /// <value>The preceeding token.</value> - public string PreceedingToken { get; set; } + /// <value>The preceding token.</value> + public string? PrecedingToken { get; set; } } } diff --git a/Emby.Naming/Video/StackResolver.cs b/Emby.Naming/Video/StackResolver.cs index f733cd2620..36f65a5624 100644 --- a/Emby.Naming/Video/StackResolver.cs +++ b/Emby.Naming/Video/StackResolver.cs @@ -1,64 +1,92 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; +using Emby.Naming.AudioBook; using Emby.Naming.Common; using MediaBrowser.Model.IO; namespace Emby.Naming.Video { + /// <summary> + /// Resolve <see cref="FileStack"/> from list of paths. + /// </summary> public class StackResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="StackResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileStackingRegexes and passes options to <see cref="VideoResolver"/>.</param> public StackResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Resolves only directories from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns> public IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files) { return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true })); } + /// <summary> + /// Resolves only files from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of files.</returns> public IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files) { return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false })); } - public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<FileSystemMetadata> files) + /// <summary> + /// Resolves audiobooks from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns> + public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files) { - var groupedDirectoryFiles = files.GroupBy(file => - file.IsDirectory - ? file.FullName - : Path.GetDirectoryName(file.FullName)); + var groupedDirectoryFiles = files.GroupBy(file => Path.GetDirectoryName(file.Path)); foreach (var directory in groupedDirectoryFiles) { - var stack = new FileStack { Name = Path.GetFileName(directory.Key), IsDirectoryStack = false }; - foreach (var file in directory) + if (string.IsNullOrEmpty(directory.Key)) { - if (file.IsDirectory) + foreach (var file in directory) { - continue; + var stack = new FileStack { Name = Path.GetFileNameWithoutExtension(file.Path), IsDirectoryStack = false }; + stack.Files.Add(file.Path); + yield return stack; } - - stack.Files.Add(file.FullName); } + else + { + var stack = new FileStack { Name = Path.GetFileName(directory.Key), IsDirectoryStack = false }; + foreach (var file in directory) + { + stack.Files.Add(file.Path); + } - yield return stack; + yield return stack; + } } } + /// <summary> + /// Resolves videos from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of videos.</returns> public IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files) { - var resolver = new VideoResolver(_options); - var list = files - .Where(i => i.IsDirectory || resolver.IsVideoFile(i.FullName) || resolver.IsStubFile(i.FullName)) + .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, _options) || VideoResolver.IsStubFile(i.FullName, _options)) .OrderBy(i => i.FullName) .ToList(); @@ -81,10 +109,10 @@ namespace Emby.Naming.Video if (match1.Success) { - var title1 = match1.Groups[1].Value; - var volume1 = match1.Groups[2].Value; - var ignore1 = match1.Groups[3].Value; - var extension1 = match1.Groups[4].Value; + var title1 = match1.Groups["title"].Value; + var volume1 = match1.Groups["volume"].Value; + var ignore1 = match1.Groups["ignore"].Value; + var extension1 = match1.Groups["extension"].Value; var j = i + 1; while (j < list.Count) diff --git a/Emby.Naming/Video/StubResolver.cs b/Emby.Naming/Video/StubResolver.cs index f1b5d7bcca..079987fe8a 100644 --- a/Emby.Naming/Video/StubResolver.cs +++ b/Emby.Naming/Video/StubResolver.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.IO; using System.Linq; @@ -8,13 +5,23 @@ using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Resolve if file is stub (.disc). + /// </summary> public static class StubResolver { + /// <summary> + /// Tries to resolve if file is stub (.disc). + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="options">NamingOptions containing StubFileExtensions and StubTypes.</param> + /// <param name="stubType">Stub type.</param> + /// <returns>True if file is a stub.</returns> public static bool TryResolveFile(string path, NamingOptions options, out string? stubType) { stubType = default; - if (path == null) + if (string.IsNullOrEmpty(path)) { return false; } diff --git a/Emby.Naming/Video/StubResult.cs b/Emby.Naming/Video/StubResult.cs deleted file mode 100644 index 1b8e99b0dc..0000000000 --- a/Emby.Naming/Video/StubResult.cs +++ /dev/null @@ -1,19 +0,0 @@ -#pragma warning disable CS1591 - -namespace Emby.Naming.Video -{ - public struct StubResult - { - /// <summary> - /// Gets or sets a value indicating whether this instance is stub. - /// </summary> - /// <value><c>true</c> if this instance is stub; otherwise, <c>false</c>.</value> - public bool IsStub { get; set; } - - /// <summary> - /// Gets or sets the type of the stub. - /// </summary> - /// <value>The type of the stub.</value> - public string StubType { get; set; } - } -} diff --git a/Emby.Naming/Video/StubTypeRule.cs b/Emby.Naming/Video/StubTypeRule.cs index 8285cb51a3..dfb3ac013d 100644 --- a/Emby.Naming/Video/StubTypeRule.cs +++ b/Emby.Naming/Video/StubTypeRule.cs @@ -1,10 +1,22 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Data class holding information about Stub type rule. + /// </summary> public class StubTypeRule { /// <summary> + /// Initializes a new instance of the <see cref="StubTypeRule"/> class. + /// </summary> + /// <param name="token">Token.</param> + /// <param name="stubType">Stub type.</param> + public StubTypeRule(string token, string stubType) + { + Token = token; + StubType = stubType; + } + + /// <summary> /// Gets or sets the token. /// </summary> /// <value>The token.</value> diff --git a/Emby.Naming/Video/VideoFileInfo.cs b/Emby.Naming/Video/VideoFileInfo.cs index 11e789b663..481773ff60 100644 --- a/Emby.Naming/Video/VideoFileInfo.cs +++ b/Emby.Naming/Video/VideoFileInfo.cs @@ -1,3 +1,4 @@ +using System; using MediaBrowser.Model.Entities; namespace Emby.Naming.Video @@ -8,6 +9,35 @@ namespace Emby.Naming.Video public class VideoFileInfo { /// <summary> + /// Initializes a new instance of the <see cref="VideoFileInfo"/> class. + /// </summary> + /// <param name="name">Name of file.</param> + /// <param name="path">Path to the file.</param> + /// <param name="container">Container type.</param> + /// <param name="year">Year of release.</param> + /// <param name="extraType">Extra type.</param> + /// <param name="extraRule">Extra rule.</param> + /// <param name="format3D">Format 3D.</param> + /// <param name="is3D">Is 3D.</param> + /// <param name="isStub">Is Stub.</param> + /// <param name="stubType">Stub type.</param> + /// <param name="isDirectory">Is directory.</param> + public VideoFileInfo(string name, string path, string? container, int? year = default, ExtraType? extraType = default, ExtraRule? extraRule = default, string? format3D = default, bool is3D = default, bool isStub = default, string? stubType = default, bool isDirectory = default) + { + Path = path; + Container = container; + Name = name; + Year = year; + ExtraType = extraType; + ExtraRule = extraRule; + Format3D = format3D; + Is3D = is3D; + IsStub = isStub; + StubType = stubType; + IsDirectory = isDirectory; + } + + /// <summary> /// Gets or sets the path. /// </summary> /// <value>The path.</value> @@ -17,7 +47,7 @@ namespace Emby.Naming.Video /// Gets or sets the container. /// </summary> /// <value>The container.</value> - public string Container { get; set; } + public string? Container { get; set; } /// <summary> /// Gets or sets the name. @@ -41,13 +71,13 @@ namespace Emby.Naming.Video /// Gets or sets the extra rule. /// </summary> /// <value>The extra rule.</value> - public ExtraRule ExtraRule { get; set; } + public ExtraRule? ExtraRule { get; set; } /// <summary> /// Gets or sets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } + public string? Format3D { get; set; } /// <summary> /// Gets or sets a value indicating whether [is3 d]. @@ -65,7 +95,7 @@ namespace Emby.Naming.Video /// Gets or sets the type of the stub. /// </summary> /// <value>The type of the stub.</value> - public string StubType { get; set; } + public string? StubType { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance is a directory. @@ -77,15 +107,14 @@ namespace Emby.Naming.Video /// Gets the file name without extension. /// </summary> /// <value>The file name without extension.</value> - public string FileNameWithoutExtension => !IsDirectory - ? System.IO.Path.GetFileNameWithoutExtension(Path) - : System.IO.Path.GetFileName(Path); + public ReadOnlySpan<char> FileNameWithoutExtension => !IsDirectory + ? System.IO.Path.GetFileNameWithoutExtension(Path.AsSpan()) + : System.IO.Path.GetFileName(Path.AsSpan()); /// <inheritdoc /> public override string ToString() { - // Makes debugging easier - return Name ?? base.ToString(); + return "VideoFileInfo(Name: '" + Name + "')"; } } } diff --git a/Emby.Naming/Video/VideoInfo.cs b/Emby.Naming/Video/VideoInfo.cs index ea74c40e2a..930fdb33f8 100644 --- a/Emby.Naming/Video/VideoInfo.cs +++ b/Emby.Naming/Video/VideoInfo.cs @@ -12,7 +12,7 @@ namespace Emby.Naming.Video /// Initializes a new instance of the <see cref="VideoInfo" /> class. /// </summary> /// <param name="name">The name.</param> - public VideoInfo(string name) + public VideoInfo(string? name) { Name = name; @@ -25,7 +25,7 @@ namespace Emby.Naming.Video /// Gets or sets the name. /// </summary> /// <value>The name.</value> - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the year. diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 948fe037b5..ed7d511a39 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.IO; @@ -11,22 +9,23 @@ using MediaBrowser.Model.IO; namespace Emby.Naming.Video { - public class VideoListResolver + /// <summary> + /// Resolves alternative versions and extras from list of video files. + /// </summary> + public static class VideoListResolver { - private readonly NamingOptions _options; - - public VideoListResolver(NamingOptions options) - { - _options = options; - } - - public IEnumerable<VideoInfo> Resolve(List<FileSystemMetadata> files, bool supportMultiVersion = true) + /// <summary> + /// Resolves alternative versions and extras from list of video files. + /// </summary> + /// <param name="files">List of related video files.</param> + /// <param name="namingOptions">The naming options.</param> + /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param> + /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns> + public static IEnumerable<VideoInfo> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions, bool supportMultiVersion = true) { - var videoResolver = new VideoResolver(_options); - var videoInfos = files - .Select(i => videoResolver.Resolve(i.FullName, i.IsDirectory)) - .Where(i => i != null) + .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions)) + .OfType<VideoFileInfo>() .ToList(); // Filter out all extras, otherwise they could cause stacks to not be resolved @@ -35,11 +34,11 @@ namespace Emby.Naming.Video .Where(i => i.ExtraType == null) .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = new StackResolver(_options) + var stackResult = new StackResolver(namingOptions) .Resolve(nonExtras).ToList(); var remainingFiles = videoInfos - .Where(i => !stackResult.Any(s => s.ContainsFile(i.Path, i.IsDirectory))) + .Where(i => !stackResult.Any(s => i.Path != null && s.ContainsFile(i.Path, i.IsDirectory))) .ToList(); var list = new List<VideoInfo>(); @@ -48,21 +47,17 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => videoResolver.Resolve(i, stack.IsDirectoryStack)).ToList() + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions)) + .OfType<VideoFileInfo>() + .ToList() }; info.Year = info.Files[0].Year; - var extraBaseNames = new List<string> { stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0]) }; - - var extras = GetExtras(remainingFiles, extraBaseNames); + var extras = ExtractExtras(remainingFiles, stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0].AsSpan()), namingOptions.VideoFlagDelimiters); if (extras.Count > 0) { - remainingFiles = remainingFiles - .Except(extras) - .ToList(); - info.Extras = extras; } @@ -75,15 +70,12 @@ namespace Emby.Naming.Video foreach (var media in standaloneMedia) { - var info = new VideoInfo(media.Name) { Files = new List<VideoFileInfo> { media } }; + var info = new VideoInfo(media.Name) { Files = new[] { media } }; info.Year = info.Files[0].Year; - var extras = GetExtras(remainingFiles, new List<string> { media.FileNameWithoutExtension }); - - remainingFiles = remainingFiles - .Except(extras.Concat(new[] { media })) - .ToList(); + remainingFiles.Remove(media); + var extras = ExtractExtras(remainingFiles, media.FileNameWithoutExtension, namingOptions.VideoFlagDelimiters); info.Extras = extras; @@ -92,8 +84,7 @@ namespace Emby.Naming.Video if (supportMultiVersion) { - list = GetVideosGroupedByVersion(list) - .ToList(); + list = GetVideosGroupedByVersion(list, namingOptions); } // If there's only one resolved video, use the folder name as well to find extras @@ -101,19 +92,14 @@ namespace Emby.Naming.Video { var info = list[0]; var videoPath = list[0].Files[0].Path; - var parentPath = Path.GetDirectoryName(videoPath); + var parentPath = Path.GetDirectoryName(videoPath.AsSpan()); - if (!string.IsNullOrEmpty(parentPath)) + if (!parentPath.IsEmpty) { var folderName = Path.GetFileName(parentPath); - if (!string.IsNullOrEmpty(folderName)) + if (!folderName.IsEmpty) { - var extras = GetExtras(remainingFiles, new List<string> { folderName }); - - remainingFiles = remainingFiles - .Except(extras) - .ToList(); - + var extras = ExtractExtras(remainingFiles, folderName, namingOptions.VideoFlagDelimiters); extras.AddRange(info.Extras); info.Extras = extras; } @@ -133,7 +119,7 @@ namespace Emby.Naming.Video } // If there's only one video, accept all trailers - // Be lenient because people use all kinds of mish mash conventions with trailers + // Be lenient because people use all kinds of mishmash conventions with trailers. if (list.Count == 1) { var trailers = remainingFiles @@ -151,86 +137,168 @@ namespace Emby.Naming.Video // Whatever files are left, just add them list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name) { - Files = new List<VideoFileInfo> { i }, + Files = new[] { i }, Year = i.Year })); return list; } - private IEnumerable<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos) + private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions) { if (videos.Count == 0) { return videos; } - var list = new List<VideoInfo>(); + var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan())); - var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path)); - - if (!string.IsNullOrEmpty(folderName) - && folderName.Length > 1 - && videos.All(i => i.Files.Count == 1 - && IsEligibleForMultiVersion(folderName, i.Files[0].Path)) - && HaveSameYear(videos)) + if (folderName.Length <= 1 || !HaveSameYear(videos)) { - var ordered = videos.OrderBy(i => i.Name).ToList(); - - list.Add(ordered[0]); + return videos; + } - var alternateVersionsLen = ordered.Count - 1; - var alternateVersions = new VideoFileInfo[alternateVersionsLen]; - for (int i = 0; i < alternateVersionsLen; i++) + // Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if] + for (var i = 0; i < videos.Count; i++) + { + var video = videos[i]; + if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions)) { - alternateVersions[i] = ordered[i + 1].Files[0]; + return videos; } + } + + // The list is created and overwritten in the caller, so we are allowed to do in-place sorting + videos.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.Ordinal)); - list[0].AlternateVersions = alternateVersions; - list[0].Name = folderName; - var extras = ordered.Skip(1).SelectMany(i => i.Extras).ToList(); - extras.AddRange(list[0].Extras); - list[0].Extras = extras; + var list = new List<VideoInfo> + { + videos[0] + }; - return list; + var alternateVersionsLen = videos.Count - 1; + var alternateVersions = new VideoFileInfo[alternateVersionsLen]; + var extras = new List<VideoFileInfo>(list[0].Extras); + for (int i = 0; i < alternateVersionsLen; i++) + { + var video = videos[i + 1]; + alternateVersions[i] = video.Files[0]; + extras.AddRange(video.Extras); } - return videos; + list[0].AlternateVersions = alternateVersions; + list[0].Name = folderName.ToString(); + list[0].Extras = extras; + + return list; } - private bool HaveSameYear(List<VideoInfo> videos) + private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos) { - return videos.Select(i => i.Year ?? -1).Distinct().Count() < 2; + if (videos.Count == 1) + { + return true; + } + + var firstYear = videos[0].Year ?? -1; + for (var i = 1; i < videos.Count; i++) + { + if ((videos[i].Year ?? -1) != firstYear) + { + return false; + } + } + + return true; } - private bool IsEligibleForMultiVersion(string folderName, string testFilename) + private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, string testFilePath, NamingOptions namingOptions) { - testFilename = Path.GetFileNameWithoutExtension(testFilename) ?? string.Empty; + var testFilename = Path.GetFileNameWithoutExtension(testFilePath.AsSpan()); + if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } - if (testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) + // Remove the folder name before cleaning as we don't care about cleaning that part + if (folderName.Length <= testFilename.Length) { - testFilename = testFilename.Substring(folderName.Length).Trim(); - return string.IsNullOrEmpty(testFilename) + testFilename = testFilename[folderName.Length..].Trim(); + } + + // There are no span overloads for regex unfortunately + var tmpTestFilename = testFilename.ToString(); + if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName)) + { + tmpTestFilename = cleanName.Trim().ToString(); + } + + // The CleanStringParser should have removed common keywords etc. + return string.IsNullOrEmpty(tmpTestFilename) || testFilename[0] == '-' - || string.IsNullOrWhiteSpace(Regex.Replace(testFilename, @"\[([^]]*)\]", string.Empty)); + || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); + } + + private static ReadOnlySpan<char> TrimFilenameDelimiters(ReadOnlySpan<char> name, ReadOnlySpan<char> videoFlagDelimiters) + { + return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd(); + } + + private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName, ReadOnlySpan<char> trimmedBaseName) + { + if (baseName.IsEmpty) + { + return false; } - return false; + return fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase) + || (!trimmedBaseName.IsEmpty && fileName.StartsWith(trimmedBaseName, StringComparison.OrdinalIgnoreCase)); + } + + /// <summary> + /// Finds similar filenames to that of [baseName] and removes any matches from [remainingFiles]. + /// </summary> + /// <param name="remainingFiles">The list of remaining filenames.</param> + /// <param name="baseName">The base name to use for the comparison.</param> + /// <param name="videoFlagDelimiters">The video flag delimiters.</param> + /// <returns>A list of video extras for [baseName].</returns> + private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> baseName, ReadOnlySpan<char> videoFlagDelimiters) + { + return ExtractExtras(remainingFiles, baseName, ReadOnlySpan<char>.Empty, videoFlagDelimiters); } - private List<VideoFileInfo> GetExtras(IEnumerable<VideoFileInfo> remainingFiles, List<string> baseNames) + /// <summary> + /// Finds similar filenames to that of [firstBaseName] and [secondBaseName] and removes any matches from [remainingFiles]. + /// </summary> + /// <param name="remainingFiles">The list of remaining filenames.</param> + /// <param name="firstBaseName">The first base name to use for the comparison.</param> + /// <param name="secondBaseName">The second base name to use for the comparison.</param> + /// <param name="videoFlagDelimiters">The video flag delimiters.</param> + /// <returns>A list of video extras for [firstBaseName] and [secondBaseName].</returns> + private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> firstBaseName, ReadOnlySpan<char> secondBaseName, ReadOnlySpan<char> videoFlagDelimiters) { - foreach (var name in baseNames.ToList()) + var trimmedFirstBaseName = TrimFilenameDelimiters(firstBaseName, videoFlagDelimiters); + var trimmedSecondBaseName = TrimFilenameDelimiters(secondBaseName, videoFlagDelimiters); + + var result = new List<VideoFileInfo>(); + for (var pos = remainingFiles.Count - 1; pos >= 0; pos--) { - var trimmedName = name.TrimEnd().TrimEnd(_options.VideoFlagDelimiters).TrimEnd(); - baseNames.Add(trimmedName); + var file = remainingFiles[pos]; + if (file.ExtraType == null) + { + continue; + } + + var filename = file.FileNameWithoutExtension; + if (StartsWith(filename, firstBaseName, trimmedFirstBaseName) + || StartsWith(filename, secondBaseName, trimmedSecondBaseName)) + { + result.Add(file); + remainingFiles.RemoveAt(pos); + } } - return remainingFiles - .Where(i => i.ExtraType != null) - .Where(i => baseNames.Any(b => - i.FileNameWithoutExtension.StartsWith(b, StringComparison.OrdinalIgnoreCase))) - .ToList(); + return result; } } } diff --git a/Emby.Naming/Video/VideoResolver.cs b/Emby.Naming/Video/VideoResolver.cs index b4aee614b0..3b1d906c64 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -1,40 +1,36 @@ -#pragma warning disable CS1591 -#nullable enable - using System; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Video { - public class VideoResolver + /// <summary> + /// Resolves <see cref="VideoFileInfo"/> from file path. + /// </summary> + public static class VideoResolver { - private readonly NamingOptions _options; - - public VideoResolver(NamingOptions options) - { - _options = options; - } - /// <summary> /// Resolves the directory. /// </summary> /// <param name="path">The path.</param> + /// <param name="namingOptions">The naming options.</param> /// <returns>VideoFileInfo.</returns> - public VideoFileInfo? ResolveDirectory(string path) + public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions) { - return Resolve(path, true); + return Resolve(path, true, namingOptions); } /// <summary> /// Resolves the file. /// </summary> /// <param name="path">The path.</param> + /// <param name="namingOptions">The naming options.</param> /// <returns>VideoFileInfo.</returns> - public VideoFileInfo? ResolveFile(string path) + public static VideoFileInfo? ResolveFile(string? path, NamingOptions namingOptions) { - return Resolve(path, false); + return Resolve(path, false, namingOptions); } /// <summary> @@ -42,29 +38,30 @@ namespace Emby.Naming.Video /// </summary> /// <param name="path">The path.</param> /// <param name="isDirectory">if set to <c>true</c> [is folder].</param> + /// <param name="namingOptions">The naming options.</param> /// <param name="parseName">Whether or not the name should be parsed for info.</param> /// <returns>VideoFileInfo.</returns> /// <exception cref="ArgumentNullException"><c>path</c> is <c>null</c>.</exception> - public VideoFileInfo? Resolve(string path, bool isDirectory, bool parseName = true) + public static VideoFileInfo? Resolve(string? path, bool isDirectory, NamingOptions namingOptions, bool parseName = true) { if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException(nameof(path)); + return null; } bool isStub = false; - string? container = null; + ReadOnlySpan<char> container = ReadOnlySpan<char>.Empty; string? stubType = null; if (!isDirectory) { - var extension = Path.GetExtension(path); + var extension = Path.GetExtension(path.AsSpan()); // Check supported extensions - if (!_options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + if (!namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { // It's not supported. Check stub extensions - if (!StubResolver.TryResolveFile(path, _options, out stubType)) + if (!StubResolver.TryResolveFile(path, namingOptions, out stubType)) { return null; } @@ -75,66 +72,86 @@ namespace Emby.Naming.Video container = extension.TrimStart('.'); } - var flags = new FlagParser(_options).GetFlags(path); - var format3DResult = new Format3DParser(_options).Parse(flags); + var format3DResult = Format3DParser.Parse(path, namingOptions); - var extraResult = new ExtraResolver(_options).GetExtraInfo(path); + var extraResult = new ExtraResolver(namingOptions).GetExtraInfo(path); - var name = isDirectory - ? Path.GetFileName(path) - : Path.GetFileNameWithoutExtension(path); + var name = Path.GetFileNameWithoutExtension(path); int? year = null; if (parseName) { - var cleanDateTimeResult = CleanDateTime(name); + var cleanDateTimeResult = CleanDateTime(name, namingOptions); name = cleanDateTimeResult.Name; year = cleanDateTimeResult.Year; if (extraResult.ExtraType == null - && TryCleanString(name, out ReadOnlySpan<char> newName)) + && TryCleanString(name, namingOptions, out ReadOnlySpan<char> newName)) { name = newName.ToString(); } } - return new VideoFileInfo - { - Path = path, - Container = container, - IsStub = isStub, - Name = name, - Year = year, - StubType = stubType, - Is3D = format3DResult.Is3D, - Format3D = format3DResult.Format3D, - ExtraType = extraResult.ExtraType, - IsDirectory = isDirectory, - ExtraRule = extraResult.Rule - }; + return new VideoFileInfo( + path: path, + container: container.IsEmpty ? null : container.ToString(), + isStub: isStub, + name: name, + year: year, + stubType: stubType, + is3D: format3DResult.Is3D, + format3D: format3DResult.Format3D, + extraType: extraResult.ExtraType, + isDirectory: isDirectory, + extraRule: extraResult.Rule); } - public bool IsVideoFile(string path) + /// <summary> + /// Determines if path is video file based on extension. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="namingOptions">The naming options.</param> + /// <returns>True if is video file.</returns> + public static bool IsVideoFile(string path, NamingOptions namingOptions) { - var extension = Path.GetExtension(path) ?? string.Empty; - return _options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(path.AsSpan()); + return namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); } - public bool IsStubFile(string path) + /// <summary> + /// Determines if path is video file stub based on extension. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="namingOptions">The naming options.</param> + /// <returns>True if is video file stub.</returns> + public static bool IsStubFile(string path, NamingOptions namingOptions) { - var extension = Path.GetExtension(path) ?? string.Empty; - return _options.StubFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(path.AsSpan()); + return namingOptions.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); } - public bool TryCleanString(string name, out ReadOnlySpan<char> newName) + /// <summary> + /// Tries to clean name of clutter. + /// </summary> + /// <param name="name">Raw name.</param> + /// <param name="namingOptions">The naming options.</param> + /// <param name="newName">Clean name.</param> + /// <returns>True if cleaning of name was successful.</returns> + public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out ReadOnlySpan<char> newName) { - return CleanStringParser.TryClean(name, _options.CleanStringRegexes, out newName); + return CleanStringParser.TryClean(name, namingOptions.CleanStringRegexes, out newName); } - public CleanDateTimeResult CleanDateTime(string name) + /// <summary> + /// Tries to get name and year from raw name. + /// </summary> + /// <param name="name">Raw name.</param> + /// <param name="namingOptions">The naming options.</param> + /// <returns>Returns <see cref="CleanDateTimeResult"/> with name and optional year.</returns> + public static CleanDateTimeResult CleanDateTime(string name, NamingOptions namingOptions) { - return CleanDateTimeParser.Clean(name, _options.CleanDateTimeRegexes); + return CleanDateTimeParser.Clean(name, namingOptions.CleanDateTimeRegexes); } } } |
