New tags <minutes>, <tag> and <if abridged->...<-if abridged>

This commit is contained in:
Jo-Be-Co
2026-03-22 00:41:13 +01:00
parent a8621699c1
commit e477c29890
14 changed files with 230 additions and 23 deletions

View File

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

View File

@@ -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<TPropertyValue>(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(?::(?<format>.*?))?\}", RegexOptions.IgnoreCase)]
private static partial Regex RegexMinutesD();
[GeneratedRegex(@"\{H(?::(?<format>.*?))?\}", RegexOptions.IgnoreCase)]
private static partial Regex RegexMinutesH();
[GeneratedRegex(@"\{M(?::(?<format>.*?))?\}", RegexOptions.IgnoreCase)]
private static partial Regex RegexMinutesM();
}

View File

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

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using FileManager.NamingTemplate;
namespace LibationFileManager.Templates;

View File

@@ -22,10 +22,12 @@ public class BookDto
public IEnumerable<SeriesDto>? 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<StringDto>? Tags { get; set; }
}

View File

@@ -52,7 +52,7 @@ internal partial class NameListFormat : IListFormat<NameListFormat>
[GeneratedRegex($@"\G(?<token>{Token})(?<descending>(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)]
private static partial Regex SortTokenizer();
/// <summary> Format must have at least one of the string {T}, {F}, {M}, {L}, {S}, or {ID} </summary>
/// <summary> Format must have at least one of the strings {T}, {F}, {M}, {L}, {S}, or {ID} </summary>
[GeneratedRegex($@"[Ff]ormat\((?<format>.*?\{{{Token}(?::.*?)?\}}.*?)\)")]
public static partial Regex FormatRegex();
}

View File

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

View File

@@ -52,7 +52,7 @@ internal partial class SeriesListFormat : IListFormat<SeriesListFormat>
[GeneratedRegex($@"\G(?<token>{Token})(?<descending>(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)]
private static partial Regex SortTokenizer();
/// <summary> Format must have at least one of the string {N}, {#}, {ID} </summary>
/// <summary> Format must have at least one of the strings {N}, {#}, {ID} </summary>
[GeneratedRegex($@"[Ff]ormat\((?<format>.*?\{{{Token}(?::.*?)?\}}.*?)\)")]
public static partial Regex FormatRegex();
}

View File

@@ -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<string, Func<StringDto, object?>> 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);
}

View File

@@ -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<StringListFormat>
{
public static IEnumerable<string> Formatter(ITemplateTag _, IEnumerable<StringDto>? entries, string? formatString, CultureInfo? culture)
=> entries is null
? []
: IListFormat<StringListFormat>.FormattedList(formatString, Sort(entries, formatString, StringDto.FormatReplacements), culture);
public static string? Finalizer(ITemplateTag _, IEnumerable<string>? entries, CultureInfo? culture)
=> IListFormat<StringListFormat>.Join(entries, culture);
private static IEnumerable<T> Sort<T>(IEnumerable<T> entries, string? formatString, Dictionary<string, Func<T, object?>> formatReplacements)
{
var pattern = formatString is null ? null : SortRegex().Match(formatString).ResolveValue("pattern");
if (pattern is null) return entries;
IOrderedEnumerable<T>? 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";
/// <summary> 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.</summary>
[GeneratedRegex($@"[Ss]ort\(\s*(?i:(?<pattern>(?:{Token}\s*?)+))\s*\)")]
private static partial Regex SortRegex();
[GeneratedRegex($@"\G(?<token>{Token})(?<descending>(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)]
private static partial Regex SortTokenizer();
/// <summary> Format must have the string {S} (optionally with formatting like {S:u})</summary>
[GeneratedRegex($@"[Ff]ormat\((?<format>.*?\{{{Token}(?::.*?)?\}}.*?)\)")]
public static partial Regex FormatRegex();
}

View File

@@ -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 <first series[{#}]>");
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>", "<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>", "<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>", "<if bookseries->...<-if bookseries>");
public static TemplateTags IfAbridged { get; } = new TemplateTags("if abridged", "Only include if abridged", "<if abridged-><-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>", "<has PROPERTY->...<-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>", "<is PROPERTY->...<-is>");
}

View File

@@ -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<LibraryBookDto> 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<LibraryBookDto> 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 } },

View File

@@ -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("<minutes>", 100, "100")]
[DataRow("<minutes[{m}]>", 100, "100")]
[DataRow("<minutes[{m:2}]>", 100, "100")]
[DataRow("<minutes[{h}-{m}]>", 100, "1-40")]
[DataRow("<minutes[{h:2}-{m:2}]>", 100, "01-40")]
[DataRow("<minutes[{d}.{h:2}-{m:2}]>", 100, "0.01-40")]
[DataRow("<minutes[{d:2}d{h:2}h{m:2}m]>", 100, "00d01h40m")]
[DataRow("<minutes[{d} days, {h} hours, {m} minutes]>", 100, "0 days, 1 hours, 40 minutes")]
[DataRow("<minutes[{h}-{m}]>", 2000, "33-20")]
[DataRow("<minutes[{d:3}-{h:3}-{m:3}]>", 2000, "001-009-020")]
[DataRow("<minutes[{d}-{m}]>", 100, "0-100")]
[DataRow("<minutes[{d}-{m}]>", 1500, "1-60")]
[DataRow("<minutes[{d}-{m}]>", 2000, "1-560")]
[DataRow("<minutes[{d}-{m}]>", 2880, "2-0")]
[DataRow("<minutes[{d:2}-{m:2}]>", 1500, "01-60")]
public void MinutesFormat(string template, int minutes, string expected)
{
var bookDto = GetLibraryBook();
bookDto.LengthInMinutes = minutes;
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate.GetFilename(bookDto, "", "", Replacements).PathWithoutPrefix.Should().Be(expected);
}
[TestMethod]
[DataRow("<id> - <filedate[yy-MM-dd]>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")]
[DataRow("<id> - <filedate [ yy-MM-dd ] >", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")]
@@ -492,6 +520,7 @@ namespace TemplatesTests
[DataRow("<is author[format({L})separator(:)][=Doyle:Fry]->true<-has>", "true")]
[DataRow("<is author[>=3]->true<-has>", "")]
[DataRow("<is author[=Sherlock]->true<-has>", "")]
[DataRow("<is tag[=Tag1]->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("<if abridged->Abridged<-if abridged>", "Abridged", true)]
[DataRow("<if abridged->Abridged<-if abridged>", "", false)]
public void IfAbridged_test(string template, string expected, bool isAbridged)
{
var bookDto = GetLibraryBook();
bookDto.IsAbridged = isAbridged;
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty })
.Should().Be(expected);
}
[TestMethod]
[DataRow("<audibletitle [u]>", "I", "en-US", "i")]
[DataRow("<audibletitle [l]>", "ı", "tr-TR", "I")]
@@ -630,6 +674,25 @@ namespace TemplatesTests
.GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }, culture)
.Should().Be(expected);
}
[TestMethod]
[DataRow("<tag>", "Tag1, Tag2, Tag3")]
[DataRow("<tag [separator( - )]>", "Tag1 - Tag2 - Tag3")]
[DataRow("<tag [format({S:u})]>", "TAG1, TAG2, TAG3")]
[DataRow("<tag [format({S:l})]>", "tag1, tag2, tag3")]
[DataRow("<tag [format(Tag: {S})]>", "Tag: Tag1, Tag: Tag2, Tag: Tag3")]
[DataRow("<tag [max(1)]>", "Tag1")]
[DataRow("<tag [sort(s)]>", "Tag3, Tag2, Tag1")]
public void Tag_test(string template, string expected)
{
var bookDto = Shared.GetLibraryBook();
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty })
.Should().Be(expected);
}
}
}

View File

@@ -26,6 +26,7 @@ These tags will be replaced in the template with the audiobook's values.
| \<series\> | All series to which the book belongs (if any) | [Series List](#series-list-formatters) |
| \<first series\> | First series | [Series](#series-formatters) |
| \<series#\> | Number order in series (alias for \<first series[{#}]\> | [Number](#number-formatters) |
| \<minutes\> | Duration of the audiobook in minutes | [Minutes](#minutes-formatters) |
| \<bitrate\> | Bitrate (kbps) of the last downloaded audiobook | [Number](#number-formatters) |
| \<samplerate\> | Sample rate (Hz) of the last downloaded audiobook | [Number](#number-formatters) |
| \<channels\> | 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\> | Libation version used during last download of the audiobook | [Text](#text-formatters) |
| \<account\> | Audible account of this book | [Text](#text-formatters) |
| \<account nickname\> | Audible account nickname of this book | [Text](#text-formatters) |
| \<tag\> | Tag(s) | [Text List](#text-list-formatters) |
| \<locale\> | Region/country | [Text](#text-formatters) |
| \<year\> | Year published | [Number](#number-formatters) |
| \<language\> | Book's language | [Text](#text-formatters) |
@@ -62,6 +64,7 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
| \<if podcast-\>...\<-if podcast\> | Only include if part of a podcast | Conditional |
| \<if bookseries-\>...\<-if bookseries\> | Only include if part of a book series | Conditional |
| \<if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is a podcast series parent | Conditional |
| \<if abridged-\>...\<-if abridged\> | Only include if item is abridged | Conditional |
| \<has PROPERTY-\>...\<-has\> | Only include if the PROPERTY has a value (i.e. not null or empty) | Conditional |
| \<is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if the PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional |
| \<is PROPERTY[FORMAT][[CHECK](#checks)]-\>...\<-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 <#>, <#><case> or <c
| T | Converts text to title case where uppercase words are preserved | \<title[T]\> | The ABC Murders |
| | | \<title[6T]\> | The AB |
### Text List Formatters
| Formatter | Description | Example Usage | Example Result |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | -------------------------------------------- |
| separator() | Speficy the text used to join<br>multiple entries.<br><br>Default is ", " | `<tag[separator(_)]>` | Tag1_Tag2_Tag3_Tag4_Tag5 |
| format(\{S\}) | Formats the entries by placing their values into the specified template.<br>Use {S:[Text_Formatter](#text-formatters)} to place the entry and optionally apply a format. | `<tag[format(Tag={S:l})`<br>`separator(;)]>` | Tag=tag1;Tag=tag2;Tag=tag3;Tag=tag4;Tag=tag5 |
| max(#) | Only use the first # of entries | `<tag[max(1)]>` | Tag1 |
### Series Formatters
| Formatter | Description | Example Usage | Example Result |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| \{N \| # \| ID\} | Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted<br>\{ID\} = Audible Series ID<br><br>Default is \{N\} | `<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N}, {ID}, {#:00.0}]>` | Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>Sherlock Holmes, B08376S3R2, 01.0-06.0 |
| Formatter | Description | Example Usage | Example Result |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| \{N \| # \| ID\} | Formats the series using<br>the series part tags.<br>\{N:[Text_Formatter](#text-formatters)\} = Series Name<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series<br>\{ID:[Text_Formatter](#text-formatters)\} = Audible Series ID<br><br>Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the correspoing formatter.<br><br>Default is \{N\} | `<first series>`<hr>`<first series[{N:l}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N:10U}, {ID}, {#:00.0}]>` | Sherlock Holmes<hr>sherlock holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>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 <#>, <#><case> or <c
| sort(T \| F \| M \| L \| S \| ID) | Sorts the names by title,<br> first, middle, or last name,<br>suffix or Audible Contributor ID<br><br>These terms define the primary, secondary, tertiary, … sorting order.<br>You may combine multiple terms in sequence to specify multilevel sorting.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<author[sort(M)]>`<hr>`<author[sort(Fl)]>`<hr><author[sort(L FM ID)]> | Stephen Fry, Arthur Conan Doyle<hr>Stephen King, Stephen Fry<hr>John P. Smith \_B000TTTBBB\_, John P. Smith \_B000TTTCCC\_, John S. Smith \_B000HHHVVV\_ |
| max(#) | Only use the first # of names<br><br>Default is all names | `<author[max(1)]>` | 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.<br>{D:[Number_Formatter](#number-formatter) = Number of full days<br>{H:[Number_Formatter](#number-formatter) = Number of full (remaining) hours<br>{M:[Number_Formatter](#number-formatter) = Number of (remaining) minutes<br><br>These tags only work in the order from day to minute.<br><br>Default is {M} | `<minutes[{M:4}minutes]>`<hr>`<minutes[{D:2}d {M:2}m]>`<hr>`<minutes[{D}-{H}-{M}]>`<hr>`<minutes[{M}-{H}-{D}]>` | 03000minutes<hr>02d 120m<hr>2-2-0<hr> 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).