diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 7c070315d..d4ebae3e1 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -232,6 +232,28 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) .Should().Be("South Park - S15E06 - City Sushi [HDTV-720p]"); } + + [TestCase("Some Escaped {{ String", "Some Escaped { String")] + [TestCase("Some Escaped }} String", "Some Escaped } String")] + [TestCase("Some Escaped {{Series Title}} String", "Some Escaped {Series Title} String")] + [TestCase("Some Escaped {{{Series Title}}} String", "Some Escaped {South Park} String")] + public void should_escape_token_in_format(string format, string expected) + { + _namingConfig.StandardEpisodeFormat = format; + + Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + .Should().Be(expected); + } + + [Test] + public void should_escape_token_in_title() + { + _namingConfig.StandardEpisodeFormat = "Some Unescaped {Series Title} String"; + _series.Title = "My {Quality Full} Title"; + + Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + .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 3b549af92..d7f9c3c2d 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Organizer private readonly ICached _requiresAbsoluteEpisodeNumberCache; 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); private static readonly Regex EpisodeRegex = new Regex(@"(?\{episode(?:\:0+)?})", @@ -158,7 +158,7 @@ namespace NzbDrone.Core.Organizer AddMediaInfoTokens(tokenHandlers, episodeFile); AddPreferredWords(tokenHandlers, series, episodeFile, preferredWords); - var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig).Trim(); + var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig, true).Trim(); var maxEpisodeTitleLength = 255 - GetLengthWithoutEpisodeTitle(component, namingConfig); AddEpisodeTitleTokens(tokenHandlers, episodes, maxEpisodeTitleLength); @@ -548,8 +548,8 @@ namespace NzbDrone.Core.Organizer private void AddEpisodeTitlePlaceholderTokens(Dictionary> tokenHandlers) { - tokenHandlers["{Episode Title}"] = m => m.RegexMatch.Value; - tokenHandlers["{Episode CleanTitle}"] = m => m.RegexMatch.Value; + tokenHandlers["{Episode Title}"] = m => null; + tokenHandlers["{Episode CleanTitle}"] = m => null; } private void AddEpisodeTitleTokens(Dictionary> tokenHandlers, List episodes, int maxLength) @@ -709,13 +709,23 @@ namespace NzbDrone.Core.Organizer } } - 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, @@ -733,7 +743,15 @@ namespace NzbDrone.Core.Organizer var tokenHandler = tokenHandlers.GetValueOrDefault(tokenMatch.Token, m => string.Empty); - var replacementText = tokenHandler(tokenMatch).Trim(); + var replacementText = tokenHandler(tokenMatch); + + if (replacementText == null) + { + // Preserve original token if handler returned null + return match.Value; + } + + replacementText = replacementText.Trim(); if (tokenMatch.Token.All(t => !char.IsLetter(t) || char.IsLower(t))) { @@ -756,6 +774,11 @@ namespace NzbDrone.Core.Organizer replacementText = tokenMatch.Prefix + replacementText + tokenMatch.Suffix; } + if (escape) + { + replacementText = replacementText.Replace("{", "{{").Replace("}", "}}"); + } + return replacementText; }