diff --git a/src/NzbDrone.Api/Config/NamingModule.cs b/src/NzbDrone.Api/Config/NamingModule.cs index 20ac48270..a6b5881aa 100644 --- a/src/NzbDrone.Api/Config/NamingModule.cs +++ b/src/NzbDrone.Api/Config/NamingModule.cs @@ -1,9 +1,13 @@ using System; using System.Collections.Generic; +using System.Linq; using FluentValidation; +using FluentValidation.Results; using Nancy.Responses; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; using Nancy.ModelBinding; @@ -29,13 +33,16 @@ namespace NzbDrone.Api.Config Get["/samples"] = x => GetExamples(this.Bind()); SharedValidator.RuleFor(c => c.MultiEpisodeStyle).InclusiveBetween(0, 3); - SharedValidator.RuleFor(c => c.StandardEpisodeFormat).NotEmpty(); - SharedValidator.RuleFor(c => c.DailyEpisodeFormat).NotEmpty(); + SharedValidator.RuleFor(c => c.StandardEpisodeFormat).ValidEpisodeFormat(); + SharedValidator.RuleFor(c => c.DailyEpisodeFormat).ValidDailyEpisodeFormat(); } private void UpdateNamingConfig(NamingConfigResource resource) { - _namingConfigService.Save(resource.InjectTo()); + var nameSpec = resource.InjectTo(); + ValidateFormatResult(nameSpec); + + _namingConfigService.Save(nameSpec); } private NamingConfigResource GetNamingConfig() @@ -108,6 +115,7 @@ namespace NzbDrone.Api.Config { try { + //TODO: Validate the result is parsable return _buildFileNames.BuildFilename(episodes, series, episodeFile, @@ -116,8 +124,118 @@ namespace NzbDrone.Api.Config catch (NamingFormatException ex) { //Catching to avoid blowing up all samples + //TODO: Use validation to report error to client + return String.Empty; } } + + private void ValidateFormatResult(NamingConfig nameSpec) + { + if (!nameSpec.RenameEpisodes) + { + return; + } + + var series = new Core.Tv.Series + { + SeriesType = SeriesTypes.Standard, + Title = "Series Title" + }; + + var episode1 = new Episode + { + SeasonNumber = 1, + EpisodeNumber = 1, + Title = "Episode Title (1)", + AirDate = "2013-10-30" + }; + + var episode2 = new Episode + { + SeasonNumber = 1, + EpisodeNumber = 2, + Title = "Episode Title (2)", + AirDate = "2013-10-30" + }; + + var episodeFile = new EpisodeFile + { + Quality = new QualityModel(Quality.HDTV720p) + }; + + if (!ValidateStandardFormat(nameSpec, series, new List { episode1 }, episodeFile)) + { + throw new ValidationException(new List + { + new ValidationFailure("StandardEpisodeFormat", "Results in unparsable filenames") + }.ToArray()); + } + + if (!ValidateStandardFormat(nameSpec, series, new List { episode1, episode2 }, episodeFile)) + { + throw new ValidationException(new List + { + new ValidationFailure("StandardEpisodeFormat", "Results in unparsable multi-episode filenames") + }.ToArray()); + } + + if (!ValidateDailyFormat(nameSpec, series, episode1, episodeFile)) + { + throw new ValidationException(new List + { + new ValidationFailure("DailyEpisodeFormat", "Results in unparsable filenames") + }.ToArray()); + } + } + + private bool ValidateStandardFormat(NamingConfig nameSpec, Core.Tv.Series series, List episodes, EpisodeFile episodeFile) + { + var filename = _buildFileNames.BuildFilename(episodes, series, episodeFile, nameSpec); + var parsedEpisodeInfo = Parser.ParseTitle(filename); + + if (parsedEpisodeInfo == null) + { + return false; + } + + return ValidateSeasonAndEpisodeNumbers(episodes, parsedEpisodeInfo); + } + + private bool ValidateDailyFormat(NamingConfig nameSpec, Core.Tv.Series series, Episode episode, EpisodeFile episodeFile) + { + series.SeriesType = SeriesTypes.Daily; + + var filename = _buildFileNames.BuildFilename(new List { episode }, series, episodeFile, nameSpec); + var parsedEpisodeInfo = Parser.ParseTitle(filename); + + if (parsedEpisodeInfo == null) + { + return false; + } + + if (parsedEpisodeInfo.IsDaily()) + { + if (!parsedEpisodeInfo.AirDate.Equals(episode.AirDate)) + { + return false; + } + + return true; + } + + return ValidateSeasonAndEpisodeNumbers(new List {episode}, parsedEpisodeInfo); + } + + private bool ValidateSeasonAndEpisodeNumbers(List episodes, ParsedEpisodeInfo parsedEpisodeInfo) + { + if (parsedEpisodeInfo.SeasonNumber != episodes.First().SeasonNumber || + !parsedEpisodeInfo.EpisodeNumbers.OrderBy(e => e).SequenceEqual(episodes.Select(e => e.EpisodeNumber).OrderBy(e => e))) + { + return false; + } + + return true; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index f9d6d1ac3..e68ac3ca9 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -84,6 +84,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("House.Hunters.International.S05E607.720p.hdtv.x264", "House.Hunters.International", 5, 607)] [TestCase("Adventure.Time.With.Finn.And.Jake.S01E20.720p.BluRay.x264-DEiMOS", "Adventure.Time.With.Finn.And.Jake", 1, 20)] [TestCase("Hostages.S01E04.2-45.PM.[HDTV-720p].mkv", "Hostages", 1, 4)] + [TestCase("S01E04", "", 1, 4)] + [TestCase("1x04", "", 1, 4)] public void ParseTitle_single(string postTitle, string title, int seasonNumber, int episodeNumber) { var result = Parser.Parser.ParseTitle(postTitle); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 7f852ed5d..a066a603a 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -327,6 +327,7 @@ + diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index e94d7b0ac..22e484a8d 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -17,8 +17,6 @@ namespace NzbDrone.Core.Organizer string BuildFilePath(Series series, int seasonNumber, string fileName, string extension); } - - public class FileNameBuilder : IBuildFileNames { private readonly IConfigService _configService; @@ -34,9 +32,11 @@ namespace NzbDrone.Core.Organizer private static readonly Regex SeasonRegex = new Regex(@"(?\{season(?:\:0+)?})", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?(?<=}).+?)?(?s?{season(?:\:0+)?}(?e|x)?(?{episode(?:\:0+)?}))(?.+?(?={))?", + public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?(?<=}).+?)?(?s?{season(?:\:0+)?}(?e|x)?(?{episode(?:\:0+)?}))(?.+?(?={))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex AirDateRegex = new Regex(@"\{Air(\s|\W|_)Date\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public FileNameBuilder(INamingConfigService namingConfigService, IConfigService configService, Logger logger) { _namingConfigService = namingConfigService; @@ -201,12 +201,12 @@ namespace NzbDrone.Core.Organizer var patternTokenArray = token.ToCharArray(); if (!tokenValues.TryGetValue(token, out replacementText)) return null; - if (patternTokenArray.All(t => !char.IsLetter(t) || char.IsLower(t))) + if (patternTokenArray.All(t => !Char.IsLetter(t) || Char.IsLower(t))) { replacementText = replacementText.ToLowerInvariant(); } - else if (patternTokenArray.All(t => !char.IsLetter(t) || char.IsUpper(t))) + else if (patternTokenArray.All(t => !Char.IsLetter(t) || Char.IsUpper(t))) { replacementText = replacementText.ToUpper(); } diff --git a/src/NzbDrone.Core/Organizer/FileNameValidation.cs b/src/NzbDrone.Core/Organizer/FileNameValidation.cs new file mode 100644 index 000000000..0eceb9564 --- /dev/null +++ b/src/NzbDrone.Core/Organizer/FileNameValidation.cs @@ -0,0 +1,43 @@ +using System; +using FluentValidation; +using FluentValidation.Validators; + +namespace NzbDrone.Core.Organizer +{ + public static class FileNameValidation + { + public static IRuleBuilderOptions ValidEpisodeFormat(this IRuleBuilder ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + return ruleBuilder.SetValidator(new RegularExpressionValidator(FileNameBuilder.SeasonEpisodePatternRegex)).WithMessage("Must contain season and episode numbers"); + } + + public static IRuleBuilderOptions ValidDailyEpisodeFormat(this IRuleBuilder ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + return ruleBuilder.SetValidator(new ValidDailyEpisodeFormatValidator()); + } + } + + public class ValidDailyEpisodeFormatValidator : PropertyValidator + { + public ValidDailyEpisodeFormatValidator() + : base("Must contain Air Date or Season and Episode") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var value = context.PropertyValue as String; + + if (!FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) && + !FileNameBuilder.AirDateRegex.IsMatch(value)) + { + return false; + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index ef62d9034..e771db42f 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex[] ReportTitleRegex = new[] { //Episodes with airdate - new Regex(@"^(?.+?)?\W*(?<airyear>\d{4})\W+(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})\W+(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Anime - Absolute Episode Number + Title + Season+Episode @@ -38,15 +38,15 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Multi-Part episodes without a title (S01E05.S01E06) - new Regex(@"^(?:\W*S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}(\W+|_|$)(?!\\)", + new Regex(@"^(?:\W*S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Multi-episode Repeated (S01E05 - S01E06, 1x05 - 1x06, etc) - new Regex(@"^(?<title>.+?)(?:(\W|_)+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)(?:(\W|_)+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes without a title, Single (S01E05, 1x05) AND Multi (S01E04E05, 1x04x05, etc) - new Regex(@"^(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+)))+)(\W+|_|$)(?!\\)", + new Regex(@"^(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+)))+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes with a title, Single episodes (S01E05, 1x05, etc) & Multi-episode (S01E05E06, S01E05-06, S01E05 E06, etc) @@ -54,7 +54,7 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes with a title, Single episodes (S01E05, 1x05, etc) & Multi-episode (S01E05E06, S01E05-06, S01E05 E06, etc) - new Regex(@"^(?<title>.+?)(?:\W+S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2,3}(?!\d+)))+)(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)(?:\W+S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2,3}(?!\d+)))+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes with single digit episode number (S01E1, S01E5E6, etc) @@ -66,11 +66,11 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Supports 103/113 naming - new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+)\d{1})(?<episode>\d{2}(?!p|i|\d+)))+(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+)\d{1})(?<episode>\d{2}(?!\w|\d+)))+", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Mini-Series, treated as season 1, episodes are labelled as Part01, Part 01, Part.1 - new Regex(@"^(?<title>.+?)(?:\W+(?:(?:Part\W?|(?<!\d+\W+)e)(?<episode>\d{1,2}(?!\d+)))+)(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)(?:\W+(?:(?:Part\W?|(?<!\d+\W+)e)(?<episode>\d{1,2}(?!\d+)))+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Supports 1103/1113 naming @@ -96,7 +96,7 @@ namespace NzbDrone.Core.Parser //Anime - Title Absolute Episode Number new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+e(?<absoluteepisode>\d{2,3}))+", - RegexOptions.IgnoreCase | RegexOptions.Compiled), + RegexOptions.IgnoreCase | RegexOptions.Compiled) }; private static readonly Regex NormalizeRegex = new Regex(@"((^|\W|_)(a|an|the|and|or|of)($|\W|_))|\W|_|(?:(?<=[^0-9]+)|\b)(?!(?:19\d{2}|20\d{2}))\d+(?=[^0-9ip]+|\b)", @@ -144,6 +144,7 @@ namespace NzbDrone.Core.Parser foreach (var regex in ReportTitleRegex) { + var regexString = regex.ToString(); var match = regex.Matches(simpleTitle); if (match.Count != 0) @@ -435,7 +436,7 @@ namespace NzbDrone.Core.Parser return false; } - if (!title.Any(Char.IsLetterOrDigit) || (!title.Any(Char.IsPunctuation) && !title.Any(Char.IsWhiteSpace))) + if (!title.Any(Char.IsLetterOrDigit)) { return false; } diff --git a/src/UI/Mixins/AsValidatedView.js b/src/UI/Mixins/AsValidatedView.js index cf4ef661e..952c1da69 100644 --- a/src/UI/Mixins/AsValidatedView.js +++ b/src/UI/Mixins/AsValidatedView.js @@ -23,7 +23,6 @@ define( } }; - var validatedSync = function (method, model,options) { this.$el.removeAllErrors(); arguments[2].isValidatedCall = true; @@ -52,7 +51,6 @@ define( } }; - this.prototype.onBeforeClose = function () { if (this.model) { diff --git a/src/UI/Settings/MediaManagement/Naming/NamingView.js b/src/UI/Settings/MediaManagement/Naming/NamingView.js index 27e5d5945..f6355e24b 100644 --- a/src/UI/Settings/MediaManagement/Naming/NamingView.js +++ b/src/UI/Settings/MediaManagement/Naming/NamingView.js @@ -5,8 +5,9 @@ define( 'vent', 'marionette', 'Settings/MediaManagement/Naming/NamingSampleModel', - 'Mixins/AsModelBoundView' - ], function ($, vent, Marionette, NamingSampleModel, AsModelBoundView) { + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView' + ], function ($, vent, Marionette, NamingSampleModel, AsModelBoundView, AsValidatedView) { var view = Marionette.ItemView.extend({ template: 'Settings/MediaManagement/Naming/NamingViewTemplate', @@ -86,5 +87,8 @@ define( } }); - return AsModelBoundView.call(view); + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; });