diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js
index 6d15e1a4f..da465d435 100644
--- a/frontend/src/Settings/MediaManagement/Naming/Naming.js
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js
@@ -74,6 +74,15 @@ class Naming extends Component {
} = this.state;
const renameBooks = hasSettings && settings.renameBooks.value;
+ const replaceIllegalCharacters = hasSettings && settings.replaceIllegalCharacters.value;
+
+ const colonReplacementOptions = [
+ { key: 0, value: translate('Delete') },
+ { key: 1, value: translate('ReplaceWithDash') },
+ { key: 2, value: translate('ReplaceWithSpaceDash') },
+ { key: 3, value: translate('ReplaceWithSpaceDashSpace') },
+ { key: 4, value: translate('SmartReplace'), hint: translate('DashOrSpaceDashDependingOnName') }
+ ];
const standardBookFormatHelpTexts = [];
const standardBookFormatErrors = [];
@@ -145,6 +154,24 @@ class Naming extends Component {
/>
+ {
+ replaceIllegalCharacters ?
+
+
+ {translate('ColonReplacement')}
+
+
+
+ :
+ null
+ }
+
{
renameBooks &&
diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ColonReplacementFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ColonReplacementFixture.cs
new file mode 100644
index 000000000..0f4bb6908
--- /dev/null
+++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ColonReplacementFixture.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FizzWare.NBuilder;
+using FluentAssertions;
+using NUnit.Framework;
+using NzbDrone.Core.Books;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Organizer;
+using NzbDrone.Core.Qualities;
+using NzbDrone.Core.Test.Framework;
+
+namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
+{
+ [TestFixture]
+ public class ColonReplacementFixture : CoreTest
+ {
+ private Author _author;
+ private Book _book;
+ private Edition _edition;
+ private BookFile _bookFile;
+ private NamingConfig _namingConfig;
+
+ [SetUp]
+ public void Setup()
+ {
+ _author = Builder
+ .CreateNew()
+ .With(s => s.Name = "Christopher Hopper")
+ .Build();
+
+ var series = Builder
+ .CreateNew()
+ .With(x => x.Title = "Series: Ruins of the Earth")
+ .Build();
+
+ var seriesLink = Builder
+ .CreateListOfSize(1)
+ .All()
+ .With(s => s.Position = "1-2")
+ .With(s => s.Series = series)
+ .BuildListOfNew();
+
+ _book = Builder
+ .CreateNew()
+ .With(s => s.Title = "Fake: Phantom Deadfall")
+ .With(s => s.AuthorMetadata = _author.Metadata.Value)
+ .With(s => s.ReleaseDate = new DateTime(2021, 2, 14))
+ .With(s => s.SeriesLinks = seriesLink)
+ .Build();
+
+ _edition = Builder
+ .CreateNew()
+ .With(s => s.Monitored = true)
+ .With(s => s.Book = _book)
+ .With(s => s.Title = _book.Title)
+ .With(s => s.ReleaseDate = new DateTime(2021, 2, 17))
+ .Build();
+
+ _bookFile = new BookFile { Quality = new QualityModel(Quality.EPUB), ReleaseGroup = "ReadarrTest" };
+
+ _namingConfig = NamingConfig.Default;
+ _namingConfig.RenameBooks = true;
+
+ Mocker.GetMock()
+ .Setup(c => c.GetConfig()).Returns(_namingConfig);
+
+ Mocker.GetMock()
+ .Setup(v => v.Get(Moq.It.IsAny()))
+ .Returns(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
+
+ Mocker.GetMock()
+ .Setup(v => v.All())
+ .Returns(new List());
+ }
+
+ [Test]
+ public void should_replace_colon_followed_by_space_with_space_dash_space_by_default()
+ {
+ _namingConfig.StandardBookFormat = "{Author Name} - {Book SeriesTitle - }{Book Title} {(Release Year)}";
+
+ Subject.BuildBookFileName(_author, _edition, _bookFile)
+ .Should().Be("Christopher Hopper - Series - Ruins of the Earth #1-2 - Fake - Phantom Deadfall (2021)");
+ }
+
+ [TestCase("Fake: Phantom Deadfall", ColonReplacementFormat.Smart, "Christopher Hopper - Series - Ruins of the Earth - Fake - Phantom Deadfall (2021)")]
+ [TestCase("Fake: Phantom Deadfall", ColonReplacementFormat.Dash, "Christopher Hopper - Series- Ruins of the Earth - Fake- Phantom Deadfall (2021)")]
+ [TestCase("Fake: Phantom Deadfall", ColonReplacementFormat.Delete, "Christopher Hopper - Series Ruins of the Earth - Fake Phantom Deadfall (2021)")]
+ [TestCase("Fake: Phantom Deadfall", ColonReplacementFormat.SpaceDash, "Christopher Hopper - Series - Ruins of the Earth - Fake - Phantom Deadfall (2021)")]
+ [TestCase("Fake: Phantom Deadfall", ColonReplacementFormat.SpaceDashSpace, "Christopher Hopper - Series - Ruins of the Earth - Fake - Phantom Deadfall (2021)")]
+ public void should_replace_colon_followed_by_space_with_expected_result(string bookTitle, ColonReplacementFormat replacementFormat, string expected)
+ {
+ _book.Title = bookTitle;
+ _namingConfig.StandardBookFormat = "{Author Name} - {Book Series - }{Book Title} {(Release Year)}";
+ _namingConfig.ColonReplacementFormat = replacementFormat;
+
+ Subject.BuildBookFileName(_author, _edition, _bookFile)
+ .Should().Be(expected);
+ }
+
+ [TestCase("Author:Name", ColonReplacementFormat.Smart, "Author-Name")]
+ [TestCase("Author:Name", ColonReplacementFormat.Dash, "Author-Name")]
+ [TestCase("Author:Name", ColonReplacementFormat.Delete, "AuthorName")]
+ [TestCase("Author:Name", ColonReplacementFormat.SpaceDash, "Author -Name")]
+ [TestCase("Author:Name", ColonReplacementFormat.SpaceDashSpace, "Author - Name")]
+ public void should_replace_colon_with_expected_result(string authorName, ColonReplacementFormat replacementFormat, string expected)
+ {
+ _author.Name = authorName;
+ _namingConfig.StandardBookFormat = "{Author Name}";
+ _namingConfig.ColonReplacementFormat = replacementFormat;
+
+ Subject.BuildBookFileName(_author, _edition, _bookFile)
+ .Should().Be(expected);
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Datastore/Migration/031_add_colon_replacement_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/031_add_colon_replacement_to_naming_config.cs
new file mode 100644
index 000000000..f36bff588
--- /dev/null
+++ b/src/NzbDrone.Core/Datastore/Migration/031_add_colon_replacement_to_naming_config.cs
@@ -0,0 +1,14 @@
+using FluentMigrator;
+using NzbDrone.Core.Datastore.Migration.Framework;
+
+namespace NzbDrone.Core.Datastore.Migration
+{
+ [Migration(031)]
+ public class add_colon_replacement_to_naming_config : NzbDroneMigrationBase
+ {
+ protected override void MainDbUpgrade()
+ {
+ Alter.Table("NamingConfig").AddColumn("ColonReplacementFormat").AsInt32().WithDefaultValue(4);
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 216b0b0aa..52b20dfa7 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -128,6 +128,7 @@
"Close": "Close",
"CollapseMultipleBooks": "Collapse Multiple Books",
"CollapseMultipleBooksHelpText": "Collapse multiple books releasing on the same day",
+ "ColonReplacement": "Colon Replacement",
"Columns": "Columns",
"CompletedDownloadHandling": "Completed Download Handling",
"Component": "Component",
@@ -158,6 +159,7 @@
"CutoffHelpText": "Once this quality is reached Readarr will no longer download books",
"CutoffUnmet": "Cutoff Unmet",
"DBMigration": "DB Migration",
+ "DashOrSpaceDashDependingOnName": "Dash or Space Dash depending on name",
"DataAllBooks": "Monitor all books",
"DataExistingBooks": "Monitor books that have files or have not released yet",
"DataFirstBook": "Monitor the first book. All other books will be ignored",
@@ -662,6 +664,9 @@
"Reorder": "Reorder",
"ReplaceIllegalCharacters": "Replace Illegal Characters",
"ReplaceIllegalCharactersHelpText": "Replace illegal characters. If unchecked, Readarr will remove them instead",
+ "ReplaceWithDash": "Replace with Dash",
+ "ReplaceWithSpaceDash": "Replace with Space Dash",
+ "ReplaceWithSpaceDashSpace": "Replace with Space Dash Space",
"RequiredHelpText": "This {0} condition must match for the custom format to apply. Otherwise a single {0} match is sufficient.",
"RequiredPlaceHolder": "Add new restriction",
"RescanAfterRefreshHelpText": "Rescan the author folder after refreshing the author",
@@ -766,6 +771,7 @@
"SkipRedownload": "Skip Redownload",
"SkipSecondarySeriesBooks": "Skip secondary series books",
"SkipredownloadHelpText": "Prevents Readarr from trying download alternative releases for the removed items",
+ "SmartReplace": "Smart Replace",
"SorryThatAuthorCannotBeFound": "Sorry, that author cannot be found.",
"SorryThatBookCannotBeFound": "Sorry, that book cannot be found.",
"Source": "Source",
diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs
index d94a89c92..34b6a7249 100644
--- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs
+++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs
@@ -223,24 +223,17 @@ namespace NzbDrone.Core.Organizer
return TitlePrefixRegex.Replace(title, "$2, $1$3");
}
- public static string CleanFileName(string name, bool replace = true)
+ public static string CleanFileName(string name)
{
- string result = name;
- string[] badCharacters = { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" };
- string[] goodCharacters = { "+", "+", "", "", "!", "-", "-", "", "" };
-
- for (int i = 0; i < badCharacters.Length; i++)
- {
- result = result.Replace(badCharacters[i], replace ? goodCharacters[i] : string.Empty);
- }
-
- return result.Trim();
+ return CleanFileName(name, NamingConfig.Default);
}
public static string CleanFolderName(string name)
{
name = FileNameCleanupRegex.Replace(name, match => match.Captures[0].Value[0].ToString());
- return name.Trim(' ', '.');
+ name = name.Trim(' ', '.');
+
+ return CleanFileName(name);
}
private void AddAuthorTokens(Dictionary> tokenHandlers, Author author)
@@ -420,7 +413,7 @@ namespace NzbDrone.Core.Organizer
replacementText = replacementText.Replace(" ", tokenMatch.Separator);
}
- replacementText = CleanFileName(replacementText, namingConfig.ReplaceIllegalCharacters);
+ replacementText = CleanFileName(replacementText, namingConfig);
if (!replacementText.IsNullOrWhiteSpace())
{
@@ -504,6 +497,53 @@ namespace NzbDrone.Core.Organizer
{
return Path.GetFileNameWithoutExtension(bookFile.Path);
}
+
+ private static string CleanFileName(string name, NamingConfig namingConfig)
+ {
+ var result = name;
+ string[] badCharacters = { "\\", "/", "<", ">", "?", "*", "|", "\"" };
+ string[] goodCharacters = { "+", "+", "", "", "!", "-", "", "" };
+
+ if (namingConfig.ReplaceIllegalCharacters)
+ {
+ // Smart replaces a colon followed by a space with space dash space for a better appearance
+ if (namingConfig.ColonReplacementFormat == ColonReplacementFormat.Smart)
+ {
+ result = result.Replace(": ", " - ");
+ result = result.Replace(":", "-");
+ }
+ else
+ {
+ var replacement = string.Empty;
+
+ switch (namingConfig.ColonReplacementFormat)
+ {
+ case ColonReplacementFormat.Dash:
+ replacement = "-";
+ break;
+ case ColonReplacementFormat.SpaceDash:
+ replacement = " -";
+ break;
+ case ColonReplacementFormat.SpaceDashSpace:
+ replacement = " - ";
+ break;
+ }
+
+ result = result.Replace(":", replacement);
+ }
+ }
+ else
+ {
+ result = result.Replace(":", string.Empty);
+ }
+
+ for (var i = 0; i < badCharacters.Length; i++)
+ {
+ result = result.Replace(badCharacters[i], namingConfig.ReplaceIllegalCharacters ? goodCharacters[i] : string.Empty);
+ }
+
+ return result.TrimStart(' ', '.').TrimEnd(' ');
+ }
}
internal sealed class TokenMatch
@@ -527,4 +567,13 @@ namespace NzbDrone.Core.Organizer
}
}
}
+
+ public enum ColonReplacementFormat
+ {
+ Delete = 0,
+ Dash = 1,
+ SpaceDash = 2,
+ SpaceDashSpace = 3,
+ Smart = 4
+ }
}
diff --git a/src/NzbDrone.Core/Organizer/NamingConfig.cs b/src/NzbDrone.Core/Organizer/NamingConfig.cs
index b80e47123..7415bc720 100644
--- a/src/NzbDrone.Core/Organizer/NamingConfig.cs
+++ b/src/NzbDrone.Core/Organizer/NamingConfig.cs
@@ -9,12 +9,14 @@ namespace NzbDrone.Core.Organizer
{
RenameBooks = false,
ReplaceIllegalCharacters = true,
+ ColonReplacementFormat = ColonReplacementFormat.Smart,
StandardBookFormat = "{Book Title}" + Path.DirectorySeparatorChar + "{Author Name} - {Book Title}{ (PartNumber)}",
AuthorFolderFormat = "{Author Name}",
};
public bool RenameBooks { get; set; }
public bool ReplaceIllegalCharacters { get; set; }
+ public ColonReplacementFormat ColonReplacementFormat { get; set; }
public string StandardBookFormat { get; set; }
public string AuthorFolderFormat { get; set; }
}
diff --git a/src/Readarr.Api.V1/Config/NamingConfigResource.cs b/src/Readarr.Api.V1/Config/NamingConfigResource.cs
index 8e2dc0035..e2c4297bd 100644
--- a/src/Readarr.Api.V1/Config/NamingConfigResource.cs
+++ b/src/Readarr.Api.V1/Config/NamingConfigResource.cs
@@ -6,6 +6,7 @@ namespace Readarr.Api.V1.Config
{
public bool RenameBooks { get; set; }
public bool ReplaceIllegalCharacters { get; set; }
+ public int ColonReplacementFormat { get; set; }
public string StandardBookFormat { get; set; }
public string AuthorFolderFormat { get; set; }
public bool IncludeAuthorName { get; set; }
diff --git a/src/Readarr.Api.V1/Config/NamingExampleResource.cs b/src/Readarr.Api.V1/Config/NamingExampleResource.cs
index e71e9d060..24edbf782 100644
--- a/src/Readarr.Api.V1/Config/NamingExampleResource.cs
+++ b/src/Readarr.Api.V1/Config/NamingExampleResource.cs
@@ -19,6 +19,7 @@ namespace Readarr.Api.V1.Config
RenameBooks = model.RenameBooks,
ReplaceIllegalCharacters = model.ReplaceIllegalCharacters,
+ ColonReplacementFormat = (int)model.ColonReplacementFormat,
StandardBookFormat = model.StandardBookFormat,
AuthorFolderFormat = model.AuthorFolderFormat
};
@@ -42,6 +43,7 @@ namespace Readarr.Api.V1.Config
RenameBooks = resource.RenameBooks,
ReplaceIllegalCharacters = resource.ReplaceIllegalCharacters,
+ ColonReplacementFormat = (ColonReplacementFormat)resource.ColonReplacementFormat,
StandardBookFormat = resource.StandardBookFormat,
AuthorFolderFormat = resource.AuthorFolderFormat,
};