From 293a1bc618ea0b7ddf7d3a5fd4c47b0e6d2d09e9 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 9 Jul 2024 22:02:04 -0700 Subject: [PATCH] New: Custom colon replacement option Closes #6898 --- .../Settings/MediaManagement/Naming/Naming.js | 19 +++++++++- .../ColonReplacementFixture.cs | 13 +++++++ ...stom_colon_replacement_to_naming_config.cs | 14 ++++++++ src/NzbDrone.Core/Localization/Core/en.json | 3 ++ .../Organizer/FileNameBuilder.cs | 16 +++++---- .../Organizer/FileNameValidation.cs | 36 +++++++++++++++++++ src/NzbDrone.Core/Organizer/NamingConfig.cs | 2 ++ .../Config/NamingConfigController.cs | 1 + .../Config/NamingConfigResource.cs | 1 + .../Config/NamingExampleResource.cs | 2 ++ 10 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/211_add_custom_colon_replacement_to_naming_config.cs diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js index a4a1d4b09..8d188551f 100644 --- a/frontend/src/Settings/MediaManagement/Naming/Naming.js +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -138,7 +138,8 @@ class Naming extends Component { { key: 1, value: translate('ReplaceWithDash') }, { key: 2, value: translate('ReplaceWithSpaceDash') }, { key: 3, value: translate('ReplaceWithSpaceDashSpace') }, - { key: 4, value: translate('SmartReplace'), hint: translate('SmartReplaceHint') } + { key: 4, value: translate('SmartReplace'), hint: translate('SmartReplaceHint') }, + { key: 5, value: translate('Custom'), hint: translate('CustomColonReplacementFormatHint') } ]; const standardEpisodeFormatHelpTexts = []; @@ -262,6 +263,22 @@ class Naming extends Component { null } + { + replaceIllegalCharacters && settings.colonReplacementFormat.value === 5 ? + + {translate('ColonReplacement')} + + + : + null + } + { renameEpisodes &&
diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ColonReplacementFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ColonReplacementFixture.cs index ae0ae7297..18b4aa9f1 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ColonReplacementFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ColonReplacementFixture.cs @@ -90,5 +90,18 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) .Should().Be(expected); } + + [TestCase("Series: Title", ColonReplacementFormat.Custom, "\ua789", "Series\ua789 Title")] + [TestCase("Series: Title", ColonReplacementFormat.Custom, "∶", "Series∶ Title")] + public void should_replace_colon_with_custom_format(string seriesName, ColonReplacementFormat replacementFormat, string customFormat, string expected) + { + _series.Title = seriesName; + _namingConfig.StandardEpisodeFormat = "{Series Title}"; + _namingConfig.ColonReplacementFormat = replacementFormat; + _namingConfig.CustomColonReplacementFormat = customFormat; + + Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + .Should().Be(expected); + } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/211_add_custom_colon_replacement_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/211_add_custom_colon_replacement_to_naming_config.cs new file mode 100644 index 000000000..537a70ea9 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/211_add_custom_colon_replacement_to_naming_config.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(211)] + public class add_custom_colon_replacement_to_naming_config : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("NamingConfig").AddColumn("CustomColonReplacementFormat").AsString().WithDefaultValue(""); + } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 36e3c35c1..76d905d4e 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -270,6 +270,9 @@ "CreateGroup": "Create Group", "CurrentlyInstalled": "Currently Installed", "Custom": "Custom", + "CustomColonReplacement": "Custom Colon Replacement", + "CustomColonReplacementFormatHelpText": "Characters to be used as a replacement for colons", + "CustomColonReplacementFormatHint": "Valid file system character such as Colon (Letter)", "CustomFilter": "Custom Filter", "CustomFilters": "Custom Filters", "CustomFormat": "Custom Format", diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 714605a99..3755f17f7 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -6,7 +6,6 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using Diacritical; -using DryIoc.ImTools; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; @@ -112,6 +111,9 @@ namespace NzbDrone.Core.Organizer { "wel", "cym" } }.ToImmutableDictionary(); + public static readonly ImmutableArray BadCharacters = ImmutableArray.Create("\\", "/", "<", ">", "?", "*", "|", "\""); + public static readonly ImmutableArray GoodCharacters = ImmutableArray.Create("+", "+", "", "", "!", "-", "", ""); + public FileNameBuilder(INamingConfigService namingConfigService, IQualityDefinitionService qualityDefinitionService, ICacheManager cacheManager, @@ -1156,8 +1158,6 @@ namespace NzbDrone.Core.Organizer private static string CleanFileName(string name, NamingConfig namingConfig) { var result = name; - string[] badCharacters = { "\\", "/", "<", ">", "?", "*", "|", "\"" }; - string[] goodCharacters = { "+", "+", "", "", "!", "-", "", "" }; if (namingConfig.ReplaceIllegalCharacters) { @@ -1182,6 +1182,9 @@ namespace NzbDrone.Core.Organizer case ColonReplacementFormat.SpaceDashSpace: replacement = " - "; break; + case ColonReplacementFormat.Custom: + replacement = namingConfig.CustomColonReplacementFormat; + break; } result = result.Replace(":", replacement); @@ -1192,9 +1195,9 @@ namespace NzbDrone.Core.Organizer result = result.Replace(":", string.Empty); } - for (var i = 0; i < badCharacters.Length; i++) + for (var i = 0; i < BadCharacters.Length; i++) { - result = result.Replace(badCharacters[i], namingConfig.ReplaceIllegalCharacters ? goodCharacters[i] : string.Empty); + result = result.Replace(BadCharacters[i], namingConfig.ReplaceIllegalCharacters ? GoodCharacters[i] : string.Empty); } return result.TrimStart(' ', '.').TrimEnd(' '); @@ -1268,6 +1271,7 @@ namespace NzbDrone.Core.Organizer Dash = 1, SpaceDash = 2, SpaceDashSpace = 3, - Smart = 4 + Smart = 4, + Custom = 5 } } diff --git a/src/NzbDrone.Core/Organizer/FileNameValidation.cs b/src/NzbDrone.Core/Organizer/FileNameValidation.cs index e8d39469f..b36f9426c 100644 --- a/src/NzbDrone.Core/Organizer/FileNameValidation.cs +++ b/src/NzbDrone.Core/Organizer/FileNameValidation.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -61,6 +62,13 @@ namespace NzbDrone.Core.Organizer return ruleBuilder.SetValidator(new IllegalCharactersValidator()); } + + public static IRuleBuilderOptions ValidCustomColonReplacement(this IRuleBuilder ruleBuilder) + { + ruleBuilder.SetValidator(new IllegalColonCharactersValidator()); + + return ruleBuilder.SetValidator(new IllegalCharactersValidator()); + } } public class ValidStandardEpisodeFormatValidator : PropertyValidator @@ -132,6 +140,34 @@ namespace NzbDrone.Core.Organizer } var invalidCharacters = InvalidPathChars.Where(i => value!.IndexOf(i) >= 0).ToList(); + + if (invalidCharacters.Any()) + { + context.MessageFormatter.AppendArgument("InvalidCharacters", string.Join("", invalidCharacters)); + return false; + } + + return true; + } + } + + public class IllegalColonCharactersValidator : PropertyValidator + { + private static readonly string[] InvalidPathChars = FileNameBuilder.BadCharacters.Concat(new[] { ":" }).ToArray(); + + protected override string GetDefaultMessageTemplate() => "Contains illegal characters: {InvalidCharacters}"; + + protected override bool IsValid(PropertyValidatorContext context) + { + var value = context.PropertyValue as string; + + if (value.IsNullOrWhiteSpace()) + { + return true; + } + + var invalidCharacters = InvalidPathChars.Where(i => value!.IndexOf(i, StringComparison.Ordinal) >= 0).ToList(); + if (invalidCharacters.Any()) { context.MessageFormatter.AppendArgument("InvalidCharacters", string.Join("", invalidCharacters)); diff --git a/src/NzbDrone.Core/Organizer/NamingConfig.cs b/src/NzbDrone.Core/Organizer/NamingConfig.cs index 57d88d405..eb3f65c84 100644 --- a/src/NzbDrone.Core/Organizer/NamingConfig.cs +++ b/src/NzbDrone.Core/Organizer/NamingConfig.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.Organizer RenameEpisodes = false, ReplaceIllegalCharacters = true, ColonReplacementFormat = ColonReplacementFormat.Smart, + CustomColonReplacementFormat = string.Empty, MultiEpisodeStyle = MultiEpisodeStyle.PrefixedRange, StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}", DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}", @@ -21,6 +22,7 @@ namespace NzbDrone.Core.Organizer public bool RenameEpisodes { get; set; } public bool ReplaceIllegalCharacters { get; set; } public ColonReplacementFormat ColonReplacementFormat { get; set; } + public string CustomColonReplacementFormat { get; set; } public MultiEpisodeStyle MultiEpisodeStyle { get; set; } public string StandardEpisodeFormat { get; set; } public string DailyEpisodeFormat { get; set; } diff --git a/src/Sonarr.Api.V3/Config/NamingConfigController.cs b/src/Sonarr.Api.V3/Config/NamingConfigController.cs index a05894f45..f8870f35c 100644 --- a/src/Sonarr.Api.V3/Config/NamingConfigController.cs +++ b/src/Sonarr.Api.V3/Config/NamingConfigController.cs @@ -36,6 +36,7 @@ namespace Sonarr.Api.V3.Config SharedValidator.RuleFor(c => c.SeriesFolderFormat).ValidSeriesFolderFormat(); SharedValidator.RuleFor(c => c.SeasonFolderFormat).ValidSeasonFolderFormat(); SharedValidator.RuleFor(c => c.SpecialsFolderFormat).ValidSpecialsFolderFormat(); + SharedValidator.RuleFor(c => c.CustomColonReplacementFormat).ValidCustomColonReplacement().When(c => c.ColonReplacementFormat == (int)ColonReplacementFormat.Custom); } protected override NamingConfigResource GetResourceById(int id) diff --git a/src/Sonarr.Api.V3/Config/NamingConfigResource.cs b/src/Sonarr.Api.V3/Config/NamingConfigResource.cs index f1acc9fe3..e3354fb38 100644 --- a/src/Sonarr.Api.V3/Config/NamingConfigResource.cs +++ b/src/Sonarr.Api.V3/Config/NamingConfigResource.cs @@ -7,6 +7,7 @@ namespace Sonarr.Api.V3.Config public bool RenameEpisodes { get; set; } public bool ReplaceIllegalCharacters { get; set; } public int ColonReplacementFormat { get; set; } + public string CustomColonReplacementFormat { get; set; } public int MultiEpisodeStyle { get; set; } public string StandardEpisodeFormat { get; set; } public string DailyEpisodeFormat { get; set; } diff --git a/src/Sonarr.Api.V3/Config/NamingExampleResource.cs b/src/Sonarr.Api.V3/Config/NamingExampleResource.cs index 6784e1a4b..ed1bfbd92 100644 --- a/src/Sonarr.Api.V3/Config/NamingExampleResource.cs +++ b/src/Sonarr.Api.V3/Config/NamingExampleResource.cs @@ -25,6 +25,7 @@ namespace Sonarr.Api.V3.Config RenameEpisodes = model.RenameEpisodes, ReplaceIllegalCharacters = model.ReplaceIllegalCharacters, ColonReplacementFormat = (int)model.ColonReplacementFormat, + CustomColonReplacementFormat = model.CustomColonReplacementFormat, MultiEpisodeStyle = (int)model.MultiEpisodeStyle, StandardEpisodeFormat = model.StandardEpisodeFormat, DailyEpisodeFormat = model.DailyEpisodeFormat, @@ -45,6 +46,7 @@ namespace Sonarr.Api.V3.Config ReplaceIllegalCharacters = resource.ReplaceIllegalCharacters, MultiEpisodeStyle = (MultiEpisodeStyle)resource.MultiEpisodeStyle, ColonReplacementFormat = (ColonReplacementFormat)resource.ColonReplacementFormat, + CustomColonReplacementFormat = resource.CustomColonReplacementFormat, StandardEpisodeFormat = resource.StandardEpisodeFormat, DailyEpisodeFormat = resource.DailyEpisodeFormat, AnimeEpisodeFormat = resource.AnimeEpisodeFormat,