mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
Do not use Audible's sentinel episode numbers as podcast series order.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
a0adf75b53
commit
67b476eaa0
5 files changed
+331
-17
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user