diff --git a/src/NzbDrone.Api/Config/NamingConfigResource.cs b/src/NzbDrone.Api/Config/NamingConfigResource.cs index af22a3f11..9ca779dac 100644 --- a/src/NzbDrone.Api/Config/NamingConfigResource.cs +++ b/src/NzbDrone.Api/Config/NamingConfigResource.cs @@ -5,13 +5,17 @@ namespace NzbDrone.Api.Config { public class NamingConfigResource : RestResource { - public Boolean IncludeEpisodeTitle { get; set; } - public Boolean ReplaceSpaces { get; set; } public Boolean RenameEpisodes { get; set; } public Int32 MultiEpisodeStyle { get; set; } - public Int32 NumberStyle { get; set; } - public String Separator { get; set; } - public Boolean IncludeQuality { get; set; } - public Boolean IncludeSeriesTitle { get; set; } + public string StandardEpisodeFormat { get; set; } + public string DailyEpisodeFormat { get; set; } + public string SeasonFolderFormat { get; set; } + + public bool IncludeSeriesTitle { get; set; } + public bool IncludeEpisodeTitle { get; set; } + public bool IncludeQuality { get; set; } + public bool ReplaceSpaces { get; set; } + public string Separator { get; set; } + public string NumberStyle { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/NamingModule.cs b/src/NzbDrone.Api/Config/NamingModule.cs index 726a268b5..1f0b39fac 100644 --- a/src/NzbDrone.Api/Config/NamingModule.cs +++ b/src/NzbDrone.Api/Config/NamingModule.cs @@ -1,26 +1,35 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using FluentValidation; +using FluentValidation.Results; using Nancy.Responses; -using NzbDrone.Core.MediaFiles; +using NzbDrone.Api.REST; using NzbDrone.Core.Organizer; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; using Nancy.ModelBinding; using NzbDrone.Api.Mapping; using NzbDrone.Api.Extensions; +using Omu.ValueInjecter; namespace NzbDrone.Api.Config { public class NamingModule : NzbDroneRestModule { private readonly INamingConfigService _namingConfigService; - private readonly IBuildFileNames _buildFileNames; + private readonly IFilenameSampleService _filenameSampleService; + private readonly IFilenameValidationService _filenameValidationService; + private readonly IBuildFileNames _filenameBuilder; - public NamingModule(INamingConfigService namingConfigService, IBuildFileNames buildFileNames) + public NamingModule(INamingConfigService namingConfigService, + IFilenameSampleService filenameSampleService, + IFilenameValidationService filenameValidationService, + IBuildFileNames filenameBuilder) : base("config/naming") { _namingConfigService = namingConfigService; - _buildFileNames = buildFileNames; + _filenameSampleService = filenameSampleService; + _filenameValidationService = filenameValidationService; + _filenameBuilder = filenameBuilder; GetResourceSingle = GetNamingConfig; GetResourceById = GetNamingConfig; UpdateResource = UpdateNamingConfig; @@ -28,18 +37,32 @@ namespace NzbDrone.Api.Config Get["/samples"] = x => GetExamples(this.Bind()); SharedValidator.RuleFor(c => c.MultiEpisodeStyle).InclusiveBetween(0, 3); - SharedValidator.RuleFor(c => c.NumberStyle).InclusiveBetween(0, 3); - SharedValidator.RuleFor(c => c.Separator).Matches(@"\s|\s\-\s|\."); + SharedValidator.RuleFor(c => c.StandardEpisodeFormat).ValidEpisodeFormat(); + SharedValidator.RuleFor(c => c.DailyEpisodeFormat).ValidDailyEpisodeFormat(); } private void UpdateNamingConfig(NamingConfigResource resource) { - _namingConfigService.Save(resource.InjectTo()); + var nameSpec = resource.InjectTo(); + ValidateFormatResult(nameSpec); + + _namingConfigService.Save(nameSpec); } private NamingConfigResource GetNamingConfig() { - return _namingConfigService.GetConfig().InjectTo(); + var nameSpec = _namingConfigService.GetConfig(); + var resource = nameSpec.InjectTo(); + + if (String.IsNullOrWhiteSpace(resource.StandardEpisodeFormat)) + { + return resource; + } + + var basicConfig = _filenameBuilder.GetBasicNamingConfig(nameSpec); + resource.InjectFrom(basicConfig); + + return resource; } private NamingConfigResource GetNamingConfig(int id) @@ -49,49 +72,59 @@ namespace NzbDrone.Api.Config private JsonResponse GetExamples(NamingConfigResource config) { + //TODO: Validate that the format is valid var nameSpec = config.InjectTo(); - - var series = new Core.Tv.Series - { - SeriesType = SeriesTypes.Standard, - Title = "Series Title" - }; - - var episode1 = new Episode - { - SeasonNumber = 1, - EpisodeNumber = 1, - Title = "Episode Title (1)" - }; - - var episode2 = new Episode - { - SeasonNumber = 1, - EpisodeNumber = 2, - Title = "Episode Title (2)" - }; - - var episodeFile = new EpisodeFile - { - Quality = new QualityModel(Quality.HDTV720p), - Path = @"C:\Test\Series.Title.S01E01.720p.HDTV.x264-EVOLVE.mkv" - }; - var sampleResource = new NamingSampleResource(); + + var singleEpisodeSampleResult = _filenameSampleService.GetStandardSample(nameSpec); + var multiEpisodeSampleResult = _filenameSampleService.GetMultiEpisodeSample(nameSpec); + var dailyEpisodeSampleResult = _filenameSampleService.GetDailySample(nameSpec); - sampleResource.SingleEpisodeExample = _buildFileNames.BuildFilename(new List { episode1 }, - series, - episodeFile, - nameSpec); + sampleResource.SingleEpisodeExample = _filenameValidationService.ValidateStandardFilename(singleEpisodeSampleResult) != null + ? "Invalid format" + : singleEpisodeSampleResult.Filename; - episodeFile.Path = @"C:\Test\Series.Title.S01E01-E02.720p.HDTV.x264-EVOLVE.mkv"; + sampleResource.MultiEpisodeExample = _filenameValidationService.ValidateStandardFilename(multiEpisodeSampleResult) != null + ? "Invalid format" + : multiEpisodeSampleResult.Filename; - sampleResource.MultiEpisodeExample = _buildFileNames.BuildFilename(new List { episode1, episode2 }, - series, - episodeFile, - nameSpec); + sampleResource.DailyEpisodeExample = _filenameValidationService.ValidateDailyFilename(dailyEpisodeSampleResult) != null + ? "Invalid format" + : dailyEpisodeSampleResult.Filename; return sampleResource.AsResponse(); } + + private void ValidateFormatResult(NamingConfig nameSpec) + { + var singleEpisodeSampleResult = _filenameSampleService.GetStandardSample(nameSpec); + var multiEpisodeSampleResult = _filenameSampleService.GetMultiEpisodeSample(nameSpec); + var dailyEpisodeSampleResult = _filenameSampleService.GetDailySample(nameSpec); + var singleEpisodeValidationResult = _filenameValidationService.ValidateStandardFilename(singleEpisodeSampleResult); + var multiEpisodeValidationResult = _filenameValidationService.ValidateStandardFilename(multiEpisodeSampleResult); + var dailyEpisodeValidationResult = _filenameValidationService.ValidateDailyFilename(dailyEpisodeSampleResult); + + var validationFailures = new List(); + + if (singleEpisodeValidationResult != null) + { + validationFailures.Add(singleEpisodeValidationResult); + } + + if (multiEpisodeValidationResult != null) + { + validationFailures.Add(multiEpisodeValidationResult); + } + + if (dailyEpisodeValidationResult != null) + { + validationFailures.Add(dailyEpisodeValidationResult); + } + + if (validationFailures.Any()) + { + throw new ValidationException(validationFailures.ToArray()); + } + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Config/NamingSampleResource.cs b/src/NzbDrone.Api/Config/NamingSampleResource.cs index 60fba0b3c..7f6d0e99e 100644 --- a/src/NzbDrone.Api/Config/NamingSampleResource.cs +++ b/src/NzbDrone.Api/Config/NamingSampleResource.cs @@ -4,5 +4,6 @@ { public string SingleEpisodeExample { get; set; } public string MultiEpisodeExample { get; set; } + public string DailyEpisodeExample { 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 628bca46f..11866ea54 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs @@ -20,18 +20,17 @@ namespace NzbDrone.Core.Test.OrganizerTests { namingConfig = new NamingConfig(); - Mocker.GetMock() .Setup(c => c.GetConfig()).Returns(namingConfig); } [Test] - [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "Season %0s", @"C:\Test\30 Rock\Season 01\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "Season %s", @"C:\Test\30 Rock\Season 1\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S01E05 - Episode Title", 1, false, "Season %0s", @"C:\Test\30 Rock\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S01E05 - Episode Title", 1, false, "Season %s", @"C:\Test\30 Rock\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "ReallyUglySeasonFolder %s", @"C:\Test\30 Rock\ReallyUglySeasonFolder 1\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S00E05 - Episode Title", 0, true, "Season %s", @"C:\Test\30 Rock\Specials\30 Rock - S00E05 - Episode Title.mkv")] + [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "Season {season:00}", @"C:\Test\30 Rock\Season 01\30 Rock - S01E05 - Episode Title.mkv")] + [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "Season {season}", @"C:\Test\30 Rock\Season 1\30 Rock - S01E05 - Episode Title.mkv")] + [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")] public void CalculateFilePath_SeasonFolder_SingleNumber(string filename, int seasonNumber, bool useSeasonFolder, string seasonFolderFormat, string expectedPath) { var fakeSeries = Builder.CreateNew() @@ -40,7 +39,7 @@ namespace NzbDrone.Core.Test.OrganizerTests .With(s => s.SeasonFolder = useSeasonFolder) .Build(); - Mocker.GetMock().Setup(e => e.SeasonFolderFormat).Returns(seasonFolderFormat); + namingConfig.SeasonFolderFormat = seasonFolderFormat; Subject.BuildFilePath(fakeSeries, seasonNumber, filename, ".mkv").Should().Be(expectedPath.AsOsAgnostic()); } diff --git a/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs index 585fb10aa..373a4aa3c 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs @@ -16,8 +16,10 @@ namespace NzbDrone.Core.Test.OrganizerTests public class FileNameBuilderFixture : CoreTest { private Series _series; - - private NamingConfig namingConfig; + private Episode _episode1; + private Episode _episode2; + private EpisodeFile _episodeFile; + private NamingConfig _namingConfig; [SetUp] public void Setup() @@ -28,571 +30,208 @@ namespace NzbDrone.Core.Test.OrganizerTests .Build(); - namingConfig = new NamingConfig(); - namingConfig.RenameEpisodes = true; + _namingConfig = new NamingConfig(); + _namingConfig.RenameEpisodes = true; Mocker.GetMock() - .Setup(c => c.GetConfig()).Returns(namingConfig); + .Setup(c => c.GetConfig()).Returns(_namingConfig); - - } - - [Test] - public void GetNewFilename_Series_Episode_Quality_S01E05_Dash() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - - var episode = Builder.CreateNew() + _episode1 = Builder.CreateNew() .With(e => e.Title = "City Sushi") .With(e => e.SeasonNumber = 15) .With(e => e.EpisodeNumber = 6) .Build(); - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("South Park - S15E06 - City Sushi [HDTV-720p]"); - } - - [Test] - public void GetNewFilename_Episode_Quality_1x05_Dash() - { - namingConfig.IncludeSeriesTitle = false; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 0; - namingConfig.ReplaceSpaces = false; - - var episode = Builder.CreateNew() + _episode2 = Builder.CreateNew() .With(e => e.Title = "City Sushi") .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("15x06 - City Sushi [HDTV-720p]"); - } - - [Test] - public void GetNewFilename_Series_Quality_01x05_Space() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = false; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " "; - namingConfig.NumberStyle = 1; - namingConfig.ReplaceSpaces = false; - - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 5) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("South Park 05x06 [HDTV-720p]"); - } - - [Test] - public void GetNewFilename_Series_s01e05_Space() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = false; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " "; - namingConfig.NumberStyle = 3; - namingConfig.ReplaceSpaces = false; - - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 5) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("South Park s05e06"); - } - - [Test] - public void GetNewFilename_Series_Episode_s01e05_Periods() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " "; - namingConfig.NumberStyle = 3; - namingConfig.ReplaceSpaces = true; - - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 5) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("South.Park.s05e06.City.Sushi"); - } - - [Test] - public void GetNewFilename_Series_Episode_s01e05_Dash_Periods_Quality() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 3; - namingConfig.ReplaceSpaces = true; - - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 5) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("South.Park.-.s05e06.-.City.Sushi.[HDTV-720p]"); - } - - [Test] - public void GetNewFilename_S01E05_Dash() - { - namingConfig.IncludeSeriesTitle = false; - namingConfig.IncludeEpisodeTitle = false; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - - - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("S15E06"); - } - - [Test] - public void GetNewFilename_multi_Series_Episode_Quality_S01E05_Scene_Dash() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - namingConfig.MultiEpisodeStyle = 3; - - var episodeOne = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (1)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 23) - .Build(); - - var episodeTwo = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (2)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 24) - .Build(); - - - string result = Subject.BuildFilename(new List { episodeOne, episodeTwo }, new Series { Title = "The Mentalist" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("The Mentalist - S03E23-E24 - Strawberries and Cream [HDTV-720p]"); - } - - [Test] - public void GetNewFilename_multi_Episode_Quality_1x05_Repeat_Dash() - { - namingConfig.IncludeSeriesTitle = false; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 0; - namingConfig.ReplaceSpaces = false; - namingConfig.MultiEpisodeStyle = 2; - - var episodeOne = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (1)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 23) - .Build(); - - var episodeTwo = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (2)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 24) - .Build(); - - - string result = Subject.BuildFilename(new List { episodeOne, episodeTwo }, new Series { Title = "The Mentalist" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("3x23x24 - Strawberries and Cream [HDTV-720p]"); - } - - [Test] - public void GetNewFilename_multi_Episode_Quality_01x05_Repeat_Space() - { - namingConfig.IncludeSeriesTitle = false; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " "; - namingConfig.NumberStyle = 0; - namingConfig.ReplaceSpaces = false; - namingConfig.MultiEpisodeStyle = 2; - - var episodeOne = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (1)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 23) - .Build(); - - var episodeTwo = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (2)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 24) - .Build(); - - - string result = Subject.BuildFilename(new List { episodeOne, episodeTwo }, new Series { Title = "The Mentalist" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("3x23x24 Strawberries and Cream [HDTV-720p]"); - } - - [Test] - public void GetNewFilename_multi_Series_Episode_s01e05_Duplicate_Period() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " "; - namingConfig.NumberStyle = 3; - namingConfig.ReplaceSpaces = true; - namingConfig.MultiEpisodeStyle = 1; - - var episodeOne = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (1)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 23) - .Build(); - - var episodeTwo = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (2)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 24) - .Build(); - - - string result = Subject.BuildFilename(new List { episodeOne, episodeTwo }, new Series { Title = "The Mentalist" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("The.Mentalist.s03e23.s03e24.Strawberries.and.Cream"); - } - - [Test] - public void GetNewFilename_multi_Series_S01E05_Extend_Dash_Period() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = false; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = true; - namingConfig.MultiEpisodeStyle = 0; - - var episodeOne = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (1)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 23) - .Build(); - - var episodeTwo = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (2)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 24) - .Build(); - - - string result = Subject.BuildFilename(new List { episodeOne, episodeTwo }, new Series { Title = "The Mentalist" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("The.Mentalist.-.S03E23-24"); - } - - [Test] - public void GetNewFilename_multi_1x05_Repeat_Dash_Period() - { - namingConfig.IncludeSeriesTitle = false; - namingConfig.IncludeEpisodeTitle = false; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 0; - namingConfig.ReplaceSpaces = true; - namingConfig.MultiEpisodeStyle = 2; - - var episodeOne = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (1)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 23) - .Build(); - - var episodeTwo = Builder.CreateNew() - .With(e => e.Title = "Strawberries and Cream (2)") - .With(e => e.SeasonNumber = 3) - .With(e => e.EpisodeNumber = 24) - .Build(); - - - string result = Subject.BuildFilename(new List { episodeOne, episodeTwo }, new Series { Title = "The Mentalist" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("3x23x24"); - } - - [Test] - public void GetNewFilename_should_append_proper_when_proper_and_append_quality_is_true() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p, true) }); - - - result.Should().Be("South Park - S15E06 - City Sushi [HDTV-720p] [Proper]"); - } - - [Test] - public void GetNewFilename_should_not_append_proper_when_not_proper_and_append_quality_is_true() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("South Park - S15E06 - City Sushi [HDTV-720p]"); - } - - [Test] - public void GetNewFilename_should_not_append_proper_when_proper_and_append_quality_is_false() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p, true) }); - - - result.Should().Be("South Park - S15E06 - City Sushi"); - } - - [Test] - public void GetNewFilename_should_order_multiple_episode_files_in_numerical_order() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - namingConfig.MultiEpisodeStyle = 3; - - var episode = Builder.CreateNew() - .With(e => e.Title = "Hey, Baby, What's Wrong? (1)") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 6) - .Build(); - - var episode2 = Builder.CreateNew() - .With(e => e.Title = "Hey, Baby, What's Wrong? (2)") - .With(e => e.SeasonNumber = 6) .With(e => e.EpisodeNumber = 7) .Build(); + _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }; + } - string result = Subject.BuildFilename(new List { episode2, episode }, new Series { Title = "30 Rock" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("30 Rock - S06E06-E07 - Hey, Baby, What's Wrong!"); + private void GivenProper() + { + _episodeFile.Quality.Proper = true; } [Test] - public void GetNewFilename_Series_Episode_Quality_S01E05_Period() + public void should_replace_Series_space_Title() { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = "."; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; + _namingConfig.StandardEpisodeFormat = "{Series Title}"; - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("South Park.S15E06.City Sushi [HDTV-720p]"); + Subject.BuildFilename(new List {_episode1}, _series, _episodeFile) + .Should().Be("South Park"); } [Test] - public void GetNewFilename_Episode_Quality_1x05_Period() + public void should_replace_Series_underscore_Title() { - namingConfig.IncludeSeriesTitle = false; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = "."; ; - namingConfig.NumberStyle = 0; - namingConfig.ReplaceSpaces = false; + _namingConfig.StandardEpisodeFormat = "{Series_Title}"; - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("15x06.City Sushi [HDTV-720p]"); + Subject.BuildFilename(new List {_episode1}, _series, _episodeFile) + .Should().Be("South_Park"); } [Test] - public void GetNewFilename_UseSceneName_when_sceneName_isNull() + public void should_replace_Series_dot_Title() { - namingConfig.IncludeSeriesTitle = false; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = "."; ; - namingConfig.NumberStyle = 0; - namingConfig.ReplaceSpaces = false; - namingConfig.RenameEpisodes = false; + _namingConfig.StandardEpisodeFormat = "{Series.Title}"; - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); - - var episodeFile = Builder.CreateNew() - .With(e => e.SceneName = null) - .With(e => e.Path = @"C:\Test\TV\30 Rock - S01E01 - Test") - .Build(); - - - string result = Subject.BuildFilename(new List { episode }, _series, episodeFile); - - - result.Should().Be(Path.GetFileNameWithoutExtension(episodeFile.Path)); + Subject.BuildFilename(new List {_episode1}, _series, _episodeFile) + .Should().Be("South.Park"); } [Test] - public void GetNewFilename_UseSceneName_when_sceneName_isNotNull() + public void should_replace_Series_dash_Title() { - namingConfig.IncludeSeriesTitle = false; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = "."; - namingConfig.NumberStyle = 0; - namingConfig.ReplaceSpaces = false; - namingConfig.RenameEpisodes = false; + _namingConfig.StandardEpisodeFormat = "{Series-Title}"; - var episode = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .Build(); + Subject.BuildFilename(new List {_episode1}, _series, _episodeFile) + .Should().Be("South-Park"); + } - var episodeFile = Builder.CreateNew() - .With(e => e.SceneName = "30.Rock.S01E01.xvid-LOL") - .With(e => e.Path = @"C:\Test\TV\30 Rock - S01E01 - Test") - .Build(); + [Test] + public void should_replace_SERIES_TITLE_with_all_caps() + { + _namingConfig.StandardEpisodeFormat = "{SERIES TITLE}"; + Subject.BuildFilename(new List {_episode1}, _series, _episodeFile) + .Should().Be("SOUTH PARK"); + } - string result = Subject.BuildFilename(new List { episode }, _series, episodeFile); + [Test] + public void should_replace_SERIES_TITLE_with_random_casing_should_keep_original_casing() + { + _namingConfig.StandardEpisodeFormat = "{sErIES-tItLE}"; + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be(_series.Title.Replace(' ', '-')); + } - result.Should().Be(episodeFile.SceneName); + [Test] + public void should_replace_series_title_with_all_lower_case() + { + _namingConfig.StandardEpisodeFormat = "{series title}"; + + Subject.BuildFilename(new List {_episode1}, _series, _episodeFile) + .Should().Be("south park"); + } + + [Test] + public void should_replace_episode_title() + { + _namingConfig.StandardEpisodeFormat = "{Episode Title}"; + + Subject.BuildFilename(new List {_episode1}, _series, _episodeFile) + .Should().Be("City Sushi"); + } + + [Test] + public void should_replace_episode_title_if_pattern_has_random_casing() + { + _namingConfig.StandardEpisodeFormat = "{ePisOde-TitLe}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("City-Sushi"); + } + + [Test] + public void should_replace_season_number_with_single_digit() + { + _episode1.SeasonNumber = 1; + _namingConfig.StandardEpisodeFormat = "{season}x{episode}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("1x6"); + } + + [Test] + public void should_replace_season00_number_with_two_digits() + { + _episode1.SeasonNumber = 1; + _namingConfig.StandardEpisodeFormat = "{season:00}x{episode}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("01x6"); + } + + [Test] + public void should_replace_episode_number_with_single_digit() + { + _episode1.SeasonNumber = 1; + _namingConfig.StandardEpisodeFormat = "{season}x{episode}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("1x6"); + } + + [Test] + public void should_replace_episode00_number_with_two_digits() + { + _episode1.SeasonNumber = 1; + _namingConfig.StandardEpisodeFormat = "{season}x{episode:00}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("1x06"); + } + + [Test] + public void should_replace_quality_title() + { + _namingConfig.StandardEpisodeFormat = "{Quality Title}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("HDTV-720p"); + } + + [Test] + public void should_replace_quality_title_with_proper() + { + _namingConfig.StandardEpisodeFormat = "{Quality Title}"; + _episodeFile.Quality.Proper = true; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("HDTV-720p Proper"); + } + + [Test] + public void should_replace_all_contents_in_pattern() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]"; + + Subject.BuildFilename(new List {_episode1}, _series, _episodeFile) + .Should().Be("South Park - S15E06 - City Sushi [HDTV-720p]"); + } + + [Test] + public void use_file_name_when_sceneName_is_null() + { + _namingConfig.RenameEpisodes = false; + _episodeFile.Path = @"C:\Test\TV\30 Rock - S01E01 - Test"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be(Path.GetFileNameWithoutExtension(_episodeFile.Path)); + } + + [Test] + public void use_file_name_when_sceneName_is_not_null() + { + _namingConfig.RenameEpisodes = false; + _episodeFile.SceneName = "30.Rock.S01E01.xvid-LOL"; + _episodeFile.Path = @"C:\Test\TV\30 Rock - S01E01 - Test"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("30.Rock.S01E01.xvid-LOL"); } [Test] public void should_only_have_one_episodeTitle_when_episode_titles_are_the_same() { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - namingConfig.MultiEpisodeStyle = 3; + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; + _namingConfig.MultiEpisodeStyle = 3; var episode = Builder.CreateNew() .With(e => e.Title = "Hey, Baby, What's Wrong? (1)") @@ -607,163 +246,91 @@ namespace NzbDrone.Core.Test.OrganizerTests .Build(); - string result = Subject.BuildFilename(new List { episode2, episode }, new Series { Title = "30 Rock" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("30 Rock - S06E06-E07 - Hey, Baby, What's Wrong!"); + Subject.BuildFilename(new List {episode2, episode}, new Series {Title = "30 Rock"}, _episodeFile) + .Should().Be("30 Rock - S06E06-E07 - Hey, Baby, What's Wrong!"); } [Test] public void should_have_two_episodeTitles_when_episode_titles_are_not_the_same() { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - namingConfig.MultiEpisodeStyle = 3; + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; + _namingConfig.MultiEpisodeStyle = 3; - var episode = Builder.CreateNew() - .With(e => e.Title = "Hello") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 6) - .Build(); + _episode1.Title = "Hello"; + _episode2.Title = "World"; - var episode2 = Builder.CreateNew() - .With(e => e.Title = "World") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 7) - .Build(); - - - string result = Subject.BuildFilename(new List { episode2, episode }, new Series { Title = "30 Rock" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("30 Rock - S06E06-E07 - Hello + World"); - } - - [Test] - public void should_have_two_episodeTitles_when_distinct_count_is_two() - { - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - namingConfig.MultiEpisodeStyle = 3; - - var episode = Builder.CreateNew() - .With(e => e.Title = "Hello (3)") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 6) - .Build(); - - var episode2 = Builder.CreateNew() - .With(e => e.Title = "Hello (2)") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 7) - .Build(); - - var episode3 = Builder.CreateNew() - .With(e => e.Title = "World") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 8) - .Build(); - - - string result = Subject.BuildFilename(new List { episode, episode2, episode3 }, new Series { Title = "30 Rock" }, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - - - result.Should().Be("30 Rock - S06E06-E07-E08 - Hello + World"); + Subject.BuildFilename(new List {_episode1, _episode2}, _series, _episodeFile) + .Should().Be("South Park - S15E06-E07 - Hello + World"); } [Test] public void should_use_airDate_if_series_isDaily() { + _namingConfig.DailyEpisodeFormat = "{Series Title} - {air-date} - {Episode Title}"; - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = true; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; + _series.Title = "The Daily Show with Jon Stewart"; + _series.SeriesType = SeriesTypes.Daily; - var series = Builder - .CreateNew() - .With(s => s.SeriesType = SeriesTypes.Daily) - .With(s => s.Title = "The Daily Show with Jon Stewart") - .Build(); + _episode1.AirDate = "2012-12-13"; + _episode1.Title = "Kristen Stewart"; - var episodes = Builder - .CreateListOfSize(1) - .All() - .With(e => e.AirDate = "2012-12-13") - .With(e => e.Title = "Kristen Stewart") - .Build(); - - var result = Subject - .BuildFilename(episodes, series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - result.Should().Be("The Daily Show with Jon Stewart - 2012-12-13 - Kristen Stewart [HDTV-720p]"); - } - - [Test] - public void should_use_airDate_if_series_isDaily_no_episode_title() - { - - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = false; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; - - var series = Builder - .CreateNew() - .With(s => s.SeriesType = SeriesTypes.Daily) - .With(s => s.Title = "The Daily Show with Jon Stewart") - .Build(); - - var episodes = Builder - .CreateListOfSize(1) - .All() - .With(e => e.AirDate = "2012-12-13") - .With(e => e.Title = "Kristen Stewart") - .Build(); - - var result = Subject - .BuildFilename(episodes, series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - result.Should().Be("The Daily Show with Jon Stewart - 2012-12-13"); + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("The Daily Show with Jon Stewart - 2012-12-13 - Kristen Stewart"); } [Test] public void should_set_airdate_to_unknown_if_not_available() { + _namingConfig.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; - namingConfig.IncludeSeriesTitle = true; - namingConfig.IncludeEpisodeTitle = true; - namingConfig.IncludeQuality = false; - namingConfig.Separator = " - "; - namingConfig.NumberStyle = 2; - namingConfig.ReplaceSpaces = false; + _series.Title = "The Daily Show with Jon Stewart"; + _series.SeriesType = SeriesTypes.Daily; - var series = Builder - .CreateNew() - .With(s => s.SeriesType = SeriesTypes.Daily) - .With(s => s.Title = "The Daily Show with Jon Stewart") - .Build(); + _episode1.AirDate = null; + _episode1.Title = "Kristen Stewart"; - var episodes = Builder - .CreateListOfSize(1) - .All() - .With(e => e.AirDate = null) - .With(e => e.Title = "Kristen Stewart") - .Build(); + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("The Daily Show with Jon Stewart - Unknown - Kristen Stewart"); + } - var result = Subject - .BuildFilename(episodes, series, new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p) }); - result.Should().Be("The Daily Show with Jon Stewart - Unknown - Kristen Stewart"); + [Test] + public void should_format_extend_multi_episode_properly() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; + _namingConfig.MultiEpisodeStyle = 0; + + Subject.BuildFilename(new List {_episode1, _episode2}, _series, _episodeFile) + .Should().Be("South Park - S15E06-07 - City Sushi"); + } + + [Test] + public void should_format_duplicate_multi_episode_properly() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; + _namingConfig.MultiEpisodeStyle = 1; + + Subject.BuildFilename(new List { _episode1, _episode2 }, _series, _episodeFile) + .Should().Be("South Park - S15E06 - S15E07 - City Sushi"); + } + + [Test] + public void should_format_repeat_multi_episode_properly() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; + _namingConfig.MultiEpisodeStyle = 2; + + Subject.BuildFilename(new List { _episode1, _episode2 }, _series, _episodeFile) + .Should().Be("South Park - S15E06E07 - City Sushi"); + } + + [Test] + public void should_format_scene_multi_episode_properly() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; + _namingConfig.MultiEpisodeStyle = 3; + + Subject.BuildFilename(new List { _episode1, _episode2 }, _series, _episodeFile) + .Should().Be("South Park - S15E06-E07 - City Sushi"); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index f9d6d1ac3..e68ac3ca9 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -84,6 +84,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("House.Hunters.International.S05E607.720p.hdtv.x264", "House.Hunters.International", 5, 607)] [TestCase("Adventure.Time.With.Finn.And.Jake.S01E20.720p.BluRay.x264-DEiMOS", "Adventure.Time.With.Finn.And.Jake", 1, 20)] [TestCase("Hostages.S01E04.2-45.PM.[HDTV-720p].mkv", "Hostages", 1, 4)] + [TestCase("S01E04", "", 1, 4)] + [TestCase("1x04", "", 1, 4)] public void ParseTitle_single(string postTitle, string title, int seasonNumber, int episodeNumber) { var result = Parser.Parser.ParseTitle(postTitle); diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 341605d68..89f542670 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -142,19 +142,6 @@ namespace NzbDrone.Core.Configuration set { SetValue(ConfigKey.DownloadedEpisodesFolder.ToString(), value); } } - public bool UseSeasonFolder - { - get { return GetValueBoolean("UseSeasonFolder", true); } - - set { SetValue("UseSeasonFolder", value); } - } - - public string SeasonFolderFormat - { - get { return GetValue("SeasonFolderFormat", "Season %s"); } - set { SetValue("SeasonFolderFormat", value); } - } - public bool AutoUnmonitorPreviouslyDownloadedEpisodes { get { return GetValueBoolean("AutoUnmonitorPreviouslyDownloadedEpisodes"); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 654f07f73..60f98aada 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -20,8 +20,6 @@ namespace NzbDrone.Core.Configuration SabPriorityType SabOlderTvPriority { get; set; } Boolean SabUseSsl { get; set; } String DownloadedEpisodesFolder { get; set; } - bool UseSeasonFolder { get; set; } - string SeasonFolderFormat { get; set; } bool AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; } int Retention { get; set; } DownloadClientType DownloadClient { get; set; } diff --git a/src/NzbDrone.Core/Datastore/Migration/029_add_formats_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/029_add_formats_to_naming_config.cs new file mode 100644 index 000000000..fbcd4cb2b --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/029_add_formats_to_naming_config.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(29)] + public class add_formats_to_naming_config : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("NamingConfig").AddColumn("StandardEpisodeFormat").AsString().Nullable(); + Alter.Table("NamingConfig").AddColumn("DailyEpisodeFormat").AsString().Nullable(); + + Execute.WithConnection(ConvertConfig); + } + + private void ConvertConfig(IDbConnection conn, IDbTransaction tran) + { + using (IDbCommand namingConfigCmd = conn.CreateCommand()) + { + namingConfigCmd.Transaction = tran; + namingConfigCmd.CommandText = @"SELECT * FROM NamingConfig LIMIT 1"; + using (IDataReader namingConfigReader = namingConfigCmd.ExecuteReader()) + { + var separatorIndex = namingConfigReader.GetOrdinal("Separator"); + var numberStyleIndex = namingConfigReader.GetOrdinal("NumberStyle"); + var includeSeriesTitleIndex = namingConfigReader.GetOrdinal("IncludeSeriesTitle"); + var includeEpisodeTitleIndex = namingConfigReader.GetOrdinal("IncludeEpisodeTitle"); + var includeQualityIndex = namingConfigReader.GetOrdinal("IncludeQuality"); + var replaceSpacesIndex = namingConfigReader.GetOrdinal("ReplaceSpaces"); + + while (namingConfigReader.Read()) + { + var separator = namingConfigReader.GetString(separatorIndex); + var numberStyle = namingConfigReader.GetInt32(numberStyleIndex); + var includeSeriesTitle = namingConfigReader.GetBoolean(includeSeriesTitleIndex); + var includeEpisodeTitle = namingConfigReader.GetBoolean(includeEpisodeTitleIndex); + var includeQuality = namingConfigReader.GetBoolean(includeQualityIndex); + var replaceSpaces = namingConfigReader.GetBoolean(replaceSpacesIndex); + + //Output settings + var seriesTitlePattern = ""; + var episodeTitlePattern = ""; + var dailyEpisodePattern = "{Air-Date}"; + var qualityFormat = " [{Quality Title}]"; + + if (includeSeriesTitle) + { + if (replaceSpaces) + { + seriesTitlePattern = "{Series.Title}"; + } + + else + { + seriesTitlePattern = "{Series Title}"; + } + + seriesTitlePattern += separator; + } + + if (includeEpisodeTitle) + { + episodeTitlePattern = separator; + + if (replaceSpaces) + { + episodeTitlePattern += "{Episode.Title}"; + } + + else + { + episodeTitlePattern += "{Episode Title}"; + } + } + + var standardEpisodeFormat = String.Format("{0}{1}{2}", seriesTitlePattern, + GetNumberStyle(numberStyle).Pattern, + episodeTitlePattern); + + var dailyEpisodeFormat = String.Format("{0}{1}{2}", seriesTitlePattern, + dailyEpisodePattern, + episodeTitlePattern); + + if (includeQuality) + { + if (replaceSpaces) + { + qualityFormat = " [{Quality.Title}]"; + } + + standardEpisodeFormat += qualityFormat; + dailyEpisodeFormat += qualityFormat; + } + + using (IDbCommand updateCmd = conn.CreateCommand()) + { + var text = String.Format("UPDATE NamingConfig " + + "SET StandardEpisodeFormat = '{0}', " + + "DailyEpisodeFormat = '{1}'", + standardEpisodeFormat, + dailyEpisodeFormat); + + updateCmd.Transaction = tran; + updateCmd.CommandText = text; + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + + private static readonly List NumberStyles = new List + { + new + { + Id = 0, + Name = "1x05", + Pattern = "{season}x{episode:00}", + EpisodeSeparator = "x" + + }, + new + { + Id = 1, + Name = "01x05", + Pattern = "{season:00}x{episode:00}", + EpisodeSeparator = "x" + }, + new + { + Id = 2, + Name = "S01E05", + Pattern = "S{season:00}E{episode:00}", + EpisodeSeparator = "E" + }, + new + { + Id = 3, + Name = "s01e05", + Pattern = "s{season:00}e{episode:00}", + EpisodeSeparator = "e" + } + }; + + private static dynamic GetNumberStyle(int id) + { + return NumberStyles.Single(s => s.Id == id); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/030_add_season_folder_format_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/030_add_season_folder_format_to_naming_config.cs new file mode 100644 index 000000000..7e7a2ab87 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/030_add_season_folder_format_to_naming_config.cs @@ -0,0 +1,56 @@ +using System; +using System.Data; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(30)] + public class add_season_folder_format_to_naming_config : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("NamingConfig").AddColumn("SeasonFolderFormat").AsString().Nullable(); + Execute.WithConnection(ConvertConfig); + Execute.Sql("DELETE FROM Config WHERE [Key] = 'seasonfolderformat'"); + Execute.Sql("DELETE FROM Config WHERE [Key] = 'useseasonfolder'"); + } + + private void ConvertConfig(IDbConnection conn, IDbTransaction tran) + { + using (IDbCommand namingConfigCmd = conn.CreateCommand()) + { + namingConfigCmd.Transaction = tran; + namingConfigCmd.CommandText = @"SELECT [Value] FROM Config WHERE [Key] = 'seasonfolderformat'"; + var seasonFormat = "Season {season}"; + + using (IDataReader namingConfigReader = namingConfigCmd.ExecuteReader()) + { + while (namingConfigReader.Read()) + { + //only getting one column, so its index is 0 + seasonFormat = namingConfigReader.GetString(0); + + seasonFormat = seasonFormat.Replace("%sn", "{Series Title}") + .Replace("%s.n", "{Series.Title}") + .Replace("%s", "{season}") + .Replace("%0s", "{season:00}") + .Replace("%e", "{episode}") + .Replace("%0e", "{episode:00}"); + } + } + + using (IDbCommand updateCmd = conn.CreateCommand()) + { + var text = String.Format("UPDATE NamingConfig " + + "SET SeasonFolderFormat = '{0}'", + seasonFormat); + + updateCmd.Transaction = tran; + updateCmd.CommandText = text; + updateCmd.ExecuteNonQuery(); + } + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/031_delete_old_naming_config_columns.cs b/src/NzbDrone.Core/Datastore/Migration/031_delete_old_naming_config_columns.cs new file mode 100644 index 000000000..c570fd574 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/031_delete_old_naming_config_columns.cs @@ -0,0 +1,23 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(31)] + public class delete_old_naming_config_columns : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + SqLiteAlter.DropColumns("NamingConfig", + new[] + { + "Separator", + "NumberStyle", + "IncludeSeriesTitle", + "IncludeEpisodeTitle", + "IncludeQuality", + "ReplaceSpaces" + }); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index b4916da94..142c9d9e8 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -185,6 +185,9 @@ Code + + + @@ -321,6 +324,15 @@ + + + + + + + + + diff --git a/src/NzbDrone.Core/Organizer/BasicNamingConfig.cs b/src/NzbDrone.Core/Organizer/BasicNamingConfig.cs new file mode 100644 index 000000000..62d476701 --- /dev/null +++ b/src/NzbDrone.Core/Organizer/BasicNamingConfig.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Organizer +{ + public class BasicNamingConfig + { + public bool IncludeSeriesTitle { get; set; } + public bool IncludeEpisodeTitle { get; set; } + public bool IncludeQuality { get; set; } + public bool ReplaceSpaces { get; set; } + public string Separator { get; set; } + public string NumberStyle { get; set; } + } +} diff --git a/src/NzbDrone.Core/Organizer/EpisodeFormat.cs b/src/NzbDrone.Core/Organizer/EpisodeFormat.cs new file mode 100644 index 000000000..c5d8fe5db --- /dev/null +++ b/src/NzbDrone.Core/Organizer/EpisodeFormat.cs @@ -0,0 +1,12 @@ +using System; + +namespace NzbDrone.Core.Organizer +{ + public class EpisodeFormat + { + public String Separator { get; set; } + public String EpisodePattern { get; set; } + public String EpisodeSeparator { get; set; } + public String SeasonEpisodePattern { get; set; } + } +} diff --git a/src/NzbDrone.Core/Organizer/Exception.cs b/src/NzbDrone.Core/Organizer/Exception.cs new file mode 100644 index 000000000..c4e80b17d --- /dev/null +++ b/src/NzbDrone.Core/Organizer/Exception.cs @@ -0,0 +1,15 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Organizer +{ + public class NamingFormatException : NzbDroneException + { + public NamingFormatException(string message, params object[] args) : base(message, args) + { + } + + public NamingFormatException(string message) : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 837fbae3b..776f138df 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -2,9 +2,10 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using NLog; +using NzbDrone.Common.Cache; using NzbDrone.Core.Configuration; -using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Tv; @@ -15,52 +16,38 @@ namespace NzbDrone.Core.Organizer string BuildFilename(IList episodes, Series series, EpisodeFile episodeFile); string BuildFilename(IList episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig); string BuildFilePath(Series series, int seasonNumber, string fileName, string extension); - } - - public interface INamingConfigService - { - NamingConfig GetConfig(); - NamingConfig Save(NamingConfig namingConfig); - } - - public class NamingConfigService : INamingConfigService - { - private readonly IBasicRepository _repository; - - public NamingConfigService(IBasicRepository repository) - { - _repository = repository; - } - - public NamingConfig GetConfig() - { - var config = _repository.SingleOrDefault(); - - if (config == null) - { - _repository.Insert(NamingConfig.Default); - config = _repository.Single(); - } - - return config; - } - - public NamingConfig Save(NamingConfig namingConfig) - { - return _repository.Upsert(namingConfig); - } + BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); } public class FileNameBuilder : IBuildFileNames { private readonly IConfigService _configService; private readonly INamingConfigService _namingConfigService; + private readonly ICached _patternCache; private readonly Logger _logger; - public FileNameBuilder(INamingConfigService namingConfigService, IConfigService configService, Logger logger) + private static readonly Regex TitleRegex = new Regex(@"(?\{(?:\w+)(?\s|\.|-|_)\w+\})", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex EpisodeRegex = new Regex(@"(?\{episode(?:\:0+)?})", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex SeasonRegex = new Regex(@"(?\{season(?:\:0+)?})", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?(?<=}).+?)?(?s?{season(?:\:0+)?}(?e|x)(?{episode(?:\:0+)?}))(?.+?(?={))?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static readonly Regex AirDateRegex = new Regex(@"\{Air(\s|\W|_)Date\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public FileNameBuilder(INamingConfigService namingConfigService, + IConfigService configService, + ICacheManger cacheManger, + Logger logger) { _namingConfigService = namingConfigService; _configService = configService; + _patternCache = cacheManger.GetCache(GetType()); _logger = logger; } @@ -71,9 +58,9 @@ namespace NzbDrone.Core.Organizer return BuildFilename(episodes, series, episodeFile, nameSpec); } - public string BuildFilename(IList episodes, Series series, EpisodeFile episodeFile, NamingConfig nameSpec) + public string BuildFilename(IList episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig) { - if (!nameSpec.RenameEpisodes) + if (!namingConfig.RenameEpisodes) { if (String.IsNullOrWhiteSpace(episodeFile.SceneName)) { @@ -83,86 +70,83 @@ namespace NzbDrone.Core.Organizer return episodeFile.SceneName; } - var sortedEpisodes = episodes.OrderBy(e => e.EpisodeNumber); - - var numberStyle = GetNumberStyle(nameSpec.NumberStyle); - - var episodeNames = new List - { - Parser.Parser.CleanupEpisodeTitle(sortedEpisodes.First().Title) - }; - - var result = String.Empty; - - if (nameSpec.IncludeSeriesTitle) + if (String.IsNullOrWhiteSpace(namingConfig.StandardEpisodeFormat) && series.SeriesType == SeriesTypes.Standard) { - result += series.Title + nameSpec.Separator; + throw new NamingFormatException("Standard episode format cannot be null"); } - if (series.SeriesType == SeriesTypes.Standard) + if (String.IsNullOrWhiteSpace(namingConfig.DailyEpisodeFormat) && series.SeriesType == SeriesTypes.Daily) { - result += numberStyle.Pattern.Replace("%0e", - String.Format("{0:00}", sortedEpisodes.First().EpisodeNumber)); + throw new NamingFormatException("Daily episode format cannot be null"); + } - if (episodes.Count > 1) + var sortedEpisodes = episodes.OrderBy(e => e.EpisodeNumber).ToList(); + var pattern = namingConfig.StandardEpisodeFormat; + var episodeTitles = new List + { + Parser.Parser.CleanupEpisodeTitle(sortedEpisodes.First().Title) + }; + + var tokenValues = new Dictionary(FilenameBuilderTokenEqualityComparer.Instance) + { + {"{Series Title}", series.Title} + }; + + if (series.SeriesType == SeriesTypes.Daily) + { + pattern = namingConfig.DailyEpisodeFormat; + + if (!String.IsNullOrWhiteSpace(episodes.First().AirDate)) { - var multiEpisodeStyle = - GetMultiEpisodeStyle(nameSpec.MultiEpisodeStyle); - - foreach (var episode in sortedEpisodes.Skip(1)) - { - if (multiEpisodeStyle.Name == "Duplicate") - { - result += nameSpec.Separator + numberStyle.Pattern; - } - else - { - result += multiEpisodeStyle.Pattern; - } - - result = result.Replace("%0e", String.Format("{0:00}", episode.EpisodeNumber)); - episodeNames.Add(Parser.Parser.CleanupEpisodeTitle(episode.Title)); - } + tokenValues.Add("{Air Date}", episodes.First().AirDate.Replace('-', ' ')); } - result = result - .Replace("%s", String.Format("{0}", episodes.First().SeasonNumber)) - .Replace("%0s", String.Format("{0:00}", episodes.First().SeasonNumber)) - .Replace("%x", numberStyle.EpisodeSeparator) - .Replace("%p", nameSpec.Separator); + else { + tokenValues.Add("{Air Date}", "Unknown"); + } } - else + var episodeFormat = GetEpisodeFormat(pattern); + + if (episodeFormat != null) { - if (!String.IsNullOrEmpty(episodes.First().AirDate)) - result += episodes.First().AirDate; + pattern = pattern.Replace(episodeFormat.SeasonEpisodePattern, "{Season Episode}"); + var seasonEpisodePattern = episodeFormat.SeasonEpisodePattern; - else - result += "Unknown"; - } + foreach (var episode in sortedEpisodes.Skip(1)) + { + switch ((MultiEpisodeStyle)namingConfig.MultiEpisodeStyle) + { + case MultiEpisodeStyle.Duplicate: + seasonEpisodePattern += episodeFormat.Separator + episodeFormat.SeasonEpisodePattern; + break; - if (nameSpec.IncludeEpisodeTitle) - { - if (episodeNames.Distinct().Count() == 1) - result += nameSpec.Separator + episodeNames.First(); + case MultiEpisodeStyle.Repeat: + seasonEpisodePattern += episodeFormat.EpisodeSeparator + episodeFormat.EpisodePattern; + break; - else - result += nameSpec.Separator + String.Join(" + ", episodeNames.Distinct()); - } + case MultiEpisodeStyle.Scene: + seasonEpisodePattern += "-" + episodeFormat.EpisodeSeparator + episodeFormat.EpisodePattern; + break; - if (nameSpec.IncludeQuality) - { - result += String.Format(" [{0}]", episodeFile.Quality.Quality); + //MultiEpisodeStyle.Extend + default: + seasonEpisodePattern += "-" + episodeFormat.EpisodePattern; + break; + } - if (episodeFile.Quality.Proper) - result += " [Proper]"; - } + episodeTitles.Add(Parser.Parser.CleanupEpisodeTitle(episode.Title)); + } - if (nameSpec.ReplaceSpaces) - result = result.Replace(' ', '.'); + seasonEpisodePattern = ReplaceNumberTokens(seasonEpisodePattern, sortedEpisodes); + tokenValues.Add("{Season Episode}", seasonEpisodePattern); + } + + tokenValues.Add("{Episode Title}", String.Join(" + ", episodeTitles.Distinct())); + tokenValues.Add("{Quality Title}", episodeFile.Quality.ToString()); + - _logger.Trace("New File Name is: [{0}]", result.Trim()); - return CleanFilename(result.Trim()); + return CleanFilename(ReplaceTokens(pattern, tokenValues).Trim()); } public string BuildFilePath(Series series, int seasonNumber, string fileName, string extension) @@ -179,12 +163,12 @@ namespace NzbDrone.Core.Organizer else { - seasonFolder = _configService.SeasonFolderFormat - .Replace("%sn", series.Title) - .Replace("%s.n", series.Title.Replace(' ', '.')) - .Replace("%s_n", series.Title.Replace(' ', '_')) - .Replace("%0s", seasonNumber.ToString("00")) - .Replace("%s", seasonNumber.ToString()); + var nameSpec = _namingConfigService.GetConfig(); + var tokenValues = new Dictionary(FilenameBuilderTokenEqualityComparer.Instance); + tokenValues.Add("{Series Title}", series.Title); + + seasonFolder = ReplaceSeasonTokens(nameSpec.SeasonFolderFormat, seasonNumber); + seasonFolder = ReplaceTokens(seasonFolder, tokenValues); } path = Path.Combine(path, seasonFolder); @@ -193,6 +177,52 @@ namespace NzbDrone.Core.Organizer return Path.Combine(path, fileName + extension); } + public BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec) + { + var episodeFormat = GetEpisodeFormat(nameSpec.StandardEpisodeFormat); + + if (episodeFormat == null) + { + return new BasicNamingConfig(); + } + + var basicNamingConfig = new BasicNamingConfig + { + Separator = episodeFormat.Separator, + NumberStyle = episodeFormat.SeasonEpisodePattern + }; + + var titleTokens = TitleRegex.Matches(nameSpec.StandardEpisodeFormat); + + foreach (Match match in titleTokens) + { + var separator = match.Groups["separator"].Value; + var token = match.Groups["token"].Value; + + if (!separator.Equals(" ")) + { + basicNamingConfig.ReplaceSpaces = true; + } + + if (token.StartsWith("{Series", StringComparison.InvariantCultureIgnoreCase)) + { + basicNamingConfig.IncludeSeriesTitle = true; + } + + if (token.StartsWith("{Episode", StringComparison.InvariantCultureIgnoreCase)) + { + basicNamingConfig.IncludeEpisodeTitle = true; + } + + if (token.StartsWith("{Quality", StringComparison.InvariantCultureIgnoreCase)) + { + basicNamingConfig.IncludeQuality = true; + } + } + + return basicNamingConfig; + } + public static string CleanFilename(string name) { string result = name; @@ -205,76 +235,92 @@ namespace NzbDrone.Core.Organizer return result.Trim(); } - private static readonly List NumberStyles = new List - { - new EpisodeSortingType - { - Id = 0, - Name = "1x05", - Pattern = "%sx%0e", - EpisodeSeparator = "x" - - }, - new EpisodeSortingType - { - Id = 1, - Name = "01x05", - Pattern = "%0sx%0e", - EpisodeSeparator = "x" - }, - new EpisodeSortingType - { - Id = 2, - Name = "S01E05", - Pattern = "S%0sE%0e", - EpisodeSeparator = "E" - }, - new EpisodeSortingType - { - Id = 3, - Name = "s01e05", - Pattern = "s%0se%0e", - EpisodeSeparator = "e" - } - }; - - private static readonly List MultiEpisodeStyles = new List - { - new EpisodeSortingType - { - Id = 0, - Name = "Extend", - Pattern = "-%0e" - }, - new EpisodeSortingType - { - Id = 1, - Name = "Duplicate", - Pattern = "%p%0s%x%0e" - }, - new EpisodeSortingType - { - Id = 2, - Name = "Repeat", - Pattern = "%x%0e" - }, - new EpisodeSortingType - { - Id = 3, - Name = "Scene", - Pattern = "-%x%0e" - } - }; - - - private static EpisodeSortingType GetNumberStyle(int id) + private string ReplaceTokens(string pattern, Dictionary tokenValues) { - return NumberStyles.Single(s => s.Id == id); + return TitleRegex.Replace(pattern, match => ReplaceToken(match, tokenValues)); } - private static EpisodeSortingType GetMultiEpisodeStyle(int id) + private string ReplaceToken(Match match, Dictionary tokenValues) { - return MultiEpisodeStyles.Single(s => s.Id == id); + var separator = match.Groups["separator"].Value; + var token = match.Groups["token"].Value; + var replacementText = ""; + var patternTokenArray = token.ToCharArray(); + if (!tokenValues.TryGetValue(token, out replacementText)) return null; + + if (patternTokenArray.All(t => !Char.IsLetter(t) || Char.IsLower(t))) + { + replacementText = replacementText.ToLowerInvariant(); + } + + else if (patternTokenArray.All(t => !Char.IsLetter(t) || Char.IsUpper(t))) + { + replacementText = replacementText.ToUpper(); + } + + if (!separator.Equals(" ")) + { + replacementText = replacementText.Replace(" ", separator); + } + + return replacementText; + } + + private string ReplaceNumberTokens(string pattern, List episodes) + { + var episodeIndex = 0; + pattern = EpisodeRegex.Replace(pattern, match => + { + var episode = episodes[episodeIndex].EpisodeNumber; + episodeIndex++; + + return ReplaceNumberToken(match.Groups["episode"].Value, episode); + }); + + return ReplaceSeasonTokens(pattern, episodes.First().SeasonNumber); + } + + private string ReplaceSeasonTokens(string pattern, int seasonNumber) + { + return SeasonRegex.Replace(pattern, match => ReplaceNumberToken(match.Groups["season"].Value, seasonNumber)); + } + + private string ReplaceNumberToken(string token, int value) + { + var split = token.Trim('{', '}').Split(':'); + if (split.Length == 1) return value.ToString("0"); + + return value.ToString(split[1]); + } + + private EpisodeFormat GetEpisodeFormat(string pattern) + { + return _patternCache.Get(pattern, () => + { + var match = SeasonEpisodePatternRegex.Match(pattern); + + if (match.Success) + { + return new EpisodeFormat + { + EpisodeSeparator = match.Groups["episodeSeparator"].Value, + Separator = match.Groups["separator"].Value, + EpisodePattern = match.Groups["episode"].Value, + SeasonEpisodePattern = match.Groups["seasonEpisode"].Value, + }; + + } + + return null; + }); } } + + public enum MultiEpisodeStyle + { + Extend = 0, + Duplicate = 1, + Repeat = 2, + Scene = 3 + } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Organizer/FileNameValidation.cs b/src/NzbDrone.Core/Organizer/FileNameValidation.cs new file mode 100644 index 000000000..0eceb9564 --- /dev/null +++ b/src/NzbDrone.Core/Organizer/FileNameValidation.cs @@ -0,0 +1,43 @@ +using System; +using FluentValidation; +using FluentValidation.Validators; + +namespace NzbDrone.Core.Organizer +{ + public static class FileNameValidation + { + public static IRuleBuilderOptions ValidEpisodeFormat(this IRuleBuilder ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + return ruleBuilder.SetValidator(new RegularExpressionValidator(FileNameBuilder.SeasonEpisodePatternRegex)).WithMessage("Must contain season and episode numbers"); + } + + public static IRuleBuilderOptions ValidDailyEpisodeFormat(this IRuleBuilder ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + return ruleBuilder.SetValidator(new ValidDailyEpisodeFormatValidator()); + } + } + + public class ValidDailyEpisodeFormatValidator : PropertyValidator + { + public ValidDailyEpisodeFormatValidator() + : base("Must contain Air Date or Season and Episode") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var value = context.PropertyValue as String; + + if (!FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) && + !FileNameBuilder.AirDateRegex.IsMatch(value)) + { + return false; + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FilenameBuilderTokenEqualityComparer.cs b/src/NzbDrone.Core/Organizer/FilenameBuilderTokenEqualityComparer.cs new file mode 100644 index 000000000..16b225131 --- /dev/null +++ b/src/NzbDrone.Core/Organizer/FilenameBuilderTokenEqualityComparer.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace NzbDrone.Core.Organizer +{ + public class FilenameBuilderTokenEqualityComparer : IEqualityComparer + { + public static readonly FilenameBuilderTokenEqualityComparer Instance = new FilenameBuilderTokenEqualityComparer(); + + private static readonly Regex SimpleTokenRegex = new Regex(@"\s|_|\W", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private FilenameBuilderTokenEqualityComparer() + { + + } + + public bool Equals(String s1, String s2) + { + return SimplifyToken(s1).Equals(SimplifyToken(s2)); + } + + public int GetHashCode(String str) + { + return SimplifyToken(str).GetHashCode(); + } + + private static string SimplifyToken(string token) + { + return SimpleTokenRegex.Replace(token, String.Empty).ToLower(); + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FilenameSampleService.cs b/src/NzbDrone.Core/Organizer/FilenameSampleService.cs new file mode 100644 index 000000000..2ea499b9b --- /dev/null +++ b/src/NzbDrone.Core/Organizer/FilenameSampleService.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Organizer +{ + public interface IFilenameSampleService + { + SampleResult GetStandardSample(NamingConfig nameSpec); + SampleResult GetMultiEpisodeSample(NamingConfig nameSpec); + SampleResult GetDailySample(NamingConfig nameSpec); + } + + public class FilenameSampleService : IFilenameSampleService + { + private readonly IBuildFileNames _buildFileNames; + private static Series _standardSeries; + private static Series _dailySeries; + private static Episode _episode1; + private static Episode _episode2; + private static List _singleEpisode; + private static List _multiEpisodes; + private static EpisodeFile _singleEpisodeFile; + private static EpisodeFile _multiEpisodeFile; + private static EpisodeFile _dailyEpisodeFile; + + public FilenameSampleService(IBuildFileNames buildFileNames) + { + _buildFileNames = buildFileNames; + _standardSeries = new Series + { + SeriesType = SeriesTypes.Standard, + Title = "Series Title" + }; + + _dailySeries = new Series + { + SeriesType = SeriesTypes.Daily, + Title = "Series Title" + }; + + _episode1 = new Episode + { + SeasonNumber = 1, + EpisodeNumber = 1, + Title = "Episode Title (1)", + AirDate = "2013-10-30" + }; + + _episode2 = new Episode + { + SeasonNumber = 1, + EpisodeNumber = 2, + Title = "Episode Title (2)" + }; + + _singleEpisode = new List { _episode1 }; + _multiEpisodes = new List { _episode1, _episode2 }; + + _singleEpisodeFile = new EpisodeFile + { + Quality = new QualityModel(Quality.HDTV720p), + Path = @"C:\Test\Series.Title.S01E01.720p.HDTV.x264-EVOLVE.mkv" + }; + + _multiEpisodeFile = new EpisodeFile + { + Quality = new QualityModel(Quality.HDTV720p), + Path = @"C:\Test\Series.Title.S01E01-E02.720p.HDTV.x264-EVOLVE.mkv" + }; + + _dailyEpisodeFile = new EpisodeFile + { + Quality = new QualityModel(Quality.HDTV720p), + Path = @"C:\Test\Series.Title.2013.10.30.HDTV.x264-EVOLVE.mkv" + }; + } + + public SampleResult GetStandardSample(NamingConfig nameSpec) + { + var result = new SampleResult + { + Filename = BuildSample(_singleEpisode, _standardSeries, _singleEpisodeFile, nameSpec), + Series = _standardSeries, + Episodes = _singleEpisode, + EpisodeFile = _singleEpisodeFile + }; + + return result; + } + + public SampleResult GetMultiEpisodeSample(NamingConfig nameSpec) + { + var result = new SampleResult + { + Filename = BuildSample(_multiEpisodes, _standardSeries, _multiEpisodeFile, nameSpec), + Series = _standardSeries, + Episodes = _multiEpisodes, + EpisodeFile = _multiEpisodeFile + }; + + return result; + } + + public SampleResult GetDailySample(NamingConfig nameSpec) + { + var result = new SampleResult + { + Filename = BuildSample(_singleEpisode, _dailySeries, _dailyEpisodeFile, nameSpec), + Series = _dailySeries, + Episodes = _singleEpisode, + EpisodeFile = _dailyEpisodeFile + }; + + return result; + } + + private string BuildSample(List episodes, Series series, EpisodeFile episodeFile, NamingConfig nameSpec) + { + try + { + return _buildFileNames.BuildFilename(episodes, series, episodeFile, nameSpec); + } + catch (NamingFormatException ex) + { + return String.Empty; + } + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FilenameValidationService.cs b/src/NzbDrone.Core/Organizer/FilenameValidationService.cs new file mode 100644 index 000000000..53f64bf86 --- /dev/null +++ b/src/NzbDrone.Core/Organizer/FilenameValidationService.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Organizer +{ + public interface IFilenameValidationService + { + ValidationFailure ValidateStandardFilename(SampleResult sampleResult); + ValidationFailure ValidateDailyFilename(SampleResult sampleResult); + } + + public class FilenameValidationService : IFilenameValidationService + { + private const string ERROR_MESSAGE = "Produces invalid file names"; + + public ValidationFailure ValidateStandardFilename(SampleResult sampleResult) + { + var validationFailure = new ValidationFailure("StandardEpisodeFormat", ERROR_MESSAGE); + var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.Filename); + + if (parsedEpisodeInfo == null) + { + return validationFailure; + } + + if (!ValidateSeasonAndEpisodeNumbers(sampleResult.Episodes, parsedEpisodeInfo)) + { + return validationFailure; + } + + return null; + } + + public ValidationFailure ValidateDailyFilename(SampleResult sampleResult) + { + var validationFailure = new ValidationFailure("DailyEpisodeFormat", ERROR_MESSAGE); + var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.Filename); + + if (parsedEpisodeInfo == null) + { + return validationFailure; + } + + if (parsedEpisodeInfo.IsDaily()) + { + if (!parsedEpisodeInfo.AirDate.Equals(sampleResult.Episodes.Single().AirDate)) + { + return validationFailure; + } + + return null; + } + + if (!ValidateSeasonAndEpisodeNumbers(sampleResult.Episodes, parsedEpisodeInfo)) + { + return validationFailure; + } + + return null; + } + + private bool ValidateSeasonAndEpisodeNumbers(List episodes, ParsedEpisodeInfo parsedEpisodeInfo) + { + if (parsedEpisodeInfo.SeasonNumber != episodes.First().SeasonNumber || + !parsedEpisodeInfo.EpisodeNumbers.OrderBy(e => e).SequenceEqual(episodes.Select(e => e.EpisodeNumber).OrderBy(e => e))) + { + return false; + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core/Organizer/NamingConfig.cs b/src/NzbDrone.Core/Organizer/NamingConfig.cs index 962fd5777..7a537e3ce 100644 --- a/src/NzbDrone.Core/Organizer/NamingConfig.cs +++ b/src/NzbDrone.Core/Organizer/NamingConfig.cs @@ -10,32 +10,19 @@ namespace NzbDrone.Core.Organizer { return new NamingConfig { - RenameEpisodes = true, - Separator = " - ", - NumberStyle = 0, - IncludeSeriesTitle = true, + RenameEpisodes = false, MultiEpisodeStyle = 0, - IncludeEpisodeTitle = true, - IncludeQuality = true, - ReplaceSpaces = false + StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Title}", + DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title} {Quality Title}", + SeasonFolderFormat = "Season {season}" }; } } public bool RenameEpisodes { get; set; } - - public string Separator { get; set; } - - public int NumberStyle { get; set; } - - public bool IncludeSeriesTitle { get; set; } - - public bool IncludeEpisodeTitle { get; set; } - - public bool IncludeQuality { get; set; } - public int MultiEpisodeStyle { get; set; } - - public bool ReplaceSpaces { get; set; } + public string StandardEpisodeFormat { get; set; } + public string DailyEpisodeFormat { get; set; } + public string SeasonFolderFormat { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Organizer/NamingConfigService.cs b/src/NzbDrone.Core/Organizer/NamingConfigService.cs new file mode 100644 index 000000000..113ef030a --- /dev/null +++ b/src/NzbDrone.Core/Organizer/NamingConfigService.cs @@ -0,0 +1,38 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Organizer +{ + public interface INamingConfigService + { + NamingConfig GetConfig(); + void Save(NamingConfig namingConfig); + } + + public class NamingConfigService : INamingConfigService + { + private readonly IBasicRepository _repository; + + public NamingConfigService(IBasicRepository repository) + { + _repository = repository; + } + + public NamingConfig GetConfig() + { + var config = _repository.SingleOrDefault(); + + if (config == null) + { + _repository.Insert(NamingConfig.Default); + config = _repository.Single(); + } + + return config; + } + + public void Save(NamingConfig namingConfig) + { + _repository.Upsert(namingConfig); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Organizer/SampleResult.cs b/src/NzbDrone.Core/Organizer/SampleResult.cs new file mode 100644 index 000000000..928438e8c --- /dev/null +++ b/src/NzbDrone.Core/Organizer/SampleResult.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Organizer +{ + public class SampleResult + { + public string Filename { get; set; } + public Series Series { get; set; } + public List Episodes { get; set; } + public EpisodeFile EpisodeFile { get; set; } + } +} diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index ef62d9034..e771db42f 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex[] ReportTitleRegex = new[] { //Episodes with airdate - new Regex(@"^(?.+?)?\W*(?<airyear>\d{4})\W+(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})\W+(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Anime - Absolute Episode Number + Title + Season+Episode @@ -38,15 +38,15 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Multi-Part episodes without a title (S01E05.S01E06) - new Regex(@"^(?:\W*S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}(\W+|_|$)(?!\\)", + new Regex(@"^(?:\W*S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Multi-episode Repeated (S01E05 - S01E06, 1x05 - 1x06, etc) - new Regex(@"^(?<title>.+?)(?:(\W|_)+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)(?:(\W|_)+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes without a title, Single (S01E05, 1x05) AND Multi (S01E04E05, 1x04x05, etc) - new Regex(@"^(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+)))+)(\W+|_|$)(?!\\)", + new Regex(@"^(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+)))+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes with a title, Single episodes (S01E05, 1x05, etc) & Multi-episode (S01E05E06, S01E05-06, S01E05 E06, etc) @@ -54,7 +54,7 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes with a title, Single episodes (S01E05, 1x05, etc) & Multi-episode (S01E05E06, S01E05-06, S01E05 E06, etc) - new Regex(@"^(?<title>.+?)(?:\W+S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2,3}(?!\d+)))+)(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)(?:\W+S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2,3}(?!\d+)))+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes with single digit episode number (S01E1, S01E5E6, etc) @@ -66,11 +66,11 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Supports 103/113 naming - new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+)\d{1})(?<episode>\d{2}(?!p|i|\d+)))+(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+)\d{1})(?<episode>\d{2}(?!\w|\d+)))+", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Mini-Series, treated as season 1, episodes are labelled as Part01, Part 01, Part.1 - new Regex(@"^(?<title>.+?)(?:\W+(?:(?:Part\W?|(?<!\d+\W+)e)(?<episode>\d{1,2}(?!\d+)))+)(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)(?:\W+(?:(?:Part\W?|(?<!\d+\W+)e)(?<episode>\d{1,2}(?!\d+)))+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Supports 1103/1113 naming @@ -96,7 +96,7 @@ namespace NzbDrone.Core.Parser //Anime - Title Absolute Episode Number new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+e(?<absoluteepisode>\d{2,3}))+", - RegexOptions.IgnoreCase | RegexOptions.Compiled), + RegexOptions.IgnoreCase | RegexOptions.Compiled) }; private static readonly Regex NormalizeRegex = new Regex(@"((^|\W|_)(a|an|the|and|or|of)($|\W|_))|\W|_|(?:(?<=[^0-9]+)|\b)(?!(?:19\d{2}|20\d{2}))\d+(?=[^0-9ip]+|\b)", @@ -144,6 +144,7 @@ namespace NzbDrone.Core.Parser foreach (var regex in ReportTitleRegex) { + var regexString = regex.ToString(); var match = regex.Matches(simpleTitle); if (match.Count != 0) @@ -435,7 +436,7 @@ namespace NzbDrone.Core.Parser return false; } - if (!title.Any(Char.IsLetterOrDigit) || (!title.Any(Char.IsPunctuation) && !title.Any(Char.IsWhiteSpace))) + if (!title.Any(Char.IsLetterOrDigit)) { return false; } diff --git a/src/NzbDrone.Core/Tv/QualityModel.cs b/src/NzbDrone.Core/Tv/QualityModel.cs index d5097c82d..eaa7f7884 100644 --- a/src/NzbDrone.Core/Tv/QualityModel.cs +++ b/src/NzbDrone.Core/Tv/QualityModel.cs @@ -85,7 +85,7 @@ namespace NzbDrone.Core.Tv string result = Quality.ToString(); if (Proper) { - result += " [proper]"; + result += " Proper"; } return result; diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 6f94faa84..9e1da54cd 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -71,8 +71,6 @@ namespace NzbDrone.Core.Tv newSeries.Monitored = true; newSeries.CleanTitle = Parser.Parser.CleanSeriesTitle(newSeries.Title); - newSeries.SeasonFolder = _configService.UseSeasonFolder; - _seriesRepository.Insert(newSeries); _eventAggregator.PublishEvent(new SeriesAddedEvent(newSeries)); diff --git a/src/NzbDrone.Integration.Test/Client/ClientBase.cs b/src/NzbDrone.Integration.Test/Client/ClientBase.cs index a93bfad43..228ac98eb 100644 --- a/src/NzbDrone.Integration.Test/Client/ClientBase.cs +++ b/src/NzbDrone.Integration.Test/Client/ClientBase.cs @@ -87,6 +87,13 @@ namespace NzbDrone.Integration.Test.Client return Post<List<dynamic>>(request, HttpStatusCode.BadRequest); } + public List<dynamic> InvalidPut(TResource body) + { + var request = BuildRequest(); + request.AddBody(body); + return Put<List<dynamic>>(request, HttpStatusCode.BadRequest); + } + public RestRequest BuildRequest(string command = "") { var request = new RestRequest(_resource + "/" + command.Trim('/')) diff --git a/src/NzbDrone.Integration.Test/NamingConfigTests.cs b/src/NzbDrone.Integration.Test/NamingConfigTests.cs index 07592193e..3f351bb68 100644 --- a/src/NzbDrone.Integration.Test/NamingConfigTests.cs +++ b/src/NzbDrone.Integration.Test/NamingConfigTests.cs @@ -1,5 +1,7 @@ -using FluentAssertions; +using System.Net; +using FluentAssertions; using NUnit.Framework; +using NzbDrone.Api.Config; namespace NzbDrone.Integration.Test { @@ -26,8 +28,73 @@ namespace NzbDrone.Integration.Test { var config = NamingConfig.GetSingle(); config.RenameEpisodes = false; + config.StandardEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; + config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; - NamingConfig.Put(config).RenameEpisodes.Should().BeFalse(); + var result = NamingConfig.Put(config); + result.RenameEpisodes.Should().BeFalse(); + result.StandardEpisodeFormat.Should().Be(config.StandardEpisodeFormat); + result.DailyEpisodeFormat.Should().Be(config.DailyEpisodeFormat); + } + + [Test] + public void should_get_bad_request_if_standard_format_is_empty() + { + var config = NamingConfig.GetSingle(); + config.RenameEpisodes = true; + config.StandardEpisodeFormat = ""; + config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; + + var errors = NamingConfig.InvalidPut(config); + errors.Should().NotBeEmpty(); + } + + [Test] + public void should_get_bad_request_if_standard_format_doesnt_contain_season_and_episode() + { + var config = NamingConfig.GetSingle(); + config.RenameEpisodes = true; + config.StandardEpisodeFormat = "{season}"; + config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; + + var errors = NamingConfig.InvalidPut(config); + errors.Should().NotBeEmpty(); + } + + [Test] + public void should_get_bad_request_if_daily_format_doesnt_contain_season_and_episode_or_air_date() + { + var config = NamingConfig.GetSingle(); + config.RenameEpisodes = true; + config.StandardEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; + config.DailyEpisodeFormat = "{Series Title} - {season} - {Episode Title}"; + + var errors = NamingConfig.InvalidPut(config); + errors.Should().NotBeEmpty(); + } + + [Test] + public void should_not_require_format_when_rename_episodes_is_false() + { + var config = NamingConfig.GetSingle(); + config.RenameEpisodes = false; + config.StandardEpisodeFormat = ""; + config.DailyEpisodeFormat = ""; + + var errors = NamingConfig.InvalidPut(config); + errors.Should().NotBeEmpty(); + } + + [Test] + public void should_require_format_when_rename_episodes_is_true() + { + var config = NamingConfig.GetSingle(); + config.RenameEpisodes = true; + config.StandardEpisodeFormat = ""; + config.DailyEpisodeFormat = ""; + + var errors = NamingConfig.InvalidPut(config); + errors.Should().NotBeEmpty(); } } } \ No newline at end of file diff --git a/src/NzbDrone.Integration.Test/RootFolderIntegrationTest.cs b/src/NzbDrone.Integration.Test/RootFolderIntegrationTest.cs index c7984e940..216507465 100644 --- a/src/NzbDrone.Integration.Test/RootFolderIntegrationTest.cs +++ b/src/NzbDrone.Integration.Test/RootFolderIntegrationTest.cs @@ -8,9 +8,6 @@ namespace NzbDrone.Integration.Test [TestFixture] public class RootFolderIntegrationTest : IntegrationTest { - - - [Test] public void should_have_no_root_folder_initially() { @@ -20,7 +17,6 @@ namespace NzbDrone.Integration.Test [Test] public void should_add_and_delete_root_folders() { - ConnectSignalR(); var rootFolder = new RootFolderResource @@ -42,8 +38,6 @@ namespace NzbDrone.Integration.Test SignalRMessages.Should().Contain(c => c.Name == "rootfolder"); - - } [Test] diff --git a/src/UI/AddSeries/RootFolders/StartingSeasonSelectionPartial.html b/src/UI/AddSeries/RootFolders/StartingSeasonSelectionPartial.html index 0827ba052..82d28119c 100644 --- a/src/UI/AddSeries/RootFolders/StartingSeasonSelectionPartial.html +++ b/src/UI/AddSeries/RootFolders/StartingSeasonSelectionPartial.html @@ -1,4 +1,4 @@ -<select class="span2 x-starting-season"> +<select class="starting-season x-starting-season"> {{#each this}} {{#if_eq seasonNumber compare="0"}} <option value="{{seasonNumber}}">Specials</option> diff --git a/src/UI/AddSeries/SearchResultView.js b/src/UI/AddSeries/SearchResultView.js index bb81a5f84..e8b24435c 100644 --- a/src/UI/AddSeries/SearchResultView.js +++ b/src/UI/AddSeries/SearchResultView.js @@ -22,6 +22,7 @@ define( ui: { qualityProfile: '.x-quality-profile', rootFolder : '.x-root-folder', + seasonFolder : '.x-season-folder', addButton : '.x-add', overview : '.x-overview', startingSeason: '.x-starting-season' @@ -30,7 +31,8 @@ define( events: { 'click .x-add' : '_addSeries', 'change .x-quality-profile': '_qualityProfileChanged', - 'change .x-root-folder' : '_rootFolderChanged' + 'change .x-root-folder' : '_rootFolderChanged', + 'change .x-season-folder' : '_seasonFolderChanged' }, initialize: function () { @@ -51,6 +53,7 @@ define( var defaultQuality = Config.getValue(Config.Keys.DefaultQualityProfileId); var defaultRoot = Config.getValue(Config.Keys.DefaultRootFolderId); + var useSeasonFolder = Config.getValueBoolean(Config.Keys.UseSeasonFolder, true); if (QualityProfiles.get(defaultQuality)) { this.ui.qualityProfile.val(defaultQuality); @@ -60,6 +63,8 @@ define( this.ui.rootFolder.val(defaultRoot); } + this.ui.seasonFolder.prop('checked', useSeasonFolder); + var minSeasonNotZero = _.min(_.reject(this.model.get('seasons'), { seasonNumber: 0 }), 'seasonNumber'); if (minSeasonNotZero) { @@ -91,15 +96,24 @@ define( if (options.key === Config.Keys.DefaultQualityProfileId) { this.ui.qualityProfile.val(options.value); } + else if (options.key === Config.Keys.DefaultRootFolderId) { this.ui.rootFolder.val(options.value); } + + else if (options.key === Config.Keys.UseSeasonFolder) { + this.ui.seasonFolder.prop('checked', options.value); + } }, _qualityProfileChanged: function () { Config.setValue(Config.Keys.DefaultQualityProfileId, this.ui.qualityProfile.val()); }, + _seasonFolderChanged: function () { + Config.setValue(Config.Keys.UseSeasonFolder, this.ui.seasonFolder.prop('checked')); + }, + _rootFolderChanged: function () { var rootFolderValue = this.ui.rootFolder.val(); if (rootFolderValue === 'addNew') { @@ -125,16 +139,17 @@ define( var quality = this.ui.qualityProfile.val(); var rootFolderPath = this.ui.rootFolder.children(':selected').text(); var startingSeason = this.ui.startingSeason.val(); + var seasonFolder = this.ui.seasonFolder.prop('checked'); this.model.set('qualityProfileId', quality); this.model.set('rootFolderPath', rootFolderPath); this.model.setSeasonPass(startingSeason); + this.model.set('seasonFolder', seasonFolder); var self = this; SeriesCollection.add(this.model); - var promise = this.model.save(); promise.done(function () { @@ -159,7 +174,6 @@ define( } }); - AsValidatedView.apply(view); return view; diff --git a/src/UI/AddSeries/SearchResultViewTemplate.html b/src/UI/AddSeries/SearchResultViewTemplate.html index 5b64b09a9..04747d8cb 100644 --- a/src/UI/AddSeries/SearchResultViewTemplate.html +++ b/src/UI/AddSeries/SearchResultViewTemplate.html @@ -32,6 +32,14 @@ {{> StartingSeasonSelectionPartial seasons}} {{> QualityProfileSelectionPartial qualityProfiles}} + + <label class="checkbox-button" title="Use season folders"> + <input type="checkbox" class="x-season-folder"/> + <div class="btn btn-primary btn-icon-only"> + <i class="icon-folder-close"></i> + </div> + </label> + <span class="btn btn-success x-add add-series pull-right"> Add <i class="icon-plus"></i> </span> diff --git a/src/UI/AddSeries/addSeries.less b/src/UI/AddSeries/addSeries.less index 1d36d9906..ee04cb289 100644 --- a/src/UI/AddSeries/addSeries.less +++ b/src/UI/AddSeries/addSeries.less @@ -91,6 +91,18 @@ .add-series { margin-left : 20px; } + + .checkbox { + width : 100px; + margin-left : 0px; + display : inline-block; + padding-top : 0px; + margin-bottom : 0px; + } + + .starting-season { + width: 140px; + } } } @@ -129,4 +141,4 @@ li.add-new:hover { overflow: auto; max-height: 300px; } -} \ No newline at end of file +} diff --git a/src/UI/Config.js b/src/UI/Config.js index 6c44d8857..e826e90cf 100644 --- a/src/UI/Config.js +++ b/src/UI/Config.js @@ -9,7 +9,8 @@ define( }, Keys : { DefaultQualityProfileId: 'DefaultQualityProfileId', - DefaultRootFolderId: 'DefaultRootFolderId' + DefaultRootFolderId: 'DefaultRootFolderId', + UseSeasonFolder: 'UseSeasonFolder' }, getValueBoolean: function (key, defaultValue) { diff --git a/src/UI/Content/checkbox-button.less b/src/UI/Content/checkbox-button.less new file mode 100644 index 000000000..becb5a7df --- /dev/null +++ b/src/UI/Content/checkbox-button.less @@ -0,0 +1,33 @@ +@import "Bootstrap/variables"; +@import "Bootstrap/mixins"; + +.checkbox-button div { + display: none; +} + +@media only screen { + .checkbox-button { + input { + position: absolute; + opacity: 0; + z-index: 5; + } + + div { + display: block; + } + + .btn { + .buttonBackground(@btnBackground, @btnBackgroundHighlight); + color: #333333; + } + + .btn:hover { + color: #333333; + } + + input:first-of-type:checked ~ .btn { + .buttonBackground(@btnPrimaryBackground, @btnPrimaryBackgroundHighlight); + } + } +} diff --git a/src/UI/Content/icons.less b/src/UI/Content/icons.less index 14f35d6dd..849830f91 100644 --- a/src/UI/Content/icons.less +++ b/src/UI/Content/icons.less @@ -87,6 +87,11 @@ color: #b94a48; } +.icon-nd-form-info-link:before { + .clickable; + .icon(@info-sign); +} + .icon-nd-donate:before { .icon(@heart); color: @nzbdroneRed; diff --git a/src/UI/Content/theme.less b/src/UI/Content/theme.less index 3cf7edb0a..7c1decd1f 100644 --- a/src/UI/Content/theme.less +++ b/src/UI/Content/theme.less @@ -7,6 +7,7 @@ @import "Backgrid/backgrid"; @import "prefixer"; @import "icons"; +@import "checkbox-button"; @import "spinner"; @import "legend"; @import "../Shared/Styles/clickable"; diff --git a/src/UI/Mixins/AsModelBoundView.js b/src/UI/Mixins/AsModelBoundView.js index 7d1c961f7..d35f8a5a0 100644 --- a/src/UI/Mixins/AsModelBoundView.js +++ b/src/UI/Mixins/AsModelBoundView.js @@ -19,7 +19,11 @@ define( this._modelBinder = new ModelBinder(); } - this._modelBinder.bind(this.model, this.el); + var options = { + changeTriggers: {'': 'change', '[contenteditable]': 'blur', '[data-onkeyup]': 'keyup'} + }; + + this._modelBinder.bind(this.model, this.el, null, options); if (originalOnRender) { originalOnRender.call(this); diff --git a/src/UI/Mixins/AsValidatedView.js b/src/UI/Mixins/AsValidatedView.js index cf4ef661e..952c1da69 100644 --- a/src/UI/Mixins/AsValidatedView.js +++ b/src/UI/Mixins/AsValidatedView.js @@ -23,7 +23,6 @@ define( } }; - var validatedSync = function (method, model,options) { this.$el.removeAllErrors(); arguments[2].isValidatedCall = true; @@ -52,7 +51,6 @@ define( } }; - this.prototype.onBeforeClose = function () { if (this.model) { diff --git a/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingView.js b/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingView.js new file mode 100644 index 000000000..29f14a74a --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingView.js @@ -0,0 +1,101 @@ +'use strict'; +define( + [ + 'underscore', + 'vent', + 'marionette', + 'Settings/MediaManagement/Naming/NamingSampleModel', + 'Mixins/AsModelBoundView' + ], function (_, vent, Marionette, NamingSampleModel, AsModelBoundView) { + + var view = Marionette.ItemView.extend({ + template: 'Settings/MediaManagement/Naming/Basic/BasicNamingViewTemplate', + + ui: { + namingOptions : '.x-naming-options', + singleEpisodeExample : '.x-single-episode-example', + multiEpisodeExample : '.x-multi-episode-example', + dailyEpisodeExample : '.x-daily-episode-example' + }, + + onRender: function () { + this.listenTo(this.model, 'change', this._buildFormat); + this._buildFormat(); + }, + + _updateSamples: function () { + var data = { + renameEpisodes: true, + standardEpisodeFormat: this.standardEpisodeFormat, + dailyEpisodeFormat: this.dailyEpisodeFormat, + multiEpisodeStyle: this.model.get('multiEpisodeStyle') + }; + + this.namingSampleModel.fetch({data: data}); + }, + + _buildFormat: function () { + if (_.has(this.model.changed, 'standardEpisodeFormat') || _.has(this.model.changed, 'dailyEpisodeFormat')) { + return; + } + + var standardEpisodeFormat = ''; + var dailyEpisodeFormat = ''; + + if (this.model.get('includeSeriesTitle')) { + if (this.model.get('replaceSpaces')) { + standardEpisodeFormat += '{Series.Title}'; + dailyEpisodeFormat += '{Series.Title}'; + } + + else { + standardEpisodeFormat += '{Series Title}'; + dailyEpisodeFormat += '{Series Title}'; + } + + standardEpisodeFormat += this.model.get('separator'); + dailyEpisodeFormat += this.model.get('separator'); + } + + standardEpisodeFormat += this.model.get('numberStyle'); + dailyEpisodeFormat += '{Air-Date}'; + + if (this.model.get('includeEpisodeTitle')) { + standardEpisodeFormat += this.model.get('separator'); + dailyEpisodeFormat += this.model.get('separator'); + + if (this.model.get('replaceSpaces')) { + standardEpisodeFormat += '{Episode.Title}'; + dailyEpisodeFormat += '{Episode.Title}'; + } + + else { + standardEpisodeFormat += '{Episode Title}'; + dailyEpisodeFormat += '{Episode Title}'; + } + } + + if (this.model.get('includeQuality')) { + if (this.model.get('replaceSpaces')) { + standardEpisodeFormat += ' {Quality.Title}'; + dailyEpisodeFormat += ' {Quality.Title}'; + } + + else { + standardEpisodeFormat += ' {Quality Title}'; + dailyEpisodeFormat += ' {Quality Title}'; + } + } + + if (this.model.get('replaceSpaces')) { + standardEpisodeFormat = standardEpisodeFormat.replace(/\s/g, '.'); + dailyEpisodeFormat = dailyEpisodeFormat.replace(/\s/g, '.'); + } + + this.model.set('standardEpisodeFormat', standardEpisodeFormat); + this.model.set('dailyEpisodeFormat', dailyEpisodeFormat); + } + }); + + return AsModelBoundView.call(view); + }); diff --git a/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingViewTemplate.html b/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingViewTemplate.html new file mode 100644 index 000000000..53cddefa4 --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingViewTemplate.html @@ -0,0 +1,92 @@ +<div class="control-group"> + <label class="control-label">Include Series Title</label> + + <div class="controls"> + <label class="checkbox toggle well"> + <input type="checkbox" name="includeSeriesTitle"/> + + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + </div> +</div> + +<div class="control-group"> + <label class="control-label">Include Episode Title</label> + + <div class="controls"> + <label class="checkbox toggle well"> + <input type="checkbox" name="includeEpisodeTitle"/> + + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + </div> +</div> + +<div class="control-group"> + <label class="control-label">Include Quality</label> + + <div class="controls"> + <label class="checkbox toggle well"> + <input type="checkbox" name="includeQuality"/> + + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + </div> +</div> + +<div class="control-group"> + <label class="control-label">Replace Spaces</label> + + <div class="controls"> + <label class="checkbox toggle well"> + <input type="checkbox" name="replaceSpaces"/> + + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + </div> +</div> + +<div class="control-group"> + <label class="control-label">Separator</label> + + <div class="controls"> + <select class="inputClass" name="separator"> + <option value=" - ">Dash</option> + <option value=" ">Space</option> + <option value=".">Period</option> + </select> + </div> +</div> + +<div class="control-group"> + <label class="control-label">Numbering Style</label> + + <div class="controls"> + <select class="inputClass" name="numberStyle"> + <option value="{season}x{episode:00}">1x05</option> + <option value="{season:00}x{episode:00}">01x05</option> + <option value="S{season:00}E{episode:00}">S01E05</option> + <option value="s{season:00}e{episode:00}">s01e05</option> + </select> + </div> +</div> diff --git a/src/UI/Settings/MediaManagement/Naming/Model.js b/src/UI/Settings/MediaManagement/Naming/NamingModel.js similarity index 100% rename from src/UI/Settings/MediaManagement/Naming/Model.js rename to src/UI/Settings/MediaManagement/Naming/NamingModel.js diff --git a/src/UI/Settings/MediaManagement/Naming/NamingSampleModel.js b/src/UI/Settings/MediaManagement/Naming/NamingSampleModel.js index fd833e32f..55b167fc0 100644 --- a/src/UI/Settings/MediaManagement/Naming/NamingSampleModel.js +++ b/src/UI/Settings/MediaManagement/Naming/NamingSampleModel.js @@ -6,5 +6,4 @@ define( return Backbone.Model.extend({ url: window.NzbDrone.ApiRoot + '/config/naming/samples' }); - }); diff --git a/src/UI/Settings/MediaManagement/Naming/NamingView.js b/src/UI/Settings/MediaManagement/Naming/NamingView.js index 5ca680643..a9ad9e7e9 100644 --- a/src/UI/Settings/MediaManagement/Naming/NamingView.js +++ b/src/UI/Settings/MediaManagement/Naming/NamingView.js @@ -1,23 +1,35 @@ 'use strict'; define( [ + 'underscore', 'marionette', + 'Config', 'Settings/MediaManagement/Naming/NamingSampleModel', - 'Mixins/AsModelBoundView' - ], function (Marionette, NamingSampleModel, AsModelBoundView) { + 'Settings/MediaManagement/Naming/Basic/BasicNamingView', + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView' + ], function (_, Marionette, Config, NamingSampleModel, BasicNamingView, AsModelBoundView, AsValidatedView) { - var view = Marionette.ItemView.extend({ + var view = Marionette.Layout.extend({ template: 'Settings/MediaManagement/Naming/NamingViewTemplate', ui: { namingOptions : '.x-naming-options', renameEpisodesCheckbox: '.x-rename-episodes', singleEpisodeExample : '.x-single-episode-example', - multiEpisodeExample : '.x-multi-episode-example' + multiEpisodeExample : '.x-multi-episode-example', + dailyEpisodeExample : '.x-daily-episode-example', + namingTokenHelper : '.x-naming-token-helper' }, events: { - 'change .x-rename-episodes': '_setFailedDownloadOptionsVisibility' + 'change .x-rename-episodes' : '_setFailedDownloadOptionsVisibility', + 'click .x-show-wizard' : '_showWizard', + 'click .x-naming-token-helper a' : '_addToken' + }, + + regions: { + basicNamingRegion: '.x-basic-naming' }, onRender: function () { @@ -25,6 +37,8 @@ define( this.ui.namingOptions.hide(); } + var basicNamingView = new BasicNamingView({ model: this.model }); + this.basicNamingRegion.show(basicNamingView); this.namingSampleModel = new NamingSampleModel(); this.listenTo(this.model, 'change', this._updateSamples); @@ -44,14 +58,44 @@ define( }, _updateSamples: function () { + if (!_.has(this.model.changed, 'standardEpisodeFormat') && !_.has(this.model.changed, 'dailyEpisodeFormat')) { + return; + } + this.namingSampleModel.fetch({ data: this.model.toJSON() }); }, _showSamples: function () { this.ui.singleEpisodeExample.html(this.namingSampleModel.get('singleEpisodeExample')); this.ui.multiEpisodeExample.html(this.namingSampleModel.get('multiEpisodeExample')); + this.ui.dailyEpisodeExample.html(this.namingSampleModel.get('dailyEpisodeExample')); + }, + + _addToken: function (e) { + e.preventDefault(); + e.stopPropagation(); + + var target = e.target; + var token = ''; + var input = this.$(target).closest('.x-helper-input').children('input'); + + if (this.$(target).attr('data-token')) { + token = '{{0}}'.format(this.$(target).attr('data-token')); + } + + else { + token = this.$(target).attr('data-separator'); + } + + input.val(input.val() + token); + + this.ui.namingTokenHelper.removeClass('open'); + input.focus(); } }); - return AsModelBoundView.call(view); + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; }); diff --git a/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html b/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html index 657be7b83..a8eb890d2 100644 --- a/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html +++ b/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html @@ -23,96 +23,60 @@ </div> <div class="x-naming-options"> - <div class="control-group"> - <label class="control-label">Include Series Title</label> + <div class="basic-setting x-basic-naming"></div> + + <div class="control-group advanced-setting"> + <label class="control-label">Standard Episode Format</label> <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="includeSeriesTitle"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> + <div class="input-append x-helper-input"> + <input type="text" class="naming-format" name="standardEpisodeFormat" data-onkeyup="true" /> + <div class="btn-group x-naming-token-helper"> + <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> + <i class="icon-plus"></i> + </button> + <ul class="dropdown-menu"> + {{> SeriesTitleNamingPartial}} + {{> SeasonNamingPartial}} + {{> EpisodeNamingPartial}} + {{> EpisodeTitleNamingPartial}} + {{> QualityTitleNamingPartial}} + {{> SeparatorNamingPartial}} + </ul> + </div> + </div> + <span class="help-inline"> + <i class="icon-nd-form-info" title="" data-original-title="All caps or all lower-case can also be used"></i> + <a href="https://github.com/NzbDrone/NzbDrone/wiki/Sorting-and-Renaming" class="help-link" title="More information"><i class="icon-nd-form-info-link"/></a> + </span> </div> </div> - <div class="control-group"> - <label class="control-label">Include Episode Title</label> + <div class="control-group advanced-setting"> + <label class="control-label">Daily Episode Format</label> <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="includeEpisodeTitle"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Include Quality</label> - - <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="includeQuality"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Replace Spaces</label> - - <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="replaceSpaces"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Separator</label> - - <div class="controls"> - <select class="inputClass" name="separator"> - <option value=" - ">Dash</option> - <option value=" ">Space</option> - <option value=".">Period</option> - </select> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Numbering Style</label> - - <div class="controls"> - <select class="inputClass" name="numberStyle"> - <option value="0">1x05</option> - <option value="1">01x05</option> - <option value="2">S01E05</option> - <option value="3">s01e05</option> - </select> + <div class="input-append x-helper-input"> + <input type="text" class="naming-format" name="dailyEpisodeFormat" data-onkeyup="true" /> + <div class="btn-group x-naming-token-helper"> + <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> + <i class="icon-plus"></i> + </button> + <ul class="dropdown-menu"> + {{> SeriesTitleNamingPartial}} + {{> AirDateNamingPartial}} + {{> SeasonNamingPartial}} + {{> EpisodeNamingPartial}} + {{> EpisodeTitleNamingPartial}} + {{> QualityTitleNamingPartial}} + {{> SeparatorNamingPartial}} + </ul> + </div> + </div> + <span class="help-inline"> + <i class="icon-nd-form-info" title="" data-original-title="All caps or all lower-case can also be used"></i> + <a href="https://github.com/NzbDrone/NzbDrone/wiki/Sorting-and-Renaming" class="help-link" title="More information"><i class="icon-nd-form-info-link"/></a> + </span> </div> </div> @@ -130,6 +94,26 @@ </div> </div> + <div class="control-group"> + <label class="control-label">Season Folder Format</label> + + <div class="controls"> + <div class="input-append x-helper-input"> + <input type="text" class="naming-format" name="seasonFolderFormat"/> + <div class="btn-group x-naming-token-helper"> + <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> + <i class="icon-plus"></i> + </button> + <ul class="dropdown-menu"> + {{> SeriesTitleNamingPartial}} + {{> SeasonNamingPartial}} + {{> SeparatorNamingPartial}} + </ul> + </div> + </div> + </div> + </div> + <div class="control-group"> <label class="control-label">Single Episode Example</label> @@ -145,4 +129,12 @@ <span class="x-multi-episode-example naming-example"></span> </div> </div> + + <div class="control-group"> + <label class="control-label">Daily-Episode Example</label> + + <div class="controls"> + <span class="x-daily-episode-example naming-example"></span> + </div> + </div> </fieldset> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/AirDateNamingPartial.html b/src/UI/Settings/MediaManagement/Naming/Partials/AirDateNamingPartial.html new file mode 100644 index 000000000..ed845e2c0 --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Partials/AirDateNamingPartial.html @@ -0,0 +1,9 @@ +<li class="dropdown-submenu"> + <a href="#" tabindex="-1" data-token="Air-Date">Air-Date</a> + <ul class="dropdown-menu"> + <li><a href="#" data-token="Air-Date">Air-Date</a></li> + <li><a href="#" data-token="Air Date">Air Date</a></li> + <li><a href="#" data-token="Air.Date">Air.Date</a></li> + <li><a href="#" data-token="Air_Date">Air_Date</a></li> + </ul> +</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeNamingPartial.html b/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeNamingPartial.html new file mode 100644 index 000000000..4c20f4ffa --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeNamingPartial.html @@ -0,0 +1,7 @@ +<li class="dropdown-submenu"> + <a href="#" tabindex="-1" data-token="episode">Episode</a> + <ul class="dropdown-menu"> + <li><a href="#" data-token="episode">1</a></li> + <li><a href="#" data-token="episode:00">01</a></li> + </ul> +</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeTitleNamingPartial.html b/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeTitleNamingPartial.html new file mode 100644 index 000000000..d4ae003d6 --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeTitleNamingPartial.html @@ -0,0 +1,8 @@ +<li class="dropdown-submenu"> + <a href="#" tabindex="-1" data-token="Episode Title">Episode Title</a> + <ul class="dropdown-menu"> + <li><a href="#" data-token="Episode Title">Episode Title</a></li> + <li><a href="#" data-token="Episode.Title">Episode.Title</a></li> + <li><a href="#" data-token="Episode_Title">Episode_Title</a></li> + </ul> +</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/QualityTitleNamingPartial.html b/src/UI/Settings/MediaManagement/Naming/Partials/QualityTitleNamingPartial.html new file mode 100644 index 000000000..4fe8dc65e --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Partials/QualityTitleNamingPartial.html @@ -0,0 +1,8 @@ +<li class="dropdown-submenu"> + <a href="#" tabindex="-1" data-token="Quality Title">Quality Title</a> + <ul class="dropdown-menu"> + <li><a href="#" data-token="Quality Title">Quality Title</a></li> + <li><a href="#" data-token="Quality.Title">Quality.Title</a></li> + <li><a href="#" data-token="Quality_Title">Quality_Title</a></li> + </ul> +</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/SeasonNamingPartial.html b/src/UI/Settings/MediaManagement/Naming/Partials/SeasonNamingPartial.html new file mode 100644 index 000000000..2c56024da --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Partials/SeasonNamingPartial.html @@ -0,0 +1,7 @@ +<li class="dropdown-submenu"> + <a href="#" tabindex="-1" data-token="season">Season</a> + <ul class="dropdown-menu"> + <li><a href="#" data-token="season">1</a></li> + <li><a href="#" data-token="season:00">01</a></li> + </ul> +</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/SeparatorNamingPartial.html b/src/UI/Settings/MediaManagement/Naming/Partials/SeparatorNamingPartial.html new file mode 100644 index 000000000..eb2abe61a --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Partials/SeparatorNamingPartial.html @@ -0,0 +1,10 @@ +<li class="dropdown-submenu"> + <a href="#" tabindex="-1">Separator</a> + <ul class="dropdown-menu"> + <li><a href="#" data-separator=" - ">Space-Dash-Space</a></li> + <li><a href="#" data-separator="-">Dash</a></li> + <li><a href="#" data-separator=" ">Space</a></li> + <li><a href="#" data-separator=".">Period</a></li> + <li><a href="#" data-separator="_">Underscore</a></li> + </ul> +</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.html b/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.html new file mode 100644 index 000000000..f2cd5fd81 --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.html @@ -0,0 +1,8 @@ +<li class="dropdown-submenu"> + <a href="#" tabindex="-1" data-token="Series Title">Series Title</a> + <ul class="dropdown-menu"> + <li><a href="#" data-token="Series Title">Series Title</a></li> + <li><a href="#" data-token="Series.Title">Series.Title</a></li> + <li><a href="#" data-token="Series_Title">Series_Title</a></li> + </ul> +</li> diff --git a/src/UI/Settings/MediaManagement/Sorting/ViewTemplate.html b/src/UI/Settings/MediaManagement/Sorting/ViewTemplate.html index 7bb516f42..dde9a2d84 100644 --- a/src/UI/Settings/MediaManagement/Sorting/ViewTemplate.html +++ b/src/UI/Settings/MediaManagement/Sorting/ViewTemplate.html @@ -1,7 +1,7 @@ -<fieldset> +<fieldset class="advanced-setting"> <legend>Folders</legend> - <div class="control-group advanced-setting"> + <div class="control-group"> <label class="control-label">Create empty series folders</label> <div class="controls"> @@ -21,33 +21,4 @@ </span> </div> </div> - - <!--TODO: Remove this and move it to Add Series--> - <div class="control-group"> - <label class="control-label">Use Season Folder</label> - - <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="useSeasonFolder"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Season Folder Format</label> - - <div class="controls"> - <input type="text" placeholder="Season %s" name="seasonFolderFormat"/> - <span class="help-inline"> - <i class="icon-question-sign" title="How should season folders be named? (Use %0s to pad to two digits, %sn for Series Name)"/> - </span> - </div> - </div> </fieldset> diff --git a/src/UI/Settings/SettingsLayout.js b/src/UI/Settings/SettingsLayout.js index 20ae6912d..a8b281cf8 100644 --- a/src/UI/Settings/SettingsLayout.js +++ b/src/UI/Settings/SettingsLayout.js @@ -6,7 +6,7 @@ define( 'backbone', 'Settings/SettingsModel', 'Settings/General/GeneralSettingsModel', - 'Settings/MediaManagement/Naming/Model', + 'Settings/MediaManagement/Naming/NamingModel', 'Settings/MediaManagement/MediaManagementLayout', 'Settings/Quality/QualityLayout', 'Settings/Indexers/IndexerLayout', diff --git a/src/UI/Settings/settings.less b/src/UI/Settings/settings.less index 5addfd8fb..528a7f37b 100644 --- a/src/UI/Settings/settings.less +++ b/src/UI/Settings/settings.less @@ -45,6 +45,10 @@ li.save-and-add:hover { margin-top: 5px; } +.naming-format { + width: 500px; +} + .advanced-settings-toggle { margin-right: 40px; @@ -73,8 +77,16 @@ li.save-and-add:hover { } } +.basic-setting { + display: block; +} + .show-advanced-settings { .advanced-setting { display: block; } -} \ No newline at end of file + + .basic-setting { + display: none; + } +} diff --git a/src/UI/Shared/Modal/Controller.js b/src/UI/Shared/Modal/Controller.js index 349db691d..199aed4e6 100644 --- a/src/UI/Shared/Modal/Controller.js +++ b/src/UI/Shared/Modal/Controller.js @@ -8,12 +8,13 @@ define( 'Series/Delete/DeleteSeriesView', 'Episode/EpisodeDetailsLayout', 'History/Details/HistoryDetailsView', - 'System/Logs/Table/Details/LogDetailsView' + 'System/Logs/Table/Details/LogDetailsView', ], function (vent, AppLayout, Marionette, EditSeriesView, DeleteSeriesView, EpisodeDetailsLayout, HistoryDetailsView, LogDetailsView) { return Marionette.AppRouter.extend({ initialize: function () { + vent.on(vent.Commands.OpenModalCommand, this._openModal, this); vent.on(vent.Commands.CloseModalCommand, this._closeModal, this); vent.on(vent.Commands.EditSeriesCommand, this._editSeries, this); vent.on(vent.Commands.DeleteSeriesCommand, this._deleteSeries, this); @@ -22,6 +23,10 @@ define( vent.on(vent.Commands.ShowLogDetails, this._showLogDetails, this); }, + _openModal: function (view) { + AppLayout.modalRegion.show(view); + }, + _closeModal: function () { AppLayout.modalRegion.closeModal(); }, diff --git a/src/UI/vent.js b/src/UI/vent.js index 7ee771295..348e9c461 100644 --- a/src/UI/vent.js +++ b/src/UI/vent.js @@ -18,12 +18,14 @@ define( vent.Commands = { EditSeriesCommand : 'EditSeriesCommand', DeleteSeriesCommand: 'DeleteSeriesCommand', + OpenModalCommand : 'OpenModalCommand', CloseModalCommand : 'CloseModalCommand', ShowEpisodeDetails : 'ShowEpisodeDetails', ShowHistoryDetails : 'ShowHistoryDetails', ShowLogDetails : 'ShowLogDetails', SaveSettings : 'saveSettings', - ShowLogFile : 'showLogFile' + ShowLogFile : 'showLogFile', + ShowNamingWizard : 'showNamingWizard' }; return vent;