diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 398f7ebe..76c53a78 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -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 }; } } + + /// + /// 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). + /// + private const long MaxPlausibleSeriesOrder = 1_000_000_000; + + private static bool IsPlausibleSeriesOrder(long n) + => n >= 0 && n < MaxPlausibleSeriesOrder; + + /// + /// 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. + /// + private static int? UsableEpisodeNumber(Item child) + => child.EpisodeNumber is int n && IsPlausibleSeriesOrder(n) ? n : null; + + /// + /// 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. + /// + 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); + + /// + /// 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. + /// + 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 } diff --git a/Source/LibationFileManager/Templates/SeriesOrder.cs b/Source/LibationFileManager/Templates/SeriesOrder.cs index f952b883..e2759690 100644 --- a/Source/LibationFileManager/Templates/SeriesOrder.cs +++ b/Source/LibationFileManager/Templates/SeriesOrder.cs @@ -8,6 +8,21 @@ namespace LibationFileManager.Templates; public class SeriesOrder : IFormattable { + /// + /// 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 into + /// scientific notation (issue #2024). Apply the numeric format only when the template asks. + /// + 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); /// - /// Use float formatters to format the number parts of the order. + /// Use numeric formatters to format the number parts of the order. /// 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 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). /// /// the string to search for a numeric value - /// If this function succeeds, the number that was found; otherwise zero. - /// If this function succeeds, the range of characters representing in ; otherwise default + /// If this function succeeds, the number that was found; otherwise default. + /// If this function succeeds, the range of characters representing in ; otherwise default /// True if a number was found; otherwise false. - 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; } diff --git a/Source/_Tests/AudibleUtilities.Tests/ApiExtendedSetSeriesTests.cs b/Source/_Tests/AudibleUtilities.Tests/ApiExtendedSetSeriesTests.cs new file mode 100644 index 00000000..5642fa9d --- /dev/null +++ b/Source/_Tests/AudibleUtilities.Tests/ApiExtendedSetSeriesTests.cs @@ -0,0 +1,201 @@ +using AudibleApi.Common; +using AudibleUtilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Linq; + +namespace ApiExtendedSetSeriesTests; + +/// +/// 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. +/// +[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)); + } +} diff --git a/Source/_Tests/LibationFileManager.Tests/SeriesOrderTests.cs b/Source/_Tests/LibationFileManager.Tests/SeriesOrderTests.cs new file mode 100644 index 00000000..d1ea56ba --- /dev/null +++ b/Source/_Tests/LibationFileManager.Tests/SeriesOrderTests.cs @@ -0,0 +1,31 @@ +using AssertionHelper; +using LibationFileManager.Templates; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Globalization; + +namespace SeriesOrderTests; + +/// +/// 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). +/// +[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"); +} diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index b470375f..62cc6014 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -770,6 +770,8 @@ namespace TemplatesTests [DataRow("", "1", "1")] [DataRow("", "1", "1")] [DataRow("", " 1 6 ", "1 6")] + [DataRow("", "2147483647", "2147483647")] + [DataRow("", "2147483647", "2147483647")] public void SeriesOrder_formatters(string template, string seriesOrder, string expected) { var bookDto = GetLibraryBook();