diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 6fa8c4b2..83f1aa27 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -66,17 +66,20 @@ public static class UtilityExtensions Authors = libraryBook.Book.Authors.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(), Narrators = libraryBook.Book.Narrators.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(), + IsAbridged = libraryBook.Book.IsAbridged, Series = GetSeries(libraryBook.Book.SeriesLink), IsPodcastParent = libraryBook.Book.IsEpisodeParent(), IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), + LengthInMinutes = libraryBook.Book.LengthInMinutes, Language = libraryBook.Book.Language, Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate, SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate, Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount, LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion.ToVersionString(), - FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion + FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion, + Tags = libraryBook.Book.UserDefinedItem.TagsEnumerated.Select(s => new StringDto(s)).ToList(), }; } diff --git a/Source/FileManager/NamingTemplate/CommonFormatters.cs b/Source/FileManager/NamingTemplate/CommonFormatters.cs index 0b371327..85ad9177 100644 --- a/Source/FileManager/NamingTemplate/CommonFormatters.cs +++ b/Source/FileManager/NamingTemplate/CommonFormatters.cs @@ -24,7 +24,7 @@ public static partial class CommonFormatters return (templateTag, value, culture) => formatter(templateTag, value, null, culture); } - public static string? StringFinalizer(ITemplateTag templateTag, string? value, CultureInfo? culture) => value ?? ""; + public static string? StringFinalizer(ITemplateTag templateTag, string? value, CultureInfo? culture) => value; public static TPropertyValue? IdlePreFormatter(ITemplateTag templateTag, TPropertyValue? value, string? formatString, CultureInfo? culture) => value; @@ -110,6 +110,41 @@ public static partial class CommonFormatters return new string('0', zeroPad) + strValue; } + public static string MinutesFormatter(ITemplateTag templateTag, int value, string? formatString, CultureInfo? culture) + { + culture ??= CultureInfo.CurrentCulture; + if (string.IsNullOrEmpty(formatString)) + return value.ToString(culture); + + var timeSpan = TimeSpan.FromMinutes(value); + var result = formatString; + + // replace all placeholders with formatted values + result = RegexMinutesD().Replace(result, m => + { + var val = (int)timeSpan.TotalDays; + timeSpan = timeSpan.Subtract(TimeSpan.FromDays(val)); + + return FloatFormatter(templateTag, val, m.Groups["format"].Value, culture); + }); + result = RegexMinutesH().Replace(result, m => + { + var val = (int)timeSpan.TotalHours; + timeSpan = timeSpan.Subtract(TimeSpan.FromHours(val)); + + return FloatFormatter(templateTag, val, m.Groups["format"].Value, culture); + }); + result = RegexMinutesM().Replace(result, m => + { + var val = (int)timeSpan.TotalMinutes; + timeSpan = timeSpan.Subtract(TimeSpan.FromMinutes(val)); + + return FloatFormatter(templateTag, val, m.Groups["format"].Value, culture); + }); + + return result; + } + public static string DateTimeFormatter(ITemplateTag _, DateTime value, string? formatString, CultureInfo? culture) { culture ??= CultureInfo.InvariantCulture; @@ -122,4 +157,14 @@ public static partial class CommonFormatters { return StringFormatter(templateTag, language?.Trim(), "3u", culture); } + + // Regex to find patterns like {D:3}, {h:4}, {m} + [GeneratedRegex(@"\{D(?::(?.*?))?\}", RegexOptions.IgnoreCase)] + private static partial Regex RegexMinutesD(); + + [GeneratedRegex(@"\{H(?::(?.*?))?\}", RegexOptions.IgnoreCase)] + private static partial Regex RegexMinutesH(); + + [GeneratedRegex(@"\{M(?::(?.*?))?\}", RegexOptions.IgnoreCase)] + private static partial Regex RegexMinutesM(); } \ No newline at end of file diff --git a/Source/LibationFileManager/Templates/ContributorDto.cs b/Source/LibationFileManager/Templates/ContributorDto.cs index e7b09847..abac37dd 100644 --- a/Source/LibationFileManager/Templates/ContributorDto.cs +++ b/Source/LibationFileManager/Templates/ContributorDto.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using FileManager.NamingTemplate; -namespace LibationFileManager.Templates; +namespace LibationFileManager.Templates; public class ContributorDto(string name, string? audibleContributorId) : IFormattable { @@ -23,16 +23,12 @@ public class ContributorDto(string name, string? audibleContributorId) : IFormat { "ID", dto => dto.AudibleContributorId }, }; - public override string ToString() - => ToString("{T} {F} {M} {L} {S}", null); + public override string ToString() => ToString("{T} {F} {M} {L} {S}", null); public string ToString(string? format, IFormatProvider? provider) - { - if (string.IsNullOrWhiteSpace(format)) - return ToString(); - - return CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements); - } + => string.IsNullOrWhiteSpace(format) + ? ToString() + : CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements); private static string RemoveSuffix(string namesString) { diff --git a/Source/LibationFileManager/Templates/IListFormat[TList].cs b/Source/LibationFileManager/Templates/IListFormat[TList].cs index 2e3c8e4d..7333b014 100644 --- a/Source/LibationFileManager/Templates/IListFormat[TList].cs +++ b/Source/LibationFileManager/Templates/IListFormat[TList].cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; -using FileManager.NamingTemplate; namespace LibationFileManager.Templates; diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index dc10fd3a..9e5e7bc1 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -22,10 +22,12 @@ public class BookDto public IEnumerable? Series { get; set; } public SeriesDto? FirstSeries => Series?.FirstOrDefault(); + public bool IsAbridged { get; set; } public bool IsSeries => Series is not null; public bool IsPodcastParent { get; set; } public bool IsPodcast { get; set; } + public int LengthInMinutes { get; set; } public int? BitRate { get; set; } public int? SampleRate { get; set; } public int? Channels { get; set; } @@ -42,4 +44,5 @@ public class LibraryBookDto : BookDto public DateTime? DateAdded { get; set; } public string? Account { get; set; } public string? AccountNickname { get; set; } + public IEnumerable? Tags { get; set; } } diff --git a/Source/LibationFileManager/Templates/NameListFormat.cs b/Source/LibationFileManager/Templates/NameListFormat.cs index bc9a43d4..d1e8ebb0 100644 --- a/Source/LibationFileManager/Templates/NameListFormat.cs +++ b/Source/LibationFileManager/Templates/NameListFormat.cs @@ -52,7 +52,7 @@ internal partial class NameListFormat : IListFormat [GeneratedRegex($@"\G(?{Token})(?(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)] private static partial Regex SortTokenizer(); - /// Format must have at least one of the string {T}, {F}, {M}, {L}, {S}, or {ID} + /// Format must have at least one of the strings {T}, {F}, {M}, {L}, {S}, or {ID} [GeneratedRegex($@"[Ff]ormat\((?.*?\{{{Token}(?::.*?)?\}}.*?)\)")] public static partial Regex FormatRegex(); } diff --git a/Source/LibationFileManager/Templates/SeriesDto.cs b/Source/LibationFileManager/Templates/SeriesDto.cs index cc2dbb95..9309a155 100644 --- a/Source/LibationFileManager/Templates/SeriesDto.cs +++ b/Source/LibationFileManager/Templates/SeriesDto.cs @@ -18,10 +18,7 @@ public record SeriesDto(string? Name, string? Number, string AudibleSeriesId) : public override string? ToString() => Name?.Trim(); public string ToString(string? format, IFormatProvider? provider) - { - if (string.IsNullOrWhiteSpace(format)) - return ToString() ?? string.Empty; - - return CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements); - } + => string.IsNullOrWhiteSpace(format) + ? ToString() ?? string.Empty + : CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements); } diff --git a/Source/LibationFileManager/Templates/SeriesListFormat.cs b/Source/LibationFileManager/Templates/SeriesListFormat.cs index ab5fd5ed..ba939250 100644 --- a/Source/LibationFileManager/Templates/SeriesListFormat.cs +++ b/Source/LibationFileManager/Templates/SeriesListFormat.cs @@ -52,7 +52,7 @@ internal partial class SeriesListFormat : IListFormat [GeneratedRegex($@"\G(?{Token})(?(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)] private static partial Regex SortTokenizer(); - /// Format must have at least one of the string {N}, {#}, {ID} + /// Format must have at least one of the strings {N}, {#}, {ID} [GeneratedRegex($@"[Ff]ormat\((?.*?\{{{Token}(?::.*?)?\}}.*?)\)")] public static partial Regex FormatRegex(); } diff --git a/Source/LibationFileManager/Templates/StringDto.cs b/Source/LibationFileManager/Templates/StringDto.cs new file mode 100644 index 00000000..7c323122 --- /dev/null +++ b/Source/LibationFileManager/Templates/StringDto.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using FileManager.NamingTemplate; + +namespace LibationFileManager.Templates; + +public record StringDto(string Value) : IFormattable +{ + public static readonly Dictionary> FormatReplacements = new(StringComparer.OrdinalIgnoreCase) + { + { "S", dto => dto.Value } + }; + + public override string ToString() => Value; + + public string ToString(string? format, IFormatProvider? provider) + => string.IsNullOrWhiteSpace(format) + ? ToString() + : CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements); +} \ No newline at end of file diff --git a/Source/LibationFileManager/Templates/StringListFormat.cs b/Source/LibationFileManager/Templates/StringListFormat.cs new file mode 100644 index 00000000..05417244 --- /dev/null +++ b/Source/LibationFileManager/Templates/StringListFormat.cs @@ -0,0 +1,58 @@ +using System; +using FileManager.NamingTemplate; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace LibationFileManager.Templates; + +internal partial class StringListFormat : IListFormat +{ + public static IEnumerable Formatter(ITemplateTag _, IEnumerable? entries, string? formatString, CultureInfo? culture) + => entries is null + ? [] + : IListFormat.FormattedList(formatString, Sort(entries, formatString, StringDto.FormatReplacements), culture); + + public static string? Finalizer(ITemplateTag _, IEnumerable? entries, CultureInfo? culture) + => IListFormat.Join(entries, culture); + + private static IEnumerable Sort(IEnumerable entries, string? formatString, Dictionary> formatReplacements) + { + var pattern = formatString is null ? null : SortRegex().Match(formatString).ResolveValue("pattern"); + if (pattern is null) return entries; + + IOrderedEnumerable? ordered = null; + foreach (Match m in SortTokenizer().Matches(pattern!)) + { + // Dictionary is case-insensitive, no ToUpper needed + if (!formatReplacements.TryGetValue(m.Groups["token"].Value, out var selector)) + continue; + + ordered = m.Groups["descending"].Success + ? ordered is null + // ReSharper disable once PossibleMultipleEnumeration + ? entries.OrderByDescending(selector) + : ordered.ThenByDescending(selector) + : ordered is null + // ReSharper disable once PossibleMultipleEnumeration + ? entries.OrderBy(selector) + : ordered.ThenBy(selector); + } + + return ordered ?? entries; + } + + private const string Token = "S"; + + /// Sort must have at least one of the token labels T, F, M, L, S or ID. Use lower case for descending direction and add multiple tokens to sort by multiple fields. Spaces may be used to separate tokens. + [GeneratedRegex($@"[Ss]ort\(\s*(?i:(?(?:{Token}\s*?)+))\s*\)")] + private static partial Regex SortRegex(); + + [GeneratedRegex($@"\G(?{Token})(?(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)] + private static partial Regex SortTokenizer(); + + /// Format must have the string {S} (optionally with formatting like {S:u}) + [GeneratedRegex($@"[Ff]ormat\((?.*?\{{{Token}(?::.*?)?\}}.*?)\)")] + public static partial Regex FormatRegex(); +} \ No newline at end of file diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index fae7ae3a..ba68cf9c 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -34,6 +34,7 @@ public sealed class TemplateTags : ITemplateTag public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)"); public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series"); public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for "); + public static TemplateTags Minutes { get; } = new TemplateTags("minutes", "Length in minutes"); public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Bitrate (kbps) of the last downloaded audiobook"); public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Sample rate (Hz) of the last downloaded audiobook"); public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels in the last downloaded audiobook"); @@ -42,6 +43,7 @@ public sealed class TemplateTags : ITemplateTag public static TemplateTags LibationVersion { get; } = new TemplateTags("libation version", "Libation version used during last download of the audiobook"); public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book"); public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book"); + public static TemplateTags Tag { get; } = new TemplateTags("tag", "Tag(s)"); public static TemplateTags Locale { get; } = new("locale", "Region/country"); public static TemplateTags YearPublished { get; } = new("year", "Year published"); public static TemplateTags Language { get; } = new("language", "Book's language"); @@ -56,6 +58,7 @@ public sealed class TemplateTags : ITemplateTag public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<-if podcast>", "...<-if podcast>"); public static TemplateTags IfPodcastParent { get; } = new TemplateTags("if podcastparent", "Only include if item is a podcast series parent", "<-if podcastparent>", "...<-if podcastparent>"); public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<-if bookseries>", "...<-if bookseries>"); + public static TemplateTags IfAbridged { get; } = new TemplateTags("if abridged", "Only include if abridged", "<-if abridged>", "...<-if abridged>"); public static TemplateTags Has { get; } = new TemplateTags("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<-has>", "...<-has>"); public static TemplateTags Is { get; } = new TemplateTags("is", "Only include if PROPERTY has a value satisfying the check (i.e. string comparison)", "<-is>", "...<-is>"); } diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 51a3fc13..de5ea318 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -288,11 +288,13 @@ public abstract class Templates { TemplateTags.DatePublished, lb => lb.DatePublished }, { TemplateTags.DateAdded, lb => lb.DateAdded }, { TemplateTags.FileDate, lb => lb.FileDate }, + { TemplateTags.Tag, lb => lb.Tags, StringListFormat.Formatter, StringListFormat.Finalizer }, }; private static readonly PropertyTagCollection audioFilePropertyTags = new(caseSensitive: true, CommonFormatters.StringFormatter, CommonFormatters.IntegerFormatter) { + { TemplateTags.Minutes, lb => lb.LengthInMinutes, CommonFormatters.MinutesFormatter }, { TemplateTags.Bitrate, lb => lb.BitRate }, { TemplateTags.SampleRate, lb => lb.SampleRate }, { TemplateTags.Channels, lb => lb.Channels }, @@ -324,6 +326,7 @@ public abstract class Templates private static readonly ConditionalTagCollection conditionalTags = new() { + { TemplateTags.IfAbridged, lb => lb.IsAbridged }, { TemplateTags.IfSeries, lb => lb.IsSeries || lb.IsPodcastParent }, { TemplateTags.IfPodcast, lb => lb.IsPodcast || lb.IsPodcastParent }, { TemplateTags.IfBookseries, lb => lb is { IsSeries: true, IsPodcast: false, IsPodcastParent: false } }, diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index c37da967..7082ed01 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -57,6 +57,9 @@ namespace TemplatesTests Codec = "AAC-LC", FileVersion = null, // explicitly null LibationVersion = "", // explicitly empty string + LengthInMinutes = 100, + IsAbridged = true, + Tags = [new StringDto("Tag1"), new StringDto("Tag2"), new StringDto("Tag3")], }; } @@ -188,6 +191,31 @@ namespace TemplatesTests fileTemplate.GetFilename(bookDto, "", "", Replacements).PathWithoutPrefix.Should().Be(expected); } + [TestMethod] + [DataRow("", 100, "100")] + [DataRow("", 100, "100")] + [DataRow("", 100, "100")] + [DataRow("", 100, "1-40")] + [DataRow("", 100, "01-40")] + [DataRow("", 100, "0.01-40")] + [DataRow("", 100, "00d01h40m")] + [DataRow("", 100, "0 days, 1 hours, 40 minutes")] + [DataRow("", 2000, "33-20")] + [DataRow("", 2000, "001-009-020")] + [DataRow("", 100, "0-100")] + [DataRow("", 1500, "1-60")] + [DataRow("", 2000, "1-560")] + [DataRow("", 2880, "2-0")] + [DataRow("", 1500, "01-60")] + public void MinutesFormat(string template, int minutes, string expected) + { + var bookDto = GetLibraryBook(); + bookDto.LengthInMinutes = minutes; + + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + fileTemplate.GetFilename(bookDto, "", "", Replacements).PathWithoutPrefix.Should().Be(expected); + } + [TestMethod] [DataRow(" - ", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")] [DataRow(" - ", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")] @@ -492,6 +520,7 @@ namespace TemplatesTests [DataRow("true<-has>", "true")] [DataRow("=3]->true<-has>", "")] [DataRow("true<-has>", "")] + [DataRow("true<-has>", "true")] public void HasValue_test(string template, string expected) { var bookDto = GetLibraryBook(); @@ -614,6 +643,21 @@ namespace TemplatesTests .Should().Be(expected); } + [TestMethod] + [DataRow("Abridged<-if abridged>", "Abridged", true)] + [DataRow("Abridged<-if abridged>", "", false)] + public void IfAbridged_test(string template, string expected, bool isAbridged) + { + var bookDto = GetLibraryBook(); + bookDto.IsAbridged = isAbridged; + + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + + fileTemplate + .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }) + .Should().Be(expected); + } + [TestMethod] [DataRow("", "I", "en-US", "i")] [DataRow("", "ı", "tr-TR", "I")] @@ -630,6 +674,25 @@ namespace TemplatesTests .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }, culture) .Should().Be(expected); } + + [TestMethod] + [DataRow("", "Tag1, Tag2, Tag3")] + [DataRow("", "Tag1 - Tag2 - Tag3")] + [DataRow("", "TAG1, TAG2, TAG3")] + [DataRow("", "tag1, tag2, tag3")] + [DataRow("", "Tag: Tag1, Tag: Tag2, Tag: Tag3")] + [DataRow("", "Tag1")] + [DataRow("", "Tag3, Tag2, Tag1")] + public void Tag_test(string template, string expected) + { + var bookDto = Shared.GetLibraryBook(); + + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + + fileTemplate + .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }) + .Should().Be(expected); + } } } diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index d86c450e..7900c105 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -26,6 +26,7 @@ These tags will be replaced in the template with the audiobook's values. | \ | All series to which the book belongs (if any) | [Series List](#series-list-formatters) | | \ | First series | [Series](#series-formatters) | | \ | Number order in series (alias for \ | [Number](#number-formatters) | +| \ | Duration of the audiobook in minutes | [Minutes](#minutes-formatters) | | \ | Bitrate (kbps) of the last downloaded audiobook | [Number](#number-formatters) | | \ | Sample rate (Hz) of the last downloaded audiobook | [Number](#number-formatters) | | \ | Number of audio channels in the last downloaded audiobook | [Number](#number-formatters) | @@ -34,6 +35,7 @@ These tags will be replaced in the template with the audiobook's values. | \ | Libation version used during last download of the audiobook | [Text](#text-formatters) | | \ | Audible account of this book | [Text](#text-formatters) | | \ | Audible account nickname of this book | [Text](#text-formatters) | +| \ | Tag(s) | [Text List](#text-list-formatters) | | \ | Region/country | [Text](#text-formatters) | | \ | Year published | [Number](#number-formatters) | | \ | Book's language | [Text](#text-formatters) | @@ -62,6 +64,7 @@ Anything between the opening tag (``) and closing tag (`<-tagname>`) w | \...\<-if podcast\> | Only include if part of a podcast | Conditional | | \...\<-if bookseries\> | Only include if part of a book series | Conditional | | \...\<-if podcastparent\>**†** | Only include if item is a podcast series parent | Conditional | +| \...\<-if abridged\> | Only include if item is abridged | Conditional | | \...\<-has\> | Only include if the PROPERTY has a value (i.e. not null or empty) | Conditional | | \...\<-is\> | Only include if the PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional | | \...\<-is\> | Only include if the formatted PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional | @@ -117,11 +120,19 @@ Text formatting can change length and case of the text. Use <#>, <#> or | The ABC Murders | | | | \ | The AB | +### Text List Formatters + +| Formatter | Description | Example Usage | Example Result | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | -------------------------------------------- | +| separator() | Speficy the text used to join
multiple entries.

Default is ", " | `` | Tag1_Tag2_Tag3_Tag4_Tag5 | +| format(\{S\}) | Formats the entries by placing their values into the specified template.
Use {S:[Text_Formatter](#text-formatters)} to place the entry and optionally apply a format. | ``separator(;)]>` | Tag=tag1;Tag=tag2;Tag=tag3;Tag=tag4;Tag=tag5 | +| max(#) | Only use the first # of entries | `` | Tag1 | + ### Series Formatters -| Formatter | Description | Example Usage | Example Result | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| \{N \| # \| ID\} | Formats the series using
the series part tags.
\{N\} = Series Name
\{#\} = Number order in series
\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted
\{ID\} = Audible Series ID

Default is \{N\} | ``
``
``
`` | Sherlock Holmes
Sherlock Holmes
Sherlock Holmes, 1-6, B08376S3R2
Sherlock Holmes, B08376S3R2, 01.0-06.0 | +| Formatter | Description | Example Usage | Example Result | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| \{N \| # \| ID\} | Formats the series using
the series part tags.
\{N:[Text_Formatter](#text-formatters)\} = Series Name
\{#:[Number_Formatter](#number-formatters)\} = Number order in series
\{ID:[Text_Formatter](#text-formatters)\} = Audible Series ID

Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the correspoing formatter.

Default is \{N\} | ``
``
``
`` | Sherlock Holmes
sherlock holmes
Sherlock Holmes, 1-6, B08376S3R2
SHERLOCK H, B08376S3R2, 01.0-06.0 | ### Series List Formatters @@ -147,6 +158,12 @@ Text formatting can change length and case of the text. Use <#>, <#> or first, middle, or last name,
suffix or Audible Contributor ID

These terms define the primary, secondary, tertiary, … sorting order.
You may combine multiple terms in sequence to specify multi‑level sorting.

*Sorting direction:*
uppercase = ascending
lowercase = descending

Default is unsorted | ``
``
| Stephen Fry, Arthur Conan Doyle
Stephen King, Stephen Fry
John P. Smith \_B000TTTBBB\_, John P. Smith \_B000TTTCCC\_, John S. Smith \_B000HHHVVV\_ | | max(#) | Only use the first # of names

Default is all names | `` | Arthur Conan Doyle | +### Minutes Formatters + +| Formatter | Description | Example Usage | Example Result | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| {M \| H \| D} | Format the minutes value in terms of minutes, hours and days.
{D:[Number_Formatter](#number-formatter) = Number of full days
{H:[Number_Formatter](#number-formatter) = Number of full (remaining) hours
{M:[Number_Formatter](#number-formatter) = Number of (remaining) minutes

These tags only work in the order from day to minute.

Default is {M} | ``
``
``
`` | 03000minutes
02d 120m
2-2-0
3000-0-0 | + ### Number Formatters For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings).