diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js index 1cd6c16c7..5f7ccf19a 100644 --- a/frontend/src/Settings/MediaManagement/Naming/Naming.js +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -120,6 +120,7 @@ class Naming extends Component { } = this.state; const renameEpisodes = hasSettings && settings.renameEpisodes.value; + const replaceIllegalCharacters = hasSettings && settings.replaceIllegalCharacters.value; const multiEpisodeStyleOptions = [ { key: 0, value: 'Extend', hint: 'S01E01-02-03' }, @@ -130,6 +131,14 @@ class Naming extends Component { { key: 5, value: 'Prefixed Range', hint: 'S01E01-E03' } ]; + const colonReplacementOptions = [ + { key: 0, value: 'Delete' }, + { key: 1, value: 'Replace with Dash' }, + { key: 2, value: 'Replace with Space Dash' }, + { key: 3, value: 'Replace with Space Dash Space' }, + { key: 4, value: 'Smart Replace', hint: 'Dash or Space Dash depending on name' } + ]; + const standardEpisodeFormatHelpTexts = []; const standardEpisodeFormatErrors = []; const dailyEpisodeFormatHelpTexts = []; @@ -232,6 +241,22 @@ class Naming extends Component { /> + { + replaceIllegalCharacters ? + + Colon Replacement + + + : + null + } + { renameEpisodes &&
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..6afc72fe2 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/ColonReplacementFixture.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + public class ColonReplacementFixture : CoreTest + { + private Series _series; + private Episode _episode1; + private EpisodeFile _episodeFile; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _series = Builder + .CreateNew() + .With(s => s.Title = "CSI: Vegas") + .Build(); + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameEpisodes = true; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(_namingConfig); + + _episode1 = Builder.CreateNew() + .With(e => e.Title = "What Happens in Vegas") + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeNumber = 6) + .With(e => e.AbsoluteEpisodeNumber = 100) + .Build(); + + _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" }; + + 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.StandardEpisodeFormat = "{Series Title}"; + + Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + .Should().Be("CSI - Vegas"); + } + + [TestCase("CSI: Vegas", ColonReplacementFormat.Smart, "CSI - Vegas")] + [TestCase("CSI: Vegas", ColonReplacementFormat.Dash, "CSI- Vegas")] + [TestCase("CSI: Vegas", ColonReplacementFormat.Delete, "CSI Vegas")] + [TestCase("CSI: Vegas", ColonReplacementFormat.SpaceDash, "CSI - Vegas")] + [TestCase("CSI: Vegas", ColonReplacementFormat.SpaceDashSpace, "CSI - Vegas")] + public void should_replace_colon_followed_by_space_with_expected_result(string seriesName, ColonReplacementFormat replacementFormat, string expected) + { + _series.Title = seriesName; + _namingConfig.StandardEpisodeFormat = "{Series Title}"; + _namingConfig.ColonReplacementFormat = replacementFormat; + + Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + .Should().Be(expected); + } + + [TestCase("Series:Title", ColonReplacementFormat.Smart, "Series-Title")] + [TestCase("Series:Title", ColonReplacementFormat.Dash, "Series-Title")] + [TestCase("Series:Title", ColonReplacementFormat.Delete, "SeriesTitle")] + [TestCase("Series:Title", ColonReplacementFormat.SpaceDash, "Series -Title")] + [TestCase("Series:Title", ColonReplacementFormat.SpaceDashSpace, "Series - Title")] + public void should_replace_colon_with_expected_result(string seriesName, ColonReplacementFormat replacementFormat, string expected) + { + _series.Title = seriesName; + _namingConfig.StandardEpisodeFormat = "{Series Title}"; + _namingConfig.ColonReplacementFormat = replacementFormat; + + Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + .Should().Be(expected); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/189_add_colon_replacement_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/189_add_colon_replacement_to_naming_config.cs new file mode 100644 index 000000000..e669e4f36 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/189_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(189)] + 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/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 9b4d96a84..364296348 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -377,30 +377,17 @@ namespace NzbDrone.Core.Organizer return title; } - public static string CleanFileName(string name, bool replace = true) + public static string CleanFileName(string name) { - string result = name; - string[] badCharacters = { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" }; - string[] goodCharacters = { "+", "+", "", "", "!", "-", "-", "", "" }; - - // Replace a colon followed by a space with space dash space for a better appearance - if (replace) - { - result = result.Replace(": ", " - "); - } - - for (int i = 0; i < badCharacters.Length; i++) - { - result = result.Replace(badCharacters[i], replace ? goodCharacters[i] : string.Empty); - } - - return result.TrimStart(' ', '.').TrimEnd(' '); + 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); } public bool RequiresEpisodeTitle(Series series, List episodes) @@ -867,7 +854,7 @@ namespace NzbDrone.Core.Organizer replacementText = replacementText.Replace(" ", tokenMatch.Separator); } - replacementText = CleanFileName(replacementText, namingConfig.ReplaceIllegalCharacters); + replacementText = CleanFileName(replacementText, namingConfig); if (!replacementText.IsNullOrWhiteSpace()) { @@ -1117,6 +1104,53 @@ namespace NzbDrone.Core.Organizer // Replace reserved windows device names with an alternative return ReservedDeviceNamesRegex.Replace(input, match => match.Value.Replace(".", "_")); } + + 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 @@ -1150,4 +1184,13 @@ namespace NzbDrone.Core.Organizer Range = 4, PrefixedRange = 5 } + + 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 50f5d254c..57d88d405 100644 --- a/src/NzbDrone.Core/Organizer/NamingConfig.cs +++ b/src/NzbDrone.Core/Organizer/NamingConfig.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.Organizer { RenameEpisodes = false, ReplaceIllegalCharacters = true, + ColonReplacementFormat = ColonReplacementFormat.Smart, 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}", @@ -19,6 +20,7 @@ namespace NzbDrone.Core.Organizer public bool RenameEpisodes { get; set; } public bool ReplaceIllegalCharacters { get; set; } + public ColonReplacementFormat ColonReplacementFormat { 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/NamingConfigResource.cs b/src/Sonarr.Api.V3/Config/NamingConfigResource.cs index 8b95058a7..4e4116807 100644 --- a/src/Sonarr.Api.V3/Config/NamingConfigResource.cs +++ b/src/Sonarr.Api.V3/Config/NamingConfigResource.cs @@ -1,4 +1,4 @@ -using Sonarr.Http.REST; +using Sonarr.Http.REST; namespace Sonarr.Api.V3.Config { @@ -6,6 +6,7 @@ namespace Sonarr.Api.V3.Config { public bool RenameEpisodes { get; set; } public bool ReplaceIllegalCharacters { get; set; } + public int ColonReplacementFormat { 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 103609d5d..66e21aff8 100644 --- a/src/Sonarr.Api.V3/Config/NamingExampleResource.cs +++ b/src/Sonarr.Api.V3/Config/NamingExampleResource.cs @@ -24,6 +24,7 @@ namespace Sonarr.Api.V3.Config RenameEpisodes = model.RenameEpisodes, ReplaceIllegalCharacters = model.ReplaceIllegalCharacters, + ColonReplacementFormat = (int)model.ColonReplacementFormat, MultiEpisodeStyle = (int)model.MultiEpisodeStyle, StandardEpisodeFormat = model.StandardEpisodeFormat, DailyEpisodeFormat = model.DailyEpisodeFormat, @@ -60,6 +61,7 @@ namespace Sonarr.Api.V3.Config RenameEpisodes = resource.RenameEpisodes, ReplaceIllegalCharacters = resource.ReplaceIllegalCharacters, MultiEpisodeStyle = (MultiEpisodeStyle)resource.MultiEpisodeStyle, + ColonReplacementFormat = (ColonReplacementFormat)resource.ColonReplacementFormat, StandardEpisodeFormat = resource.StandardEpisodeFormat, DailyEpisodeFormat = resource.DailyEpisodeFormat, AnimeEpisodeFormat = resource.AnimeEpisodeFormat,