From f21c59ba5a3a3cd60a2829fb9e422910216fccf7 Mon Sep 17 00:00:00 2001 From: Nik Khosravipour Date: Sun, 4 Oct 2026 17:49:32 +0200 Subject: [PATCH] Add {Edition AudibleAsin} naming token Renders the selected edition's Audible ASIN (empty when missing so conditional brackets collapse). Always uppercase regardless of token spelling, since Audiobookshelf only recognises [A-Z0-9]{10}. --- .../MediaManagement/Naming/NamingModal.js | 2 + .../FileNameBuilderEditionTokensFixture.cs | 104 ++++++++++++++++++ .../Organizer/FileNameBuilder.cs | 12 +- .../Organizer/FileNameSampleService.cs | 1 + .../NamingPattern/PatternCompiler.cs | 1 + 5 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/Chaptarr.Core.Test/Organizer/FileNameBuilderEditionTokensFixture.cs diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js index e29edba0..860dd69e 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -83,6 +83,8 @@ const bookTokens = [ { token: '{Book Disambiguation}', example: 'Disambiguation' }, + { token: '{Edition AudibleAsin}', example: 'B0ABCDEFGH' }, + { token: '{PartNumber:0}', example: '2' }, { token: '{PartNumber:00}', example: '02' }, { token: '{PartNumber:smart}', example: '02' }, diff --git a/src/Chaptarr.Core.Test/Organizer/FileNameBuilderEditionTokensFixture.cs b/src/Chaptarr.Core.Test/Organizer/FileNameBuilderEditionTokensFixture.cs new file mode 100644 index 00000000..3c7d8e34 --- /dev/null +++ b/src/Chaptarr.Core.Test/Organizer/FileNameBuilderEditionTokensFixture.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using NLog; +using NUnit.Framework; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Blocklisting; +using NzbDrone.Core.Books; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.History; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace Chaptarr.Core.Test.Organizer +{ + [TestFixture] + public class FileNameBuilderEditionTokensFixture + { + private class StubNamingConfigService : INamingConfigService + { + public NamingConfig GetConfig() => NamingConfig.Default; + public void Save(NamingConfig namingConfig) { } + } + + private class StubQualityDefinitionService : IQualityDefinitionService + { + public void Update(QualityDefinition qualityDefinition) => throw new NotImplementedException(); + public void UpdateMany(List qualityDefinitions) => throw new NotImplementedException(); + public List All() => throw new NotImplementedException(); + public QualityDefinition GetById(int id) => throw new NotImplementedException(); + public QualityDefinition Get(Quality quality) => new QualityDefinition(quality); + } + + private class StubCustomFormatCalculationService : ICustomFormatCalculationService + { + public List ParseCustomFormat(RemoteBook remoteBook, long size) => new(); + public List ParseCustomFormat(BookFile bookFile, Author artist) => new(); + public List ParseCustomFormat(BookFile bookFile) => new(); + public List ParseCustomFormat(Blocklist blocklist, Author artist) => new(); + public List ParseCustomFormat(EntityHistory history, Author artist) => new(); + public List ParseCustomFormat(LocalBook localBook) => new(); + } + + private static string Build(string format, Edition edition) + { + var builder = new FileNameBuilder( + new StubNamingConfigService(), + new StubQualityDefinitionService(), + new CacheManager(), + new StubCustomFormatCalculationService(), + LogManager.GetCurrentClassLogger()); + + var author = new Author { Name = "Jim Butcher" }; + edition.Book = new Book { Author = author }; + + var bookFile = new BookFile + { + Path = "/books/x.m4b", + Quality = new QualityModel(Quality.M4B) + }; + + var namingConfig = NamingConfig.Default; + namingConfig.RenameBooks = true; + namingConfig.StandardBookFormat = format; + + return builder.BuildBookFileName(author, edition, bookFile, namingConfig, customFormats: new List()); + } + + [Test] + public void should_render_audible_asin_in_brackets() + { + var edition = new Edition { Title = "Grave Peril", AudibleASIN = "B0ABCDEFGH" }; + + Assert.That(Build("{Book Title}{ [Edition AudibleAsin]}", edition), Is.EqualTo("Grave Peril [B0ABCDEFGH]")); + } + + [Test] + public void should_collapse_audible_asin_bracket_when_missing() + { + var edition = new Edition { Title = "Grave Peril", Asin = "0451457811", Isbn13 = "9780451457813" }; + + Assert.That(Build("{Book Title}{ [Edition AudibleAsin]}", edition), Is.EqualTo("Grave Peril")); + } + + [Test] + public void should_keep_audible_asin_uppercase_when_token_is_spelled_lowercase() + { + var edition = new Edition { Title = "Grave Peril", AudibleASIN = "b0abcdefgh" }; + + Assert.That(Build("{Book Title}{ [edition audibleasin]}", edition), Is.EqualTo("Grave Peril [B0ABCDEFGH]")); + Assert.That(Build("{Book Title}{ [edition.audibleasin]}", edition), Is.EqualTo("Grave Peril [B0ABCDEFGH]")); + } + + [Test] + public void should_not_change_formats_without_audible_asin_token() + { + var edition = new Edition { Title = "Grave Peril", AudibleASIN = "B0ABCDEFGH" }; + + Assert.That(Build("{Book Title}", edition), Is.EqualTo("Grave Peril")); + Assert.That(Build("{book title}", edition), Is.EqualTo("grave peril")); + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 23c20cf5..7c3ba976 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -407,6 +407,8 @@ private static bool IsInitialToken(string token) return segmentCount > 0; } + private const string AudibleAsinToken = "{Edition AudibleAsin}"; + private void AddBookTokens(Dictionary> tokenHandlers, Edition edition) { tokenHandlers["{Book Title}"] = m => edition.Title; @@ -494,6 +496,10 @@ private void AddBookTokens(Dictionary> tokenHan return bookTitle; }; + // Audible ASIN of the selected edition only (never an ISBN or a non-Audible ASIN; Edition.Asin may hold ISBN-10). + // Empty when missing so conditional brackets such as {[Edition AudibleAsin]} collapse. + tokenHandlers[AudibleAsinToken] = m => (edition.AudibleASIN ?? string.Empty).Trim().ToUpperInvariant(); + if (edition.Disambiguation != null) { tokenHandlers["{Book Disambiguation}"] = m => edition.Disambiguation; @@ -788,7 +794,11 @@ private string ReplaceToken(Match match, Dictionary !char.IsLetter(t) || char.IsLower(t))) + if (FileNameBuilderTokenEqualityComparer.Instance.Equals(tokenMatch.Token, AudibleAsinToken)) + { + // ASINs must stay uppercase ([A-Z0-9]{10}); do not mirror the token spelling's case. + } + else if (tokenMatch.Token.All(t => !char.IsLetter(t) || char.IsLower(t))) { replacementText = replacementText.ToLower(); } diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index 086fcf49..d83e379a 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -59,6 +59,7 @@ public FileNameSampleService(IBuildFileNames buildFileNames) _standardEdition = new Edition { Title = "The Edition Title", + AudibleASIN = "B0ABCDEFGH", Book = _standardBook, NarratorNames = new List { "Alice Reader", "Bob Voice" } }; diff --git a/src/NzbDrone.Core/Organizer/NamingPattern/PatternCompiler.cs b/src/NzbDrone.Core/Organizer/NamingPattern/PatternCompiler.cs index 307d98bb..943e8046 100644 --- a/src/NzbDrone.Core/Organizer/NamingPattern/PatternCompiler.cs +++ b/src/NzbDrone.Core/Organizer/NamingPattern/PatternCompiler.cs @@ -60,6 +60,7 @@ public class PatternCompiler : IPatternCompiler { "ReleaseYear", "{Release Year}" }, { "ReleaseYearFirst", "{Release YearFirst}" }, { "EditionYear", "{Edition Year}" }, + { "EditionAudibleAsin", "{Edition AudibleAsin}" }, // GraphicAudio tokens { "GraphicAudio", "{GraphicAudio}" },