Validate that we can parse the chosen scheme before saving

This commit is contained in:
Mark McDowall 2013-11-15 00:53:12 -08:00
parent 48ece3d367
commit 9d5c1aa0a4
8 changed files with 189 additions and 22 deletions

View File

@ -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<NamingConfigResource>());
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<NamingConfig>());
var nameSpec = resource.InjectTo<NamingConfig>();
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<Episode> { episode1 }, episodeFile))
{
throw new ValidationException(new List<ValidationFailure>
{
new ValidationFailure("StandardEpisodeFormat", "Results in unparsable filenames")
}.ToArray());
}
if (!ValidateStandardFormat(nameSpec, series, new List<Episode> { episode1, episode2 }, episodeFile))
{
throw new ValidationException(new List<ValidationFailure>
{
new ValidationFailure("StandardEpisodeFormat", "Results in unparsable multi-episode filenames")
}.ToArray());
}
if (!ValidateDailyFormat(nameSpec, series, episode1, episodeFile))
{
throw new ValidationException(new List<ValidationFailure>
{
new ValidationFailure("DailyEpisodeFormat", "Results in unparsable filenames")
}.ToArray());
}
}
private bool ValidateStandardFormat(NamingConfig nameSpec, Core.Tv.Series series, List<Episode> 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> { 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> {episode}, parsedEpisodeInfo);
}
private bool ValidateSeasonAndEpisodeNumbers(List<Episode> 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;
}
}
}

View File

@ -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);

View File

@ -327,6 +327,7 @@
<Compile Include="Organizer\EpisodeFormat.cs" />
<Compile Include="Organizer\Exception.cs" />
<Compile Include="Organizer\FilenameBuilderTokenEqualityComparer.cs" />
<Compile Include="Organizer\FileNameValidation.cs" />
<Compile Include="Organizer\NamingConfigService.cs" />
<Compile Include="Parser\InvalidDateException.cs" />
<Compile Include="Parser\Model\SeriesTitleInfo.cs" />

View File

@ -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>\{season(?:\:0+)?})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?<separator>(?<=}).+?)?(?<seasonEpisode>s?{season(?:\:0+)?}(?<episodeSeparator>e|x)?(?<episode>{episode(?:\:0+)?}))(?<separator>.+?(?={))?",
public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?<separator>(?<=}).+?)?(?<seasonEpisode>s?{season(?:\:0+)?}(?<episodeSeparator>e|x)?(?<episode>{episode(?:\:0+)?}))(?<separator>.+?(?={))?",
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();
}

View File

@ -0,0 +1,43 @@
using System;
using FluentValidation;
using FluentValidation.Validators;
namespace NzbDrone.Core.Organizer
{
public static class FileNameValidation
{
public static IRuleBuilderOptions<T, string> ValidEpisodeFormat<T>(this IRuleBuilder<T, string> ruleBuilder)
{
ruleBuilder.SetValidator(new NotEmptyValidator(null));
return ruleBuilder.SetValidator(new RegularExpressionValidator(FileNameBuilder.SeasonEpisodePatternRegex)).WithMessage("Must contain season and episode numbers");
}
public static IRuleBuilderOptions<T, string> ValidDailyEpisodeFormat<T>(this IRuleBuilder<T, string> 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;
}
}
}

View File

@ -18,7 +18,7 @@ namespace NzbDrone.Core.Parser
private static readonly Regex[] ReportTitleRegex = new[]
{
//Episodes with airdate
new Regex(@"^(?<title>.+?)?\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;
}

View File

@ -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) {

View File

@ -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;
});