mirror of
https://github.com/Readarr/Readarr
synced 2026-02-11 09:13:51 +01:00
New: PartNumber and PartCount naming tokens
This commit is contained in:
parent
a0e2747004
commit
4d840d6f43
10 changed files with 168 additions and 13 deletions
|
|
@ -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 @@ public void should_cleanup_Book_Title()
|
|||
.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 @@ public void should_replace_double_period_with_single_period()
|
|||
{
|
||||
_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 @@ public void should_replace_triple_period_with_single_period()
|
|||
{
|
||||
_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 @@ public class FileNameBuilder : IBuildFileNames
|
|||
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 @@ public string BuildBookFileName(Author author, Edition edition, BookFile bookFil
|
|||
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 @@ private void AddBookFileTokens(Dictionary<string, Func<TokenMatch, string>> toke
|
|||
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 @@ private string ReplaceToken(Match match, Dictionary<string, Func<TokenMatch, str
|
|||
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 @@ public class FileNameSampleService : IFilenameSampleService
|
|||
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 @@ public FileNameSampleService(IBuildFileNames buildFileNames)
|
|||
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 @@ public SampleResult GetMultiDiscTrackSample(NamingConfig nameSpec)
|
|||
{
|
||||
var result = new SampleResult
|
||||
{
|
||||
FileName = BuildTrackSample(_standardAuthor, _singleTrackFile, nameSpec),
|
||||
FileName = BuildTrackSample(_standardAuthor, _multiTrackFile, nameSpec),
|
||||
Author = _standardAuthor,
|
||||
Book = _standardBook,
|
||||
BookFile = _singleTrackFile
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public static IRuleBuilderOptions<T, string> ValidAuthorFolderFormat<T>(this IRu
|
|||
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 @@ protected override bool IsValid(PropertyValidatorContext context)
|
|||
{
|
||||
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 @@ public void should_be_able_to_update()
|
|||
{
|
||||
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 @@ public object GetExamples([FromQuery]NamingConfigResource 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…
Reference in a new issue