New: PartNumber and PartCount naming tokens

pull/1064/head
ta264 4 years ago
parent a0e2747004
commit 4d840d6f43

@ -87,6 +87,12 @@ class Naming extends Component {
standardBookFormatErrors.push({ message: 'Single Book: Invalid Format' }); 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) { if (examples.authorFolderExample) {
authorFolderFormatHelpTexts.push(`Example: ${examples.authorFolderExample}`); authorFolderFormatHelpTexts.push(`Example: ${examples.authorFolderExample}`);
} else { } else {

@ -35,6 +35,14 @@ const fileNameTokens = [
{ {
token: '{Author.Name}.{Book.Title}.{Quality.Full}', token: '{Author.Name}.{Book.Title}.{Quality.Full}',
example: 'Author.Name.Book.Title.MP3' 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)'
} }
]; ];

@ -246,6 +246,72 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
.Should().Be("Hybrid.Theory.2000"); .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] [Test]
public void should_replace_quality_title() public void should_replace_quality_title()
{ {
@ -367,7 +433,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{ {
_namingConfig.StandardBookFormat = "{Author.Name}.{Book.Title}"; _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"); .Should().Be("In.The.Woods.30.Rock");
} }
@ -376,7 +442,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{ {
_namingConfig.StandardBookFormat = "{Author.Name}.{Book.Title}"; _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"); .Should().Be("In.The.Woods.30.Rock");
} }

@ -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)}'");
}
}
}

@ -35,6 +35,8 @@ namespace NzbDrone.Core.Organizer
private static readonly Regex TitleRegex = new Regex(@"\{(?<prefix>[- ._\[(]*)(?<token>(?:[a-z0-9]+)(?:(?<separator>[- ._]+)(?:[a-z0-9]+))?)(?::(?<customFormat>[a-z0-9]+))?(?<suffix>[- ._)\]]*)\}", private static readonly Regex TitleRegex = new Regex(@"\{(?<prefix>[- ._\[(]*)(?<token>(?:[a-z0-9]+)(?:(?<separator>[- ._]+)(?:[a-z0-9]+))?)(?::(?<customFormat>[a-z0-9]+))?(?<suffix>[- ._)\]]*)\}",
RegexOptions.Compiled | RegexOptions.IgnoreCase); RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static readonly Regex PartRegex = new Regex(@"\{(?<prefix>[^{]*?)(?<token1>PartNumber|PartCount)(?::(?<customFormat1>[a-z0-9]+))?(?<separator>.*(?=PartNumber|PartCount))?((?<token2>PartNumber|PartCount)(?::(?<customFormat2>[a-z0-9]+))?)?(?<suffix>[^}]*)\}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?<separator>(?<=})[- ._]+?)?(?<seasonEpisode>s?{season(?:\:0+)?}(?<episodeSeparator>[- ._]?[ex])(?<episode>{episode(?:\:0+)?}))(?<separator>[- ._]+?(?={))?", public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?<separator>(?<=})[- ._]+?)?(?<seasonEpisode>s?{season(?:\:0+)?}(?<episodeSeparator>[- ._]?[ex])(?<episode>{episode(?:\:0+)?}))(?<separator>[- ._]+?(?={))?",
RegexOptions.Compiled | RegexOptions.IgnoreCase); RegexOptions.Compiled | RegexOptions.IgnoreCase);
@ -97,15 +99,12 @@ namespace NzbDrone.Core.Organizer
AddMediaInfoTokens(tokenHandlers, bookFile); AddMediaInfoTokens(tokenHandlers, bookFile);
AddPreferredWords(tokenHandlers, author, bookFile, preferredWords); 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 = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString());
fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty); fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty);
if (bookFile.PartCount > 1)
{
fileName = fileName + " (" + bookFile.Part + ")";
}
return fileName; return fileName;
} }
@ -277,6 +276,12 @@ namespace NzbDrone.Core.Organizer
tokenHandlers["{Original Title}"] = m => GetOriginalTitle(bookFile); tokenHandlers["{Original Title}"] = m => GetOriginalTitle(bookFile);
tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(bookFile); tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(bookFile);
tokenHandlers["{Release Group}"] = m => bookFile.ReleaseGroup ?? m.DefaultValue("Readarr"); 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<string, Func<TokenMatch, string>> tokenHandlers, Author author, BookFile bookFile) private void AddQualityTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Author author, BookFile bookFile)
@ -374,6 +379,40 @@ namespace NzbDrone.Core.Organizer
return replacementText; return replacementText;
} }
private string ReplacePartTokens(string pattern, Dictionary<string, Func<TokenMatch, string>> tokenHandlers, NamingConfig namingConfig)
{
return PartRegex.Replace(pattern, match => ReplacePartToken(match, tokenHandlers, namingConfig));
}
private string ReplacePartToken(Match match, Dictionary<string, Func<TokenMatch, string>> 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) private BookFormat[] GetTrackFormat(string pattern)
{ {
return _trackFormatCache.Get(pattern, () => SeasonEpisodePatternRegex.Matches(pattern).OfType<Match>() return _trackFormatCache.Get(pattern, () => SeasonEpisodePatternRegex.Matches(pattern).OfType<Match>()

@ -21,6 +21,7 @@ namespace NzbDrone.Core.Organizer
private static Book _standardBook; private static Book _standardBook;
private static Edition _standardEdition; private static Edition _standardEdition;
private static BookFile _singleTrackFile; private static BookFile _singleTrackFile;
private static BookFile _multiTrackFile;
private static List<string> _preferredWords; private static List<string> _preferredWords;
public FileNameSampleService(IBuildFileNames buildFileNames) public FileNameSampleService(IBuildFileNames buildFileNames)
@ -66,7 +67,21 @@ namespace NzbDrone.Core.Organizer
SceneName = "Author.Name.Book.Name.TrackNum.Track.Title.MP3256", SceneName = "Author.Name.Book.Name.TrackNum.Track.Title.MP3256",
ReleaseGroup = "RlsGrp", ReleaseGroup = "RlsGrp",
MediaInfo = mediaInfo, 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<string> _preferredWords = new List<string>
@ -92,7 +107,7 @@ namespace NzbDrone.Core.Organizer
{ {
var result = new SampleResult var result = new SampleResult
{ {
FileName = BuildTrackSample(_standardAuthor, _singleTrackFile, nameSpec), FileName = BuildTrackSample(_standardAuthor, _multiTrackFile, nameSpec),
Author = _standardAuthor, Author = _standardAuthor,
Book = _standardBook, Book = _standardBook,
BookFile = _singleTrackFile BookFile = _singleTrackFile

@ -25,7 +25,7 @@ namespace NzbDrone.Core.Organizer
public class ValidStandardTrackFormatValidator : PropertyValidator public class ValidStandardTrackFormatValidator : PropertyValidator
{ {
public ValidStandardTrackFormatValidator() 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; 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; return false;
} }

@ -25,7 +25,7 @@ namespace NzbDrone.Integration.Test.ApiTests
{ {
var config = NamingConfig.GetSingle(); var config = NamingConfig.GetSingle();
config.RenameBooks = false; config.RenameBooks = false;
config.StandardBookFormat = "{Author Name} - {Book Title}"; config.StandardBookFormat = "{Author Name} - {Book Title}{ (PartNumber)}";
var result = NamingConfig.Put(config); var result = NamingConfig.Put(config);
result.RenameBooks.Should().BeFalse(); result.RenameBooks.Should().BeFalse();

@ -76,11 +76,16 @@ namespace Readarr.Api.V1.Config
var sampleResource = new NamingExampleResource(); var sampleResource = new NamingExampleResource();
var singleTrackSampleResult = _filenameSampleService.GetStandardTrackSample(nameSpec); var singleTrackSampleResult = _filenameSampleService.GetStandardTrackSample(nameSpec);
var multiDiscTrackSampleResult = _filenameSampleService.GetMultiDiscTrackSample(nameSpec);
sampleResource.SingleBookExample = _filenameValidationService.ValidateTrackFilename(singleTrackSampleResult) != null sampleResource.SingleBookExample = _filenameValidationService.ValidateTrackFilename(singleTrackSampleResult) != null
? null ? null
: singleTrackSampleResult.FileName; : singleTrackSampleResult.FileName;
sampleResource.MultiPartBookExample = _filenameValidationService.ValidateTrackFilename(multiDiscTrackSampleResult) != null
? null
: multiDiscTrackSampleResult.FileName;
sampleResource.AuthorFolderExample = nameSpec.AuthorFolderFormat.IsNullOrWhiteSpace() sampleResource.AuthorFolderExample = nameSpec.AuthorFolderFormat.IsNullOrWhiteSpace()
? null ? null
: _filenameSampleService.GetAuthorFolderSample(nameSpec); : _filenameSampleService.GetAuthorFolderSample(nameSpec);

@ -5,6 +5,7 @@ namespace Readarr.Api.V1.Config
public class NamingExampleResource public class NamingExampleResource
{ {
public string SingleBookExample { get; set; } public string SingleBookExample { get; set; }
public string MultiPartBookExample { get; set; }
public string AuthorFolderExample { get; set; } public string AuthorFolderExample { get; set; }
} }

Loading…
Cancel
Save