Do not use Audible's sentinel episode numbers as podcast series order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robert McRackanandCursor committed 2026-09-03 16:18:39 -04:00
1 parent a0adf75b53
commit 67b476eaa0
5 files changed
+331 -17

No files matched your search

+71 -6
View File
@@ -6,6 +6,7 @@ using Newtonsoft.Json.Linq;
using Polly;
using Polly.Retry;
using System.Diagnostics;
using System.Globalization;
using System.Threading.Channels;
namespace AudibleUtilities;
@@ -439,21 +440,21 @@ public class ApiExtended
}
int lastEpNum = -1, dupeCount = 0;
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
foreach (var child in children.OrderBy(i => UsableEpisodeNumber(i)).ThenBy(i => i.PublicationDateTime))
{
string sequence;
if (child.EpisodeNumber is null)
var episodeNumber = UsableEpisodeNumber(child);
if (episodeNumber is null)
{
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
sequence = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
sequence = FallbackSeriesSequence(parent, child);
}
else
{
//multipart episodes may have the same episode number
if (child.EpisodeNumber == lastEpNum)
if (episodeNumber == lastEpNum)
dupeCount++;
else
lastEpNum = child.EpisodeNumber.Value;
lastEpNum = episodeNumber.Value;
sequence = (lastEpNum + dupeCount).ToString();
}
@@ -472,5 +473,69 @@ public class ApiExtended
};
}
}
/// <summary>
/// Nine digits. Big enough for YYYYMMDD-style numbering; too small for unix timestamps,
/// Integer.MAX_VALUE, and the other sentinel integers Audible has sent as episode order (issue #2024).
/// </summary>
private const long MaxPlausibleSeriesOrder = 1_000_000_000;
private static bool IsPlausibleSeriesOrder(long n)
=> n >= 0 && n < MaxPlausibleSeriesOrder;
/// <summary>
/// Audible sometimes serializes a missing episode_number as a sentinel integer (Integer.MAX_VALUE
/// was the one in #2024) instead of omitting the field. Treat any implausibly large value the same way.
/// </summary>
private static int? UsableEpisodeNumber(Item child)
=> child.EpisodeNumber is int n && IsPlausibleSeriesOrder(n) ? n : null;
/// <summary>
/// When episode_number is missing or implausibly large, use relationship sort/sequence
/// or the catalog series sequence. Prefer the parent's child relationship (the historical source),
/// then the child's parent relationship, then any series sequence Audible already attached.
/// </summary>
private static string FallbackSeriesSequence(Item parent, Item child)
{
var fromParent = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin);
if (UsableRelationshipOrder(fromParent) is string parentOrder)
return parentOrder;
var fromChild = child.Relationships?.FirstOrDefault(r => r.Asin == parent.Asin);
if (UsableRelationshipOrder(fromChild) is string childOrder)
return childOrder;
var catalogSequence = child.Series?.FirstOrDefault(s => s.Asin == parent.Asin)?.Sequence;
if (IsUsableOrderString(catalogSequence))
return catalogSequence!;
return "0";
}
private static string? UsableRelationshipOrder(Relationship? relationship)
{
if (relationship is null)
return null;
if (IsUsableSort(relationship.Sort))
return relationship.Sort!.Value.ToString();
if (IsUsableOrderString(relationship.Sequence))
return relationship.Sequence;
return null;
}
private static bool IsUsableSort(long? sort)
=> sort is long n && IsPlausibleSeriesOrder(n);
/// <summary>
/// Bare integers that are too large are sentinels or timestamps. Mixed forms like "1-6" or "2.1"
/// are real series orders and are left alone.
/// </summary>
private static bool IsUsableOrderString(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value == "-1")
return false;
return !long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|| IsPlausibleSeriesOrder(n);
}
#endregion
}
@@ -8,6 +8,21 @@ namespace LibationFileManager.Templates;
public class SeriesOrder : IFormattable
{
/// <summary>
/// A numeric span from the original order string. Keep the original digits for unformatted
/// output so large values (e.g. 2147483647) are not rounded through <see cref="float"/> into
/// scientific notation (issue #2024). Apply the numeric format only when the template asks.
/// </summary>
private readonly record struct NumberPart(string Raw, decimal Value) : IFormattable
{
public override string ToString() => Raw;
public string ToString(string? format, IFormatProvider? formatProvider)
=> string.IsNullOrEmpty(format)
? Raw
: Value.ToString(format, formatProvider ?? CultureInfo.InvariantCulture);
}
private object[] OrderParts { get; }
private SeriesOrder(object[] orderParts)
{
@@ -17,26 +32,25 @@ public class SeriesOrder : IFormattable
public override string ToString() => ToString(null, null);
/// <summary>
/// Use float formatters to format the number parts of the order.
/// Use numeric formatters to format the number parts of the order.
/// </summary>
public string ToString(string? format, IFormatProvider? formatProvider)
=> string.Concat(OrderParts.Select(p => p switch
{
float f => f.ToString(format, formatProvider ?? CultureInfo.InvariantCulture),
IFormattable f => f.ToString(format, formatProvider),
IFormattable f => f.ToString(format, formatProvider ?? CultureInfo.InvariantCulture),
_ => p.ToString(),
})).Trim();
public static SeriesOrder Parse(string? order)
{
List<object> parts = [];
while (TryParseNumber(order, out var value, out var range))
while (TryParseNumber(order, out var number, out var range))
{
var prefix = order[..range.Start.Value];
if (!string.IsNullOrEmpty(prefix))
parts.Add(prefix);
parts.Add(value);
parts.Add(number);
order = order[range.End.Value..];
}
@@ -51,12 +65,12 @@ public class SeriesOrder : IFormattable
/// Try to parse any positive number from within the string (greedy).
/// </summary>
/// <param name="numString">the string to search for a numeric value</param>
/// <param name="value">If this function succeeds, the number that was found; otherwise zero.</param>
/// <param name="range">If this function succeeds, the range of characters representing <paramref name="value"/> in <paramref name="numString"/>; otherwise default</param>
/// <param name="number">If this function succeeds, the number that was found; otherwise default.</param>
/// <param name="range">If this function succeeds, the range of characters representing <paramref name="number"/> in <paramref name="numString"/>; otherwise default</param>
/// <returns>True if a number was found; otherwise false.</returns>
private static bool TryParseNumber([NotNullWhen(true)] string? numString, out float value, out Range range)
private static bool TryParseNumber([NotNullWhen(true)] string? numString, out NumberPart number, out Range range)
{
value = 0;
number = default;
if (string.IsNullOrWhiteSpace(numString))
{
range = default;
@@ -73,14 +87,15 @@ public class SeriesOrder : IFormattable
for (var e = numString.Length; e > s; e--)
{
//The float parser will succeed with trailing whitespace,
//The decimal parser will succeed with trailing whitespace,
//but we want to preserve it in the final display string.
if (char.IsWhiteSpace(numString[e - 1]))
continue;
var substring = numString[s..e];
if (float.TryParse(substring, CultureInfo.InvariantCulture, out value))
if (decimal.TryParse(substring, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value))
{
number = new NumberPart(substring, value);
range = new Range(s, e);
return true;
}
@@ -0,0 +1,201 @@
using AudibleApi.Common;
using AudibleUtilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Linq;
namespace ApiExtendedSetSeriesTests;
/// <summary>
/// Podcast series numbers come from Audible's episode_number, then relationship sort/sequence.
/// A missing episode_number is sometimes sent as a huge sentinel integer (issue #2024), which
/// must not be stored as the series order.
/// </summary>
[TestClass]
public class SetSeries
{
private static Relationship childRel(string asin, long? sort = null, string? sequence = null)
=> new()
{
Asin = asin,
RelationshipToProduct = RelationshipToProduct.Child,
RelationshipType = RelationshipType.Episode,
Sort = sort,
Sequence = sequence
};
private static Relationship parentRel(string asin, long? sort = null, string? sequence = null)
=> new()
{
Asin = asin,
RelationshipToProduct = RelationshipToProduct.Parent,
RelationshipType = RelationshipType.Episode,
Sort = sort,
Sequence = sequence
};
private static Item show(string asin, params Relationship[] childRels)
=> new()
{
Asin = asin,
Title = "My Show",
PurchaseDate = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero),
Relationships = childRels
};
private static Item episode(string asin, string parentAsin, int? episodeNumber, long? sort = null, string? sequence = null, string? catalogSequence = null)
=> new()
{
Asin = asin,
Title = $"Episode {asin}",
EpisodeNumber = episodeNumber,
Relationships = [parentRel(parentAsin, sort, sequence)],
Series = catalogSequence is null ? null : [new Series { Asin = parentAsin, Sequence = catalogSequence, Title = "My Show" }]
};
private static string SequenceOf(Item item) => item.Series!.Single().Sequence!;
[TestMethod]
public void a_real_episode_number_is_the_series_order()
{
var parent = show("SHOW", childRel("EP", sort: 99));
var child = episode("EP", "SHOW", episodeNumber: 406, sort: 99);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_episode_number_falls_back_to_parent_relationship_sort()
{
var parent = show("SHOW", childRel("EP", sort: 406));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_episode_number_falls_back_to_child_relationship_sort()
{
var parent = show("SHOW", childRel("EP"));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue, sort: 406);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_episode_number_falls_back_to_relationship_sequence()
{
var parent = show("SHOW", childRel("EP", sort: int.MaxValue, sequence: "406"));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_episode_number_falls_back_to_catalog_series_sequence()
{
var parent = show("SHOW", childRel("EP", sort: int.MaxValue));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue, sort: int.MaxValue, catalogSequence: "406");
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_with_no_fallback_is_zero_not_the_sentinel()
{
var parent = show("SHOW", childRel("EP"));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("0", SequenceOf(child));
}
[TestMethod]
public void null_episode_number_still_uses_parent_sort()
{
var parent = show("SHOW", childRel("EP", sort: 7));
var child = episode("EP", "SHOW", episodeNumber: null);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("7", SequenceOf(child));
}
[TestMethod]
public void a_real_episode_number_wins_over_a_different_sort()
{
var parent = show("SHOW", childRel("EP", sort: 1));
var child = episode("EP", "SHOW", episodeNumber: 5, sort: 1);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("5", SequenceOf(child));
}
[TestMethod]
public void multipart_episodes_with_the_same_number_keep_an_offset()
{
var parent = show("SHOW", childRel("A", sort: 3), childRel("B", sort: 3));
var a = episode("A", "SHOW", episodeNumber: 3);
var b = episode("B", "SHOW", episodeNumber: 3);
ApiExtended.SetSeries(parent, [a, b]);
CollectionAssert.AreEquivalent(new[] { "3", "4" }, new[] { SequenceOf(a), SequenceOf(b) });
}
[TestMethod]
public void a_yyyymmdd_episode_number_is_kept()
{
var parent = show("SHOW", childRel("EP", sort: 1));
var child = episode("EP", "SHOW", episodeNumber: 20260903, sort: 1);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("20260903", SequenceOf(child));
}
[TestMethod]
public void a_unix_timestamp_sort_is_not_used_as_the_series_order()
{
var parent = show("SHOW", childRel("EP", sort: 1_725_400_800));
var child = episode("EP", "SHOW", episodeNumber: null, sequence: "406");
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void a_nine_digit_episode_number_is_kept()
{
var parent = show("SHOW", childRel("EP"));
var child = episode("EP", "SHOW", episodeNumber: 999_999_999);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("999999999", SequenceOf(child));
}
[TestMethod]
public void a_ten_digit_episode_number_falls_back()
{
var parent = show("SHOW", childRel("EP", sort: 406));
var child = episode("EP", "SHOW", episodeNumber: 1_000_000_000);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
}
@@ -0,0 +1,31 @@
using AssertionHelper;
using LibationFileManager.Templates;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Globalization;
namespace SeriesOrderTests;
/// <summary>
/// Unformatted series numbers must keep the original digits. Parsing them as float used to
/// print 2147483647 as 2.1474836E+09 and collide different values (issue #2024).
/// </summary>
[TestClass]
public class Parse
{
[TestMethod]
[DataRow("1", "1")]
[DataRow("406", "406")]
[DataRow("1-6", "1-6")]
[DataRow("2147483647", "2147483647")]
[DataRow(" 1 6 ", "1 6")]
public void unformatted_keeps_the_original_digits(string order, string expected)
=> SeriesOrder.Parse(order).ToString().Should().Be(expected);
[TestMethod]
public void a_numeric_format_still_applies_to_each_number_part()
=> SeriesOrder.Parse("1-6").ToString("F2", CultureInfo.InvariantCulture).Should().Be("1.00-6.00");
[TestMethod]
public void a_numeric_format_does_not_round_a_large_integer()
=> SeriesOrder.Parse("2147483647").ToString("F0", CultureInfo.InvariantCulture).Should().Be("2147483647");
}
@@ -770,6 +770,8 @@ namespace TemplatesTests
[DataRow("<series#[]>", "1", "1")]
[DataRow("<series#>", "1", "1")]
[DataRow("<series#>", " 1 6 ", "1 6")]
[DataRow("<series#>", "2147483647", "2147483647")]
[DataRow("<series#[F0]>", "2147483647", "2147483647")]
public void SeriesOrder_formatters(string template, string seriesOrder, string expected)
{
var bookDto = GetLibraryBook();