New: PartNumber and PartCount naming tokens

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

@ -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 {

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

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

@ -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>[- ._)\]]*)\}",
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>[- ._]+?(?={))?",
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<string, Func<TokenMatch, string>> tokenHandlers, Author author, BookFile bookFile)
@ -374,6 +379,40 @@ namespace NzbDrone.Core.Organizer
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)
{
return _trackFormatCache.Get(pattern, () => SeasonEpisodePatternRegex.Matches(pattern).OfType<Match>()

@ -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<string> _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<string>
@ -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

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

@ -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();

@ -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);

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

Loading…
Cancel
Save