mirror of
https://github.com/Radarr/Radarr.git
synced 2024-09-11 12:02:35 +02:00
Validate that we can parse the chosen scheme before saving
This commit is contained in:
parent
48ece3d367
commit
9d5c1aa0a4
@ -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 @@ public NamingModule(INamingConfigService namingConfigService, IBuildFileNames bu
|
||||
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 @@ private string BuildSample(List<Episode> episodes, Core.Tv.Series series, Episod
|
||||
{
|
||||
try
|
||||
{
|
||||
//TODO: Validate the result is parsable
|
||||
return _buildFileNames.BuildFilename(episodes,
|
||||
series,
|
||||
episodeFile,
|
||||
@ -116,8 +124,118 @@ private string BuildSample(List<Episode> episodes, Core.Tv.Series series, Episod
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -84,6 +84,8 @@ public class ParserFixture : CoreTest
|
||||
[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);
|
||||
|
@ -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" />
|
||||
|
@ -17,8 +17,6 @@ public interface IBuildFileNames
|
||||
string BuildFilePath(Series series, int seasonNumber, string fileName, string extension);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public class FileNameBuilder : IBuildFileNames
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
@ -34,9 +32,11 @@ public class FileNameBuilder : IBuildFileNames
|
||||
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 @@ private string ReplaceToken(Match match, Dictionary<string, string> tokenValues)
|
||||
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();
|
||||
}
|
||||
|
43
src/NzbDrone.Core/Organizer/FileNameValidation.cs
Normal file
43
src/NzbDrone.Core/Organizer/FileNameValidation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -18,7 +18,7 @@ public static class 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 @@ public static class 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 @@ public static class 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 @@ public static class 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 @@ public static class 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 @@ public static ParsedEpisodeInfo ParseTitle(string title)
|
||||
|
||||
foreach (var regex in ReportTitleRegex)
|
||||
{
|
||||
var regexString = regex.ToString();
|
||||
var match = regex.Matches(simpleTitle);
|
||||
|
||||
if (match.Count != 0)
|
||||
@ -435,7 +436,7 @@ private static bool ValidateBeforeParsing(string title)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!title.Any(Char.IsLetterOrDigit) || (!title.Any(Char.IsPunctuation) && !title.Any(Char.IsWhiteSpace)))
|
||||
if (!title.Any(Char.IsLetterOrDigit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user