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, };