diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js index 30308ec77..5b9c82150 100644 --- a/frontend/src/Settings/MediaManagement/Naming/Naming.js +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -87,6 +87,12 @@ class Naming extends Component { standardBookFormatErrors.push({ message: 'Single Book: Invalid Format' }); } + if (examples.multiPartBookExample) { + standardBookFormatHelpTexts.push(`Multi-part Book: ${examples.multiPartBookExample}`); + } else { + standardBookFormatErrors.push({ message: 'Multi-part Book: Invalid Format' }); + } + if (examples.authorFolderExample) { authorFolderFormatHelpTexts.push(`Example: ${examples.authorFolderExample}`); } else { diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js index c66eff38b..c24006b16 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -35,6 +35,14 @@ const fileNameTokens = [ { token: '{Author.Name}.{Book.Title}.{Quality.Full}', example: 'Author.Name.Book.Title.MP3' + }, + { + token: '{Author Name} - {Book Title}{ (PartNumber)}', + example: 'Author Name - Book Title (2)' + }, + { + token: '{Author Name} - {Book Title}{ (PartNumber/PartCount)}', + example: 'Author Name - Book Title (2/10)' } ]; diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 69eb2eec3..d1085ce1f 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -246,6 +246,72 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests .Should().Be("Hybrid.Theory.2000"); } + [Test] + public void should_set_part_number() + { + _namingConfig.StandardBookFormat = "{(PartNumber)}"; + _trackFile.PartCount = 2; + _trackFile.Part = 1; + + Subject.BuildBookFileName(_author, _edition, _trackFile) + .Should().Be("(1)"); + } + + [Test] + public void should_set_part_number_with_prefix() + { + _namingConfig.StandardBookFormat = "{(ptPartNumber)}"; + _trackFile.PartCount = 2; + _trackFile.Part = 1; + + Subject.BuildBookFileName(_author, _edition, _trackFile) + .Should().Be("(pt1)"); + } + + [Test] + public void should_set_part_number_with_format() + { + _namingConfig.StandardBookFormat = "{(ptPartNumber:00)}"; + _trackFile.PartCount = 2; + _trackFile.Part = 1; + + Subject.BuildBookFileName(_author, _edition, _trackFile) + .Should().Be("(pt01)"); + } + + [Test] + public void should_set_part_number_and_count_with_format() + { + _namingConfig.StandardBookFormat = "{(ptPartNumber:00 of PartCount:00)}"; + _trackFile.PartCount = 2; + _trackFile.Part = 1; + + Subject.BuildBookFileName(_author, _edition, _trackFile) + .Should().Be("(pt01 of 02)"); + } + + [Test] + public void should_remove_part_token_for_single_files() + { + _namingConfig.StandardBookFormat = "{(ptPartNumber:00 of PartCount:00)}"; + _trackFile.PartCount = 1; + _trackFile.Part = 1; + + Subject.BuildBookFileName(_author, _edition, _trackFile) + .Should().Be(""); + } + + [Test] + public void part_regex_should_not_gobble_others() + { + _namingConfig.StandardBookFormat = "{Book Title}{ (PartNumber)} - {Author Name}"; + _trackFile.Part = 1; + _trackFile.PartCount = 2; + + Subject.BuildBookFileName(_author, _edition, _trackFile) + .Should().Be("Hybrid Theory (1) - Linkin Park"); + } + [Test] public void should_replace_quality_title() { @@ -367,7 +433,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests { _namingConfig.StandardBookFormat = "{Author.Name}.{Book.Title}"; - Subject.BuildBookFileName(new Author { Name = "In The Woods." }, new Edition { Title = "30 Rock" }, _trackFile) + Subject.BuildBookFileName(new Author { Name = "In The Woods." }, new Edition { Title = "30 Rock", Book = new Book() }, _trackFile) .Should().Be("In.The.Woods.30.Rock"); } @@ -376,7 +442,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests { _namingConfig.StandardBookFormat = "{Author.Name}.{Book.Title}"; - Subject.BuildBookFileName(new Author { Name = "In The Woods..." }, new Edition { Title = "30 Rock" }, _trackFile) + Subject.BuildBookFileName(new Author { Name = "In The Woods..." }, new Edition { Title = "30 Rock", Book = new Book() }, _trackFile) .Should().Be("In.The.Woods.30.Rock"); } diff --git a/src/NzbDrone.Core/Datastore/Migration/012_add_bookfile_part_naming_token.cs b/src/NzbDrone.Core/Datastore/Migration/012_add_bookfile_part_naming_token.cs new file mode 100644 index 000000000..fe5e48440 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/012_add_bookfile_part_naming_token.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(012)] + public class add_bookfile_part_naming_token : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.Sql("UPDATE NamingConfig SET StandardBookFormat = StandardBookFormat || '{ (PartNumber)}'"); + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index f8f8cbf39..d108be7ae 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -35,6 +35,8 @@ namespace NzbDrone.Core.Organizer private static readonly Regex TitleRegex = new Regex(@"\{(?[- ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[a-z0-9]+))?(?[- ._)\]]*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex PartRegex = new Regex(@"\{(?[^{]*?)(?PartNumber|PartCount)(?::(?[a-z0-9]+))?(?.*(?=PartNumber|PartCount))?((?PartNumber|PartCount)(?::(?[a-z0-9]+))?)?(?[^}]*)\}", + RegexOptions.Compiled | RegexOptions.IgnoreCase); public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?(?<=})[- ._]+?)?(?s?{season(?:\:0+)?}(?[- ._]?[ex])(?{episode(?:\:0+)?}))(?[- ._]+?(?={))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -97,15 +99,12 @@ namespace NzbDrone.Core.Organizer AddMediaInfoTokens(tokenHandlers, bookFile); AddPreferredWords(tokenHandlers, author, bookFile, preferredWords); - var fileName = ReplaceTokens(safePattern, tokenHandlers, namingConfig).Trim(); + var fileName = ReplacePartTokens(safePattern, tokenHandlers, namingConfig); + fileName = ReplaceTokens(fileName, tokenHandlers, namingConfig).Trim(); + fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty); - if (bookFile.PartCount > 1) - { - fileName = fileName + " (" + bookFile.Part + ")"; - } - return fileName; } @@ -277,6 +276,12 @@ namespace NzbDrone.Core.Organizer tokenHandlers["{Original Title}"] = m => GetOriginalTitle(bookFile); tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(bookFile); tokenHandlers["{Release Group}"] = m => bookFile.ReleaseGroup ?? m.DefaultValue("Readarr"); + + if (bookFile.PartCount > 1) + { + tokenHandlers["{PartNumber}"] = m => bookFile.Part.ToString(m.CustomFormat); + tokenHandlers["{PartCount}"] = m => bookFile.PartCount.ToString(m.CustomFormat); + } } private void AddQualityTokens(Dictionary> tokenHandlers, Author author, BookFile bookFile) @@ -374,6 +379,40 @@ namespace NzbDrone.Core.Organizer return replacementText; } + private string ReplacePartTokens(string pattern, Dictionary> tokenHandlers, NamingConfig namingConfig) + { + return PartRegex.Replace(pattern, match => ReplacePartToken(match, tokenHandlers, namingConfig)); + } + + private string ReplacePartToken(Match match, Dictionary> tokenHandlers, NamingConfig namingConfig) + { + var tokenHandler = tokenHandlers.GetValueOrDefault($"{{{match.Groups["token1"].Value}}}", m => string.Empty); + + var tokenText1 = tokenHandler(new TokenMatch { CustomFormat = match.Groups["customFormat1"].Success ? match.Groups["customFormat1"].Value : "0" }); + + if (tokenText1 == string.Empty) + { + return string.Empty; + } + + var prefix = match.Groups["prefix"].Value; + + var tokenText2 = string.Empty; + + var separator = match.Groups["separator"].Success ? match.Groups["separator"].Value : string.Empty; + + var suffix = match.Groups["suffix"].Value; + + if (match.Groups["token2"].Success) + { + tokenHandler = tokenHandlers.GetValueOrDefault($"{{{match.Groups["token2"].Value}}}", m => string.Empty); + + tokenText2 = tokenHandler(new TokenMatch { CustomFormat = match.Groups["customFormat2"].Success ? match.Groups["customFormat2"].Value : "0" }); + } + + return $"{prefix}{tokenText1}{separator}{tokenText2}{suffix}"; + } + private BookFormat[] GetTrackFormat(string pattern) { return _trackFormatCache.Get(pattern, () => SeasonEpisodePatternRegex.Matches(pattern).OfType() diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index a2882c119..57c5fdc76 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.Organizer private static Book _standardBook; private static Edition _standardEdition; private static BookFile _singleTrackFile; + private static BookFile _multiTrackFile; private static List _preferredWords; public FileNameSampleService(IBuildFileNames buildFileNames) @@ -66,7 +67,21 @@ namespace NzbDrone.Core.Organizer SceneName = "Author.Name.Book.Name.TrackNum.Track.Title.MP3256", ReleaseGroup = "RlsGrp", MediaInfo = mediaInfo, - Edition = _standardEdition + Edition = _standardEdition, + Part = 1, + PartCount = 1 + }; + + _multiTrackFile = new BookFile + { + Quality = new QualityModel(Quality.MP3, new Revision(2)), + Path = "/music/Author.Name.Book.Name.TrackNum.Track.Title.MP3256.mp3", + SceneName = "Author.Name.Book.Name.TrackNum.Track.Title.MP3256", + ReleaseGroup = "RlsGrp", + MediaInfo = mediaInfo, + Edition = _standardEdition, + Part = 1, + PartCount = 2 }; _preferredWords = new List @@ -92,7 +107,7 @@ namespace NzbDrone.Core.Organizer { var result = new SampleResult { - FileName = BuildTrackSample(_standardAuthor, _singleTrackFile, nameSpec), + FileName = BuildTrackSample(_standardAuthor, _multiTrackFile, nameSpec), Author = _standardAuthor, Book = _standardBook, BookFile = _singleTrackFile diff --git a/src/NzbDrone.Core/Organizer/FileNameValidation.cs b/src/NzbDrone.Core/Organizer/FileNameValidation.cs index 1a7aa77a0..cc8bbf70e 100644 --- a/src/NzbDrone.Core/Organizer/FileNameValidation.cs +++ b/src/NzbDrone.Core/Organizer/FileNameValidation.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Core.Organizer public class ValidStandardTrackFormatValidator : PropertyValidator { public ValidStandardTrackFormatValidator() - : base("Must contain Book Title") + : base("Must contain Book Title AND PartNumber, OR Original Title") { } @@ -33,7 +33,8 @@ namespace NzbDrone.Core.Organizer { var value = context.PropertyValue as string; - if (!FileNameBuilder.BookTitleRegex.IsMatch(value)) + if (!(FileNameBuilder.BookTitleRegex.IsMatch(value) && FileNameBuilder.PartRegex.IsMatch(value)) && + !FileNameValidation.OriginalTokenRegex.IsMatch(value)) { return false; } diff --git a/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs index e3cb00798..3236371df 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Integration.Test.ApiTests { var config = NamingConfig.GetSingle(); config.RenameBooks = false; - config.StandardBookFormat = "{Author Name} - {Book Title}"; + config.StandardBookFormat = "{Author Name} - {Book Title}{ (PartNumber)}"; var result = NamingConfig.Put(config); result.RenameBooks.Should().BeFalse(); diff --git a/src/Readarr.Api.V1/Config/NamingConfigController.cs b/src/Readarr.Api.V1/Config/NamingConfigController.cs index 1ba8e8457..2a2796c33 100644 --- a/src/Readarr.Api.V1/Config/NamingConfigController.cs +++ b/src/Readarr.Api.V1/Config/NamingConfigController.cs @@ -76,11 +76,16 @@ namespace Readarr.Api.V1.Config var sampleResource = new NamingExampleResource(); var singleTrackSampleResult = _filenameSampleService.GetStandardTrackSample(nameSpec); + var multiDiscTrackSampleResult = _filenameSampleService.GetMultiDiscTrackSample(nameSpec); sampleResource.SingleBookExample = _filenameValidationService.ValidateTrackFilename(singleTrackSampleResult) != null ? null : singleTrackSampleResult.FileName; + sampleResource.MultiPartBookExample = _filenameValidationService.ValidateTrackFilename(multiDiscTrackSampleResult) != null + ? null + : multiDiscTrackSampleResult.FileName; + sampleResource.AuthorFolderExample = nameSpec.AuthorFolderFormat.IsNullOrWhiteSpace() ? null : _filenameSampleService.GetAuthorFolderSample(nameSpec); diff --git a/src/Readarr.Api.V1/Config/NamingExampleResource.cs b/src/Readarr.Api.V1/Config/NamingExampleResource.cs index 0316319a8..e71e9d060 100644 --- a/src/Readarr.Api.V1/Config/NamingExampleResource.cs +++ b/src/Readarr.Api.V1/Config/NamingExampleResource.cs @@ -5,6 +5,7 @@ namespace Readarr.Api.V1.Config public class NamingExampleResource { public string SingleBookExample { get; set; } + public string MultiPartBookExample { get; set; } public string AuthorFolderExample { get; set; } }