diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 31c242ae9..38f45b340 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -421,6 +421,28 @@ public void should_replace_all_contents_in_pattern() .Should().Be("Linkin Park - Hybrid Theory - [MP3]"); } + [TestCase("Some Escaped {{ String", "Some Escaped { String")] + [TestCase("Some Escaped }} String", "Some Escaped } String")] + [TestCase("Some Escaped {{Book Title}} String", "Some Escaped {Book Title} String")] + [TestCase("Some Escaped {{{Book Title}}} String", "Some Escaped {Hybrid Theory} String")] + public void should_escape_token_in_format(string format, string expected) + { + _namingConfig.StandardBookFormat = format; + + Subject.BuildBookFileName(_author, _edition, _trackFile, _namingConfig) + .Should().Be(expected); + } + + [Test] + public void should_escape_token_in_title() + { + _namingConfig.StandardBookFormat = "Some Unescaped {Book Title} String"; + _edition.Title = "My {Quality Full} Title"; + + Subject.BuildBookFileName(_author, _edition, _trackFile, _namingConfig) + .Should().Be("Some Unescaped My {Quality Full} Title String"); + } + [Test] public void use_file_name_when_sceneName_is_null() { diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 3dfcc44dc..3b82c496d 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -33,7 +33,7 @@ public class FileNameBuilder : IBuildFileNames private readonly ICached _trackFormatCache; private readonly Logger _logger; - private static readonly Regex TitleRegex = new Regex(@"\{(?[- ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[a-z0-9]+))?(?[- ._)\]]*)\}", + private static readonly Regex TitleRegex = new Regex(@"(?\{\{|\}\})|\{(?[- ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[a-z0-9]+))?(?[- ._)\]]*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static readonly Regex PartRegex = new Regex(@"\{(?[^{]*?)(?PartNumber|PartCount)(?::(?[a-z0-9]+))?(?.*(?=PartNumber|PartCount))?((?PartNumber|PartCount)(?::(?[a-z0-9]+))?)?(?[^}]*)\}", @@ -90,13 +90,6 @@ public string BuildBookFileName(Author author, Edition edition, BookFile bookFil var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); - AddAuthorTokens(tokenHandlers, author); - AddBookTokens(tokenHandlers, edition); - AddBookFileTokens(tokenHandlers, bookFile); - AddQualityTokens(tokenHandlers, author, bookFile); - AddMediaInfoTokens(tokenHandlers, bookFile); - AddCustomFormats(tokenHandlers, author, bookFile, customFormats); - var splitPatterns = pattern.Split(new char[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); var components = new List(); @@ -104,7 +97,17 @@ public string BuildBookFileName(Author author, Edition edition, BookFile bookFil { var splitPattern = s; + AddAuthorTokens(tokenHandlers, author); + AddBookPlaceholderTokens(tokenHandlers); + AddBookFileTokens(tokenHandlers, bookFile); + AddQualityTokens(tokenHandlers, author, bookFile); + AddMediaInfoTokens(tokenHandlers, bookFile); + AddCustomFormats(tokenHandlers, author, bookFile, customFormats); + var component = ReplacePartTokens(splitPattern, tokenHandlers, namingConfig).Trim(); + component = ReplaceTokens(component, tokenHandlers, namingConfig, true).Trim(); + + AddBookTokens(tokenHandlers, edition); component = ReplaceTokens(component, tokenHandlers, namingConfig).Trim(); component = FileNameCleanupRegex.Replace(component, match => match.Captures[0].Value[0].ToString()); @@ -249,6 +252,30 @@ private void AddAuthorTokens(Dictionary> tokenH } } + private void AddBookPlaceholderTokens(Dictionary> tokenHandlers) + { + tokenHandlers["{Book Title}"] = m => null; + tokenHandlers["{Book CleanTitle}"] = m => null; + tokenHandlers["{Book TitleThe}"] = m => null; + + tokenHandlers["{Book TitleNoSub}"] = m => null; + tokenHandlers["{Book CleanTitleNoSub}"] = m => null; + tokenHandlers["{Book TitleTheNoSub}"] = m => null; + + tokenHandlers["{Book Subtitle}"] = m => null; + tokenHandlers["{Book CleanSubtitle}"] = m => null; + tokenHandlers["{Book SubtitleThe}"] = m => null; + + tokenHandlers["{Book Series}"] = m => null; + tokenHandlers["{Book SeriesPosition}"] = m => null; + tokenHandlers["{Book SeriesTitle}"] = m => null; + + tokenHandlers["{Book Disambiguation}"] = m => null; + tokenHandlers["{Release Year}"] = m => null; + tokenHandlers["{Edition Year}"] = m => null; + tokenHandlers["{Release YearFirst}"] = m => null; + } + private void AddBookTokens(Dictionary> tokenHandlers, Edition edition) { tokenHandlers["{Book Title}"] = m => edition.Title; @@ -372,13 +399,29 @@ private void AddCustomFormats(Dictionary> token tokenHandlers["{Custom Formats}"] = m => string.Join(" ", customFormats.Where(x => x.IncludeCustomFormatWhenRenaming)); } - private string ReplaceTokens(string pattern, Dictionary> tokenHandlers, NamingConfig namingConfig) + private string ReplaceTokens(string pattern, Dictionary> tokenHandlers, NamingConfig namingConfig, bool escape = false) { - return TitleRegex.Replace(pattern, match => ReplaceToken(match, tokenHandlers, namingConfig)); + return TitleRegex.Replace(pattern, match => ReplaceToken(match, tokenHandlers, namingConfig, escape)); } - private string ReplaceToken(Match match, Dictionary> tokenHandlers, NamingConfig namingConfig) + private string ReplaceToken(Match match, Dictionary> tokenHandlers, NamingConfig namingConfig, bool escape) { + if (match.Groups["escaped"].Success) + { + if (escape) + { + return match.Value; + } + else if (match.Value == "{{") + { + return "{"; + } + else if (match.Value == "}}") + { + return "}"; + } + } + var tokenMatch = new TokenMatch { RegexMatch = match, @@ -396,7 +439,19 @@ private string ReplaceToken(Match match, Dictionary string.Empty); - var replacementText = tokenHandler(tokenMatch).Trim(); + var replacementText = tokenHandler(tokenMatch); + + if (replacementText == null && escape) + { + // Preserve original token if handler returned null and escape is true + return match.Value; + } + else if (replacementText == null) + { + return ""; + } + + replacementText = replacementText.Trim(); if (tokenMatch.Token.All(t => !char.IsLetter(t) || char.IsLower(t))) { @@ -419,6 +474,11 @@ private string ReplaceToken(Match match, Dictionary