diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js index d38d86e0a..863eb78dc 100644 --- a/frontend/src/Settings/MediaManagement/Naming/Naming.js +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -85,6 +85,16 @@ class Naming extends Component { }); } + onSpecialsFolderNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'specialsFolderFormat', + season: true + } + }); + } + onNamingModalClose = () => { this.setState({ isNamingModalOpen: false }); } @@ -130,6 +140,8 @@ class Naming extends Component { const seriesFolderFormatErrors = []; const seasonFolderFormatHelpTexts = []; const seasonFolderFormatErrors = []; + const specialsFolderFormatHelpTexts = []; + const specialsFolderFormatErrors = []; if (examplesPopulated) { if (examples.singleEpisodeExample) { @@ -173,6 +185,12 @@ class Naming extends Component { } else { seasonFolderFormatErrors.push({ message: 'Invalid Format' }); } + + if (examples.specialsFolderExample) { + specialsFolderFormatHelpTexts.push(`Example: ${examples.specialsFolderExample}`); + } else { + specialsFolderFormatErrors.push({ message: 'Invalid Format' }); + } } return ( @@ -297,6 +315,24 @@ class Naming extends Component { /> + + Specials Folder Format + + ?} + onChange={onInputChange} + {...settings.specialsFolderFormat} + helpTexts={specialsFolderFormatHelpTexts} + errors={[...specialsFolderFormatErrors, ...settings.specialsFolderFormat.errors]} + /> + + Multi-Episode Style diff --git a/src/NzbDrone.Api/Config/NamingConfigModule.cs b/src/NzbDrone.Api/Config/NamingConfigModule.cs index f7dc2bf19..39883086c 100644 --- a/src/NzbDrone.Api/Config/NamingConfigModule.cs +++ b/src/NzbDrone.Api/Config/NamingConfigModule.cs @@ -41,6 +41,7 @@ namespace NzbDrone.Api.Config SharedValidator.RuleFor(c => c.AnimeEpisodeFormat).ValidAnimeEpisodeFormat(); SharedValidator.RuleFor(c => c.SeriesFolderFormat).ValidSeriesFolderFormat(); SharedValidator.RuleFor(c => c.SeasonFolderFormat).ValidSeasonFolderFormat(); + SharedValidator.RuleFor(c => c.SpecialsFolderFormat).ValidSpecialsFolderFormat(); } private void UpdateNamingConfig(NamingConfigResource resource) @@ -109,6 +110,10 @@ namespace NzbDrone.Api.Config ? "Invalid format" : _filenameSampleService.GetSeasonFolderSample(nameSpec); + sampleResource.SpecialsFolderExample = nameSpec.SpecialsFolderFormat.IsNullOrWhiteSpace() + ? "Invalid format" + : _filenameSampleService.GetSpecialsFolderSample(nameSpec); + return sampleResource.AsResponse(); } diff --git a/src/NzbDrone.Api/Config/NamingConfigResource.cs b/src/NzbDrone.Api/Config/NamingConfigResource.cs index cfc6d507a..1a59a79d9 100644 --- a/src/NzbDrone.Api/Config/NamingConfigResource.cs +++ b/src/NzbDrone.Api/Config/NamingConfigResource.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Api.Config public string AnimeEpisodeFormat { get; set; } public string SeriesFolderFormat { get; set; } public string SeasonFolderFormat { get; set; } + public string SpecialsFolderFormat { get; set; } public bool IncludeSeriesTitle { get; set; } public bool IncludeEpisodeTitle { get; set; } public bool IncludeQuality { get; set; } @@ -36,7 +37,8 @@ namespace NzbDrone.Api.Config DailyEpisodeFormat = model.DailyEpisodeFormat, AnimeEpisodeFormat = model.AnimeEpisodeFormat, SeriesFolderFormat = model.SeriesFolderFormat, - SeasonFolderFormat = model.SeasonFolderFormat + SeasonFolderFormat = model.SeasonFolderFormat, + SpecialsFolderFormat = model.SpecialsFolderFormat //IncludeSeriesTitle //IncludeEpisodeTitle //IncludeQuality @@ -69,7 +71,8 @@ namespace NzbDrone.Api.Config DailyEpisodeFormat = resource.DailyEpisodeFormat, AnimeEpisodeFormat = resource.AnimeEpisodeFormat, SeriesFolderFormat = resource.SeriesFolderFormat, - SeasonFolderFormat = resource.SeasonFolderFormat + SeasonFolderFormat = resource.SeasonFolderFormat, + SpecialsFolderFormat = resource.SpecialsFolderFormat }; } } diff --git a/src/NzbDrone.Api/Config/NamingSampleResource.cs b/src/NzbDrone.Api/Config/NamingSampleResource.cs index 1f9c7f066..630dde156 100644 --- a/src/NzbDrone.Api/Config/NamingSampleResource.cs +++ b/src/NzbDrone.Api/Config/NamingSampleResource.cs @@ -9,5 +9,6 @@ public string AnimeMultiEpisodeExample { get; set; } public string SeriesFolderExample { get; set; } public string SeasonFolderExample { get; set; } + public string SpecialsFolderExample { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs index c3dcb9c42..20b73bcb8 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs @@ -29,7 +29,7 @@ namespace NzbDrone.Core.Test.OrganizerTests [TestCase("30 Rock - S01E05 - Episode Title", 1, false, "Season {season:00}", @"C:\Test\30 Rock\30 Rock - S01E05 - Episode Title.mkv")] [TestCase("30 Rock - S01E05 - Episode Title", 1, false, "Season {season}", @"C:\Test\30 Rock\30 Rock - S01E05 - Episode Title.mkv")] [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "ReallyUglySeasonFolder {season}", @"C:\Test\30 Rock\ReallyUglySeasonFolder 1\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S00E05 - Episode Title", 0, true, "Season {season}", @"C:\Test\30 Rock\Specials\30 Rock - S00E05 - Episode Title.mkv")] + [TestCase("30 Rock - S00E05 - Episode Title", 0, true, "Season {season}", @"C:\Test\30 Rock\MySpecials\30 Rock - S00E05 - Episode Title.mkv")] public void CalculateFilePath_SeasonFolder_SingleNumber(string filename, int seasonNumber, bool useSeasonFolder, string seasonFolderFormat, string expectedPath) { var fakeSeries = Builder.CreateNew() @@ -39,6 +39,7 @@ namespace NzbDrone.Core.Test.OrganizerTests .Build(); namingConfig.SeasonFolderFormat = seasonFolderFormat; + namingConfig.SpecialsFolderFormat = "MySpecials"; Subject.BuildFilePath(fakeSeries, seasonNumber, filename, ".mkv").Should().Be(expectedPath.AsOsAgnostic()); } diff --git a/src/NzbDrone.Core/Datastore/Migration/134_add_specials_folder_format.cs b/src/NzbDrone.Core/Datastore/Migration/134_add_specials_folder_format.cs new file mode 100644 index 000000000..8977065e6 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/134_add_specials_folder_format.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Data; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(134)] + public class add_specials_folder_format : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("NamingConfig").AddColumn("SpecialsFolderFormat").AsString().Nullable(); + Execute.WithConnection(ConvertConfig); + } + + private void ConvertConfig(IDbConnection conn, IDbTransaction tran) + { + var defaultFormat = "Specials"; + + using (IDbCommand updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "UPDATE NamingConfig SET SpecialsFolderFormat = ?"; + updateCmd.AddParameter(defaultFormat); + updateCmd.ExecuteNonQuery(); + } + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index c968e0449..b71e5e1f1 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -136,6 +136,7 @@ + diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 3331c8ca0..ca8edeec2 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -174,18 +174,11 @@ namespace NzbDrone.Core.Organizer if (series.SeasonFolder) { - if (seasonNumber == 0) - { - path = Path.Combine(path, "Specials"); - } - else - { - var seasonFolder = GetSeasonFolder(series, seasonNumber); + var seasonFolder = GetSeasonFolder(series, seasonNumber); - seasonFolder = CleanFileName(seasonFolder); + seasonFolder = CleanFileName(seasonFolder); - path = Path.Combine(path, seasonFolder); - } + path = Path.Combine(path, seasonFolder); } return path; @@ -266,7 +259,9 @@ namespace NzbDrone.Core.Organizer AddIdTokens(tokenHandlers, series); AddSeasonTokens(tokenHandlers, seasonNumber); - var folderName = ReplaceTokens(namingConfig.SeasonFolderFormat, tokenHandlers, namingConfig); + var format = seasonNumber == 0 ? namingConfig.SpecialsFolderFormat : namingConfig.SeasonFolderFormat; + + var folderName = ReplaceTokens(format, tokenHandlers, namingConfig); return CleanFolderName(folderName); } diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index 06014fc91..9f1909a76 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Core.Organizer SampleResult GetAnimeMultiEpisodeSample(NamingConfig nameSpec); string GetSeriesFolderSample(NamingConfig nameSpec); string GetSeasonFolderSample(NamingConfig nameSpec); + string GetSpecialsFolderSample(NamingConfig nameSpec); } public class FileNameSampleService : IFilenameSampleService @@ -245,6 +246,11 @@ namespace NzbDrone.Core.Organizer return _buildFileNames.GetSeasonFolder(_standardSeries, _episode1.SeasonNumber, nameSpec); } + public string GetSpecialsFolderSample(NamingConfig nameSpec) + { + return _buildFileNames.GetSeasonFolder(_standardSeries, 0, nameSpec); + } + private string BuildSample(List episodes, Series series, EpisodeFile episodeFile, NamingConfig nameSpec) { try diff --git a/src/NzbDrone.Core/Organizer/FileNameValidation.cs b/src/NzbDrone.Core/Organizer/FileNameValidation.cs index 930b8a044..5366f709f 100644 --- a/src/NzbDrone.Core/Organizer/FileNameValidation.cs +++ b/src/NzbDrone.Core/Organizer/FileNameValidation.cs @@ -41,6 +41,11 @@ namespace NzbDrone.Core.Organizer ruleBuilder.SetValidator(new NotEmptyValidator(null)); return ruleBuilder.SetValidator(new RegularExpressionValidator(SeasonFolderRegex)).WithMessage("Must contain season number"); } + + public static IRuleBuilderOptions ValidSpecialsFolderFormat(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.SetValidator(new NotEmptyValidator(null)); + } } public class ValidStandardEpisodeFormatValidator : PropertyValidator diff --git a/src/NzbDrone.Core/Organizer/NamingConfig.cs b/src/NzbDrone.Core/Organizer/NamingConfig.cs index 5de62a090..d1c4da6e1 100644 --- a/src/NzbDrone.Core/Organizer/NamingConfig.cs +++ b/src/NzbDrone.Core/Organizer/NamingConfig.cs @@ -13,7 +13,8 @@ namespace NzbDrone.Core.Organizer DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}", AnimeEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}", SeriesFolderFormat = "{Series Title}", - SeasonFolderFormat = "Season {season}" + SeasonFolderFormat = "Season {season}", + SpecialsFolderFormat = "Specials" }; public bool RenameEpisodes { get; set; } @@ -24,5 +25,6 @@ namespace NzbDrone.Core.Organizer public string AnimeEpisodeFormat { get; set; } public string SeriesFolderFormat { get; set; } public string SeasonFolderFormat { get; set; } + public string SpecialsFolderFormat { get; set; } } } diff --git a/src/Sonarr.Api.V3/Config/NamingConfigModule.cs b/src/Sonarr.Api.V3/Config/NamingConfigModule.cs index 7bafcf6c6..e4b7fd1af 100644 --- a/src/Sonarr.Api.V3/Config/NamingConfigModule.cs +++ b/src/Sonarr.Api.V3/Config/NamingConfigModule.cs @@ -41,6 +41,7 @@ namespace Sonarr.Api.V3.Config SharedValidator.RuleFor(c => c.AnimeEpisodeFormat).ValidAnimeEpisodeFormat(); SharedValidator.RuleFor(c => c.SeriesFolderFormat).ValidSeriesFolderFormat(); SharedValidator.RuleFor(c => c.SeasonFolderFormat).ValidSeasonFolderFormat(); + SharedValidator.RuleFor(c => c.SpecialsFolderFormat).ValidSpecialsFolderFormat(); } private void UpdateNamingConfig(NamingConfigResource resource) @@ -114,6 +115,10 @@ namespace Sonarr.Api.V3.Config ? null : _filenameSampleService.GetSeasonFolderSample(nameSpec); + sampleResource.SpecialsFolderExample = nameSpec.SpecialsFolderFormat.IsNullOrWhiteSpace() + ? null + : _filenameSampleService.GetSpecialsFolderSample(nameSpec); + return sampleResource.AsResponse(); } diff --git a/src/Sonarr.Api.V3/Config/NamingConfigResource.cs b/src/Sonarr.Api.V3/Config/NamingConfigResource.cs index b5e1eb251..4a78ffe7b 100644 --- a/src/Sonarr.Api.V3/Config/NamingConfigResource.cs +++ b/src/Sonarr.Api.V3/Config/NamingConfigResource.cs @@ -12,6 +12,7 @@ namespace Sonarr.Api.V3.Config public string AnimeEpisodeFormat { get; set; } public string SeriesFolderFormat { get; set; } public string SeasonFolderFormat { get; set; } + public string SpecialsFolderFormat { get; set; } public bool IncludeSeriesTitle { get; set; } public bool IncludeEpisodeTitle { get; set; } public bool IncludeQuality { get; set; } diff --git a/src/Sonarr.Api.V3/Config/NamingExampleResource.cs b/src/Sonarr.Api.V3/Config/NamingExampleResource.cs index 167dc6b99..02f7c472e 100644 --- a/src/Sonarr.Api.V3/Config/NamingExampleResource.cs +++ b/src/Sonarr.Api.V3/Config/NamingExampleResource.cs @@ -11,6 +11,7 @@ namespace Sonarr.Api.V3.Config public string AnimeMultiEpisodeExample { get; set; } public string SeriesFolderExample { get; set; } public string SeasonFolderExample { get; set; } + public string SpecialsFolderExample { get; set; } } public static class NamingConfigResourceMapper @@ -28,7 +29,8 @@ namespace Sonarr.Api.V3.Config DailyEpisodeFormat = model.DailyEpisodeFormat, AnimeEpisodeFormat = model.AnimeEpisodeFormat, SeriesFolderFormat = model.SeriesFolderFormat, - SeasonFolderFormat = model.SeasonFolderFormat + SeasonFolderFormat = model.SeasonFolderFormat, + SpecialsFolderFormat = model.SpecialsFolderFormat //IncludeSeriesTitle //IncludeEpisodeTitle //IncludeQuality @@ -61,7 +63,8 @@ namespace Sonarr.Api.V3.Config DailyEpisodeFormat = resource.DailyEpisodeFormat, AnimeEpisodeFormat = resource.AnimeEpisodeFormat, SeriesFolderFormat = resource.SeriesFolderFormat, - SeasonFolderFormat = resource.SeasonFolderFormat + SeasonFolderFormat = resource.SeasonFolderFormat, + SpecialsFolderFormat = resource.SpecialsFolderFormat }; } }