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