diff --git a/Source/ApplicationServices/DeferredDownload.cs b/Source/ApplicationServices/DeferredDownload.cs
new file mode 100644
index 00000000..72499978
--- /dev/null
+++ b/Source/ApplicationServices/DeferredDownload.cs
@@ -0,0 +1,107 @@
+using DataLayer;
+using Dinah.Core;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ApplicationServices;
+
+/// One title Libation is waiting on before attempting it again, and why.
+public sealed record DeferredDownload(
+ string Account,
+ string AudibleProductId,
+ DownloadFailureKind Kind,
+ int ConsecutiveFailures,
+ DateTimeOffset LastFailedAt,
+ DateTimeOffset RetryAfter,
+ string? Reason)
+{
+ /// The label used in the log, the CLI summary and the GUI's skipped-titles breakdown.
+ public string KindLabel => Kind switch
+ {
+ DownloadFailureKind.LicenseDenied => "Audible denied a download license",
+ DownloadFailureKind.AssetUnavailable => "Audible has no downloadable audio yet",
+ DownloadFailureKind.ServiceInterruption => "A possible Audible service interruption",
+ _ => "A previous failure"
+ };
+}
+
+///
+/// The titles a bulk or automatic download run should leave alone for now, looked up by library book.
+///
+/// Read once per run: a run that takes hours must not have its own failures start suppressing the titles
+/// still ahead of it in the same pass.
+///
+///
+public sealed class DownloadDeferrals
+{
+ /// No title is deferred. Used by targeted and forced runs, which must attempt what was asked.
+ public static DownloadDeferrals None { get; } = new([]);
+
+ private readonly Dictionary<(string Account, string AudibleProductId), DeferredDownload> byBook;
+
+ private DownloadDeferrals(IEnumerable deferred)
+ => byBook = deferred.ToDictionary(d => (d.Account, d.AudibleProductId));
+
+ public static DownloadDeferrals Create(IEnumerable deferred) => new(deferred);
+
+ /// Reads the store. Never throws: a failure here must not stop a download run.
+ public static DownloadDeferrals Load(DateTimeOffset now)
+ => Create(DownloadAttemptFailureStore.GetDeferred(now));
+
+ public int Count => byBook.Count;
+ public bool Any => byBook.Count > 0;
+
+ public DeferredDownload? Find(LibraryBook libraryBook)
+ => libraryBook.Account is { } account
+ && byBook.TryGetValue((account, libraryBook.Book.AudibleProductId), out var deferred)
+ ? deferred
+ : null;
+
+ public bool IsDeferred(LibraryBook libraryBook) => Find(libraryBook) is not null;
+}
+
+/// What to tell the user about titles a run held back, instead of the full warning per title per run.
+public static class DeferredDownloadUserMessage
+{
+ ///
+ /// A compact breakdown for the log, eg:
+ /// "Audible denied a download license: 3, Audible has no downloadable audio yet: 1".
+ ///
+ public static string BuildLogBreakdown(IEnumerable deferred)
+ {
+ var breakdown = string.Join(", ", GroupByKind(deferred).Select(g => $"{g.First().KindLabel}: {g.Count()}"));
+ return breakdown is "" ? "none" : breakdown;
+ }
+
+ ///
+ /// The lines a CLI run prints in place of a full warning per title. Says how many were held back, why,
+ /// when the soonest will be attempted again, and how to override.
+ ///
+ public static IEnumerable BuildCliSkippedLines(IReadOnlyCollection skipped, DateTimeOffset now)
+ {
+ if (skipped.Count == 0)
+ yield break;
+
+ yield return $"Skipped {"title".PluralizeWithCount(skipped.Count)} that recently failed to download. Libation will try again by itself.";
+
+ foreach (var group in GroupByKind(skipped))
+ yield return $" {group.First().KindLabel}: {group.Count()} (next attempt {DescribeWhen(group.Min(d => d.RetryAfter), now)})";
+
+ yield return " To try one now: libationcli liberate . For all of them: libationcli liberate --force.";
+ }
+
+ /// "in about 3 hours" / "in about 12 days (9/14/2026)" - a summary should not need a clock to read.
+ public static string DescribeWhen(DateTimeOffset when, DateTimeOffset now)
+ {
+ var wait = when - now;
+
+ return wait <= TimeSpan.Zero ? "on the next run"
+ : wait < TimeSpan.FromHours(1) ? $"in about {"minute".PluralizeWithCount(Math.Max(1, (int)wait.TotalMinutes))}"
+ : wait < TimeSpan.FromDays(1) ? $"in about {"hour".PluralizeWithCount((int)Math.Round(wait.TotalHours))}"
+ : $"in about {"day".PluralizeWithCount((int)Math.Round(wait.TotalDays))} ({when.ToLocalTime():d})";
+ }
+
+ private static IEnumerable> GroupByKind(IEnumerable deferred)
+ => deferred.GroupBy(d => d.Kind).OrderBy(g => g.Key);
+}
diff --git a/Source/ApplicationServices/DownloadAttemptFailureStore.cs b/Source/ApplicationServices/DownloadAttemptFailureStore.cs
new file mode 100644
index 00000000..1679c7cd
--- /dev/null
+++ b/Source/ApplicationServices/DownloadAttemptFailureStore.cs
@@ -0,0 +1,176 @@
+using DataLayer;
+using Dinah.Core;
+using Microsoft.EntityFrameworkCore;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ApplicationServices;
+
+///
+/// Reads and writes the record of refused download attempts that keeps a scheduled run from asking Audible
+/// for the same license every time.
+///
+/// Every method swallows its own errors. This is bookkeeping that makes downloading quieter; it must never
+/// be the reason a download fails, and a broken query must leave downloading exactly as it was before this
+/// existed.
+///
+///
+public static class DownloadAttemptFailureStore
+{
+ ///
+ /// Records a failed attempt, extending the wait before the title is attempted again. A failure of a
+ /// different kind than last time restarts the count: Audible changed its mind about why, so the previous
+ /// wait no longer describes the situation.
+ ///
+ public static void Record(LibraryBook libraryBook, DownloadFailureKind kind, string? reason, DateTimeOffset? failedAt = null)
+ {
+ ArgumentNullException.ThrowIfNull(libraryBook);
+
+ if (string.IsNullOrWhiteSpace(libraryBook.Account) || string.IsNullOrWhiteSpace(libraryBook.Book.AudibleProductId))
+ return;
+
+ try
+ {
+ var when = failedAt ?? DateTimeOffset.Now;
+ var account = libraryBook.Account;
+ var productId = libraryBook.Book.AudibleProductId;
+
+ using var context = DbContexts.GetContext();
+ var existing = context.DownloadAttemptFailures
+ .SingleOrDefault(f => f.Account == account && f.AudibleProductId == productId);
+
+ var consecutiveFailures = existing is null || existing.Kind != kind ? 1 : existing.ConsecutiveFailures + 1;
+ var retryAfter = DownloadRetryBackoff.GetRetryAfter(kind, consecutiveFailures, when);
+
+ if (existing is null)
+ context.DownloadAttemptFailures.Add(new DownloadAttemptFailure(productId, account, kind, consecutiveFailures, when, retryAfter, Truncate(reason)));
+ else
+ existing.Record(kind, consecutiveFailures, when, retryAfter, Truncate(reason));
+
+ context.SaveChanges();
+
+ Serilog.Log.Logger.Information(
+ "Not attempting {audibleProductId} again until {retryAfter}. {@DebugInfo}",
+ productId,
+ retryAfter.ToLocalTime(),
+ new { Title = libraryBook.Book.TitleWithSubtitle, Account = account.ToMask(), kind, consecutiveFailures, reason });
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(
+ ex,
+ "Failed to record a refused download attempt. The title will be attempted again on the next run. {@DebugInfo}",
+ new { libraryBook.Book.AudibleProductId, Title = libraryBook.Book.TitleWithSubtitle, kind });
+ }
+ }
+
+ ///
+ /// Forgets any record for this title, so it is attempted again at the next opportunity. Called when a
+ /// download succeeds and when the user asks for the title explicitly.
+ ///
+ public static void Clear(LibraryBook libraryBook)
+ {
+ ArgumentNullException.ThrowIfNull(libraryBook);
+ Clear(libraryBook.Account, libraryBook.Book.AudibleProductId);
+ }
+
+ public static void Clear(string? account, string? audibleProductId)
+ {
+ if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(audibleProductId))
+ return;
+
+ try
+ {
+ using var context = DbContexts.GetContext();
+
+ // ExecuteDelete rather than a load-then-remove so the common case (nothing recorded) is one
+ // statement. Called after every successful download.
+ var deleted = context.DownloadAttemptFailures
+ .Where(f => f.Account == account && f.AudibleProductId == audibleProductId)
+ .ExecuteDelete();
+
+ if (deleted > 0)
+ Serilog.Log.Logger.Debug("Cleared the recorded download failure for {audibleProductId}", audibleProductId);
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(ex, "Failed to clear the recorded download failure for {audibleProductId}", audibleProductId);
+ }
+ }
+
+ /// Titles whose wait has not elapsed. Empty when the query fails, so downloading carries on.
+ public static IReadOnlyList GetDeferred(DateTimeOffset now)
+ {
+ try
+ {
+ var ticks = now.UtcTicks;
+
+ using var context = DbContexts.GetContext();
+ return Project(context.DownloadAttemptFailures.AsNoTracking().Where(f => f.RetryAfterUtcTicks > ticks));
+ }
+ catch (Exception ex)
+ {
+ // Failing open is the safer default: a broken query must not stop titles from being downloaded.
+ Serilog.Log.Logger.Error(ex, "Failed to read recorded download failures. Treating every title as ready to attempt.");
+ return [];
+ }
+ }
+
+ /// The current wait for one title, or null when it is ready to be attempted. Null when the query fails.
+ public static DeferredDownload? Find(LibraryBook libraryBook, DateTimeOffset now)
+ {
+ ArgumentNullException.ThrowIfNull(libraryBook);
+
+ var account = libraryBook.Account;
+ var productId = libraryBook.Book.AudibleProductId;
+
+ if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(productId))
+ return null;
+
+ try
+ {
+ var ticks = now.UtcTicks;
+
+ using var context = DbContexts.GetContext();
+ return Project(
+ context.DownloadAttemptFailures
+ .AsNoTracking()
+ .Where(f => f.Account == account && f.AudibleProductId == productId && f.RetryAfterUtcTicks > ticks))
+ .FirstOrDefault();
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(ex, "Failed to read the recorded download failure for {audibleProductId}", productId);
+ return null;
+ }
+ }
+
+ private static List Project(IQueryable query)
+ => query
+ // Materialise the columns first: the record's constructor is not translatable to SQL.
+ .Select(f => new
+ {
+ f.Account,
+ f.AudibleProductId,
+ f.Kind,
+ f.ConsecutiveFailures,
+ f.LastFailedAtUtcTicks,
+ f.RetryAfterUtcTicks,
+ f.Reason
+ })
+ .ToList()
+ .Select(f => new DeferredDownload(
+ f.Account,
+ f.AudibleProductId,
+ f.Kind,
+ f.ConsecutiveFailures,
+ new DateTimeOffset(f.LastFailedAtUtcTicks, TimeSpan.Zero),
+ new DateTimeOffset(f.RetryAfterUtcTicks, TimeSpan.Zero),
+ f.Reason))
+ .ToList();
+
+ /// Audible's messages can run long; the full text is already in the log.
+ private static string? Truncate(string? reason)
+ => reason is null || reason.Length <= 400 ? reason : reason[..400];
+}
diff --git a/Source/ApplicationServices/DownloadRetryBackoff.cs b/Source/ApplicationServices/DownloadRetryBackoff.cs
new file mode 100644
index 00000000..c561e7e2
--- /dev/null
+++ b/Source/ApplicationServices/DownloadRetryBackoff.cs
@@ -0,0 +1,50 @@
+using DataLayer;
+using System;
+using System.Collections.Generic;
+
+namespace ApplicationServices;
+
+///
+/// How long to leave a title alone after a download attempt Audible refused, so a scheduled run stops asking
+/// for the same license every time.
+///
+/// The wait doubles with each failure in a row, up to a cap, and every kind of failure has a finite cap: a
+/// title held back is always attempted again eventually. Audible never distinguishes "you will never have
+/// rights to this" from "not right now", so nothing here may be permanent.
+///
+///
+public static class DownloadRetryBackoff
+{
+ private static readonly Dictionary schedule = new()
+ {
+ // An eligibility refusal changes only when the account or the catalog changes. A day matches the
+ // advice Libation already gives for a Plus title ("try again in 1 to 2 days"), which is the most
+ // common refusal that clears by itself.
+ [DownloadFailureKind.LicenseDenied] = (TimeSpan.FromDays(1), TimeSpan.FromDays(30)),
+
+ // A preorder becomes downloadable on its release date, which nobody can predict from the error, so
+ // keep checking within a week.
+ [DownloadFailureKind.AssetUnavailable] = (TimeSpan.FromHours(6), TimeSpan.FromDays(7)),
+
+ // Short: an outage that has passed should not delay a title any longer than it has to.
+ [DownloadFailureKind.ServiceInterruption] = (TimeSpan.FromHours(1), TimeSpan.FromHours(12)),
+ };
+
+ /// How long to wait after the th failure in a row.
+ public static TimeSpan GetWait(DownloadFailureKind kind, int consecutiveFailures)
+ {
+ var (first, max) = schedule.TryGetValue(kind, out var found)
+ ? found
+ : schedule[DownloadFailureKind.ServiceInterruption];
+
+ // Doubling in ticks would overflow long before the cap matters, so count the doublings first.
+ var doublings = Math.Clamp(consecutiveFailures - 1, 0, 30);
+ var wait = first * Math.Pow(2, doublings);
+
+ return wait > max ? max : wait;
+ }
+
+ /// When a title becomes eligible for another automatic attempt.
+ public static DateTimeOffset GetRetryAfter(DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset failedAt)
+ => failedAt + GetWait(kind, consecutiveFailures);
+}
diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs
index bfa51a1c..40e69016 100644
--- a/Source/ApplicationServices/LibraryCommands.cs
+++ b/Source/ApplicationServices/LibraryCommands.cs
@@ -579,13 +579,19 @@ public static class LibraryCommands
return 0;
int qtyChanges;
+ var statusChanged = new List();
using (var context = DbContexts.GetContext())
{
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in nonNullBooks)
{
+ var statusBefore = book.Book.UserDefinedItem.BookStatus;
+
action?.Invoke(book.Book.UserDefinedItem);
+ if (book.Book.UserDefinedItem.BookStatus != statusBefore)
+ statusChanged.Add(book);
+
var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
@@ -596,7 +602,16 @@ public static class LibraryCommands
qtyChanges = context.SaveChanges();
}
if (qtyChanges > 0)
+ {
+ // Changing a title's download status is the user saying they want a different outcome for it,
+ // so drop any wait Libation was observing before attempting it again. Compared against the
+ // previous value rather than acting on every call: editing tags or a rating must not quietly
+ // put a title Audible just refused back into the next scheduled run.
+ foreach (var book in statusChanged)
+ DownloadAttemptFailureStore.Clear(book);
+
BookUserDefinedItemCommitted?.Invoke(null, nonNullBooks);
+ }
return qtyChanges;
}
diff --git a/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.Designer.cs b/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.Designer.cs
new file mode 100644
index 00000000..0eaaf6d6
--- /dev/null
+++ b/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.Designer.cs
@@ -0,0 +1,578 @@
+//
+using System;
+using DataLayer;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DataLayer.Postgres.Migrations
+{
+ [DbContext(typeof(LibationContext))]
+ [Migration("20260816160446_AddDownloadAttemptFailures")]
+ partial class AddDownloadAttemptFailures
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CategoryCategoryLadder", b =>
+ {
+ b.Property("_categoriesCategoryId")
+ .HasColumnType("integer");
+
+ b.Property("_categoryLaddersCategoryLadderId")
+ .HasColumnType("integer");
+
+ b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
+
+ b.HasIndex("_categoryLaddersCategoryLadderId");
+
+ b.ToTable("CategoryCategoryLadder");
+ });
+
+ modelBuilder.Entity("DataLayer.Book", b =>
+ {
+ b.Property("BookId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BookId"));
+
+ b.Property("AudibleProductId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .HasColumnType("integer");
+
+ b.Property("DatePublished")
+ .HasColumnType("timestamp without time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsAbridged")
+ .HasColumnType("boolean");
+
+ b.Property("IsSpatial")
+ .HasColumnType("boolean");
+
+ b.Property("Language")
+ .HasColumnType("text");
+
+ b.Property("LengthInMinutes")
+ .HasColumnType("integer");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PictureId")
+ .HasColumnType("text");
+
+ b.Property("PictureLarge")
+ .HasColumnType("text");
+
+ b.Property("Subtitle")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("BookId");
+
+ b.HasIndex("AudibleProductId");
+
+ b.ToTable("Books");
+ });
+
+ modelBuilder.Entity("DataLayer.BookCategory", b =>
+ {
+ b.Property("BookId")
+ .HasColumnType("integer");
+
+ b.Property("CategoryLadderId")
+ .HasColumnType("integer");
+
+ b.HasKey("BookId", "CategoryLadderId");
+
+ b.HasIndex("BookId");
+
+ b.HasIndex("CategoryLadderId");
+
+ b.ToTable("BookCategory");
+ });
+
+ modelBuilder.Entity("DataLayer.BookContributor", b =>
+ {
+ b.Property("BookId")
+ .HasColumnType("integer");
+
+ b.Property("ContributorId")
+ .HasColumnType("integer");
+
+ b.Property("Role")
+ .HasColumnType("integer");
+
+ b.Property("Order")
+ .HasColumnType("smallint");
+
+ b.HasKey("BookId", "ContributorId", "Role");
+
+ b.HasIndex("BookId");
+
+ b.HasIndex("ContributorId");
+
+ b.ToTable("BookContributor");
+ });
+
+ modelBuilder.Entity("DataLayer.Category", b =>
+ {
+ b.Property("CategoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId"));
+
+ b.Property("AudibleCategoryId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("CategoryId");
+
+ b.HasIndex("AudibleCategoryId");
+
+ b.ToTable("Categories");
+ });
+
+ modelBuilder.Entity("DataLayer.CategoryLadder", b =>
+ {
+ b.Property("CategoryLadderId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryLadderId"));
+
+ b.HasKey("CategoryLadderId");
+
+ b.ToTable("CategoryLadders");
+ });
+
+ modelBuilder.Entity("DataLayer.Contributor", b =>
+ {
+ b.Property("ContributorId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ContributorId"));
+
+ b.Property("AudibleContributorId")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("ContributorId");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Contributors");
+
+ b.HasData(
+ new
+ {
+ ContributorId = -1,
+ Name = ""
+ });
+ });
+
+ modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
+ {
+ b.Property("DownloadAttemptFailureId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DownloadAttemptFailureId"));
+
+ b.Property("Account")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("AudibleProductId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ConsecutiveFailures")
+ .HasColumnType("integer");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("LastFailedAtUtcTicks")
+ .HasColumnType("bigint");
+
+ b.Property("Reason")
+ .HasColumnType("text");
+
+ b.Property("RetryAfterUtcTicks")
+ .HasColumnType("bigint");
+
+ b.HasKey("DownloadAttemptFailureId");
+
+ b.HasIndex("Account", "AudibleProductId")
+ .IsUnique();
+
+ b.ToTable("DownloadAttemptFailures");
+ });
+
+ modelBuilder.Entity("DataLayer.DownloadHistory", b =>
+ {
+ b.Property("DownloadHistoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DownloadHistoryId"));
+
+ b.Property("AudibleProductId")
+ .HasColumnType("text");
+
+ b.Property("Bytes")
+ .HasColumnType("bigint");
+
+ b.Property("CompletedAtUtcTicks")
+ .HasColumnType("bigint");
+
+ b.Property("IsAudiblePlus")
+ .HasColumnType("boolean");
+
+ b.HasKey("DownloadHistoryId");
+
+ b.HasIndex("CompletedAtUtcTicks");
+
+ b.ToTable("DownloadHistory");
+ });
+
+ modelBuilder.Entity("DataLayer.LibraryBook", b =>
+ {
+ b.Property("BookId")
+ .HasColumnType("integer");
+
+ b.Property("AbsentFromLastScan")
+ .HasColumnType("boolean");
+
+ b.Property("Account")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DateAdded")
+ .HasColumnType("timestamp without time zone");
+
+ b.Property("IncludedUntil")
+ .HasColumnType("timestamp without time zone");
+
+ b.Property("IsAudiblePlus")
+ .HasColumnType("boolean");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.HasKey("BookId");
+
+ b.ToTable("LibraryBooks");
+ });
+
+ modelBuilder.Entity("DataLayer.Series", b =>
+ {
+ b.Property("SeriesId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SeriesId"));
+
+ b.Property("AudibleSeriesId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.HasKey("SeriesId");
+
+ b.HasIndex("AudibleSeriesId");
+
+ b.ToTable("Series");
+ });
+
+ modelBuilder.Entity("DataLayer.SeriesBook", b =>
+ {
+ b.Property("SeriesId")
+ .HasColumnType("integer");
+
+ b.Property("BookId")
+ .HasColumnType("integer");
+
+ b.Property("Order")
+ .HasColumnType("text");
+
+ b.HasKey("SeriesId", "BookId");
+
+ b.HasIndex("BookId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("SeriesBook");
+ });
+
+ modelBuilder.Entity("CategoryCategoryLadder", b =>
+ {
+ b.HasOne("DataLayer.Category", null)
+ .WithMany()
+ .HasForeignKey("_categoriesCategoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("DataLayer.CategoryLadder", null)
+ .WithMany()
+ .HasForeignKey("_categoryLaddersCategoryLadderId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("DataLayer.Book", b =>
+ {
+ b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
+ {
+ b1.Property("BookId")
+ .HasColumnType("integer");
+
+ b1.Property("OverallRating")
+ .HasColumnType("real");
+
+ b1.Property("PerformanceRating")
+ .HasColumnType("real");
+
+ b1.Property("StoryRating")
+ .HasColumnType("real");
+
+ b1.HasKey("BookId");
+
+ b1.ToTable("Books");
+
+ b1.WithOwner()
+ .HasForeignKey("BookId");
+ });
+
+ b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
+ {
+ b1.Property("SupplementId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("SupplementId"));
+
+ b1.Property("BookId")
+ .HasColumnType("integer");
+
+ b1.Property("Url")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.HasKey("SupplementId");
+
+ b1.HasIndex("BookId");
+
+ b1.ToTable("Supplement");
+
+ b1.WithOwner("Book")
+ .HasForeignKey("BookId");
+
+ b1.Navigation("Book");
+ });
+
+ b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
+ {
+ b1.Property("BookId")
+ .HasColumnType("integer");
+
+ b1.Property("BookStatus")
+ .HasColumnType("integer");
+
+ b1.Property("IsFinished")
+ .HasColumnType("boolean");
+
+ b1.Property("LastDownloaded")
+ .HasColumnType("timestamp without time zone");
+
+ b1.Property("LastDownloadedFileVersion")
+ .HasColumnType("text");
+
+ b1.Property("LastDownloadedFormat")
+ .HasColumnType("bigint");
+
+ b1.Property("LastDownloadedVersion")
+ .HasColumnType("text");
+
+ b1.Property("PdfStatus")
+ .HasColumnType("integer");
+
+ b1.Property("Tags")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.HasKey("BookId");
+
+ b1.ToTable("UserDefinedItem", (string)null);
+
+ b1.WithOwner("Book")
+ .HasForeignKey("BookId");
+
+ b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
+ {
+ b2.Property("UserDefinedItemBookId")
+ .HasColumnType("integer");
+
+ b2.Property("OverallRating")
+ .HasColumnType("real");
+
+ b2.Property("PerformanceRating")
+ .HasColumnType("real");
+
+ b2.Property("StoryRating")
+ .HasColumnType("real");
+
+ b2.HasKey("UserDefinedItemBookId");
+
+ b2.ToTable("UserDefinedItem");
+
+ b2.WithOwner()
+ .HasForeignKey("UserDefinedItemBookId");
+ });
+
+ b1.Navigation("Book");
+
+ b1.Navigation("Rating")
+ .IsRequired();
+ });
+
+ b.Navigation("Rating")
+ .IsRequired();
+
+ b.Navigation("Supplements");
+
+ b.Navigation("UserDefinedItem")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("DataLayer.BookCategory", b =>
+ {
+ b.HasOne("DataLayer.Book", "Book")
+ .WithMany("CategoriesLink")
+ .HasForeignKey("BookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
+ .WithMany("BooksLink")
+ .HasForeignKey("CategoryLadderId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Book");
+
+ b.Navigation("CategoryLadder");
+ });
+
+ modelBuilder.Entity("DataLayer.BookContributor", b =>
+ {
+ b.HasOne("DataLayer.Book", "Book")
+ .WithMany("ContributorsLink")
+ .HasForeignKey("BookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("DataLayer.Contributor", "Contributor")
+ .WithMany("BooksLink")
+ .HasForeignKey("ContributorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Book");
+
+ b.Navigation("Contributor");
+ });
+
+ modelBuilder.Entity("DataLayer.LibraryBook", b =>
+ {
+ b.HasOne("DataLayer.Book", "Book")
+ .WithOne()
+ .HasForeignKey("DataLayer.LibraryBook", "BookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Book");
+ });
+
+ modelBuilder.Entity("DataLayer.SeriesBook", b =>
+ {
+ b.HasOne("DataLayer.Book", "Book")
+ .WithMany("SeriesLink")
+ .HasForeignKey("BookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("DataLayer.Series", "Series")
+ .WithMany("BooksLink")
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Book");
+
+ b.Navigation("Series");
+ });
+
+ modelBuilder.Entity("DataLayer.Book", b =>
+ {
+ b.Navigation("CategoriesLink");
+
+ b.Navigation("ContributorsLink");
+
+ b.Navigation("SeriesLink");
+ });
+
+ modelBuilder.Entity("DataLayer.CategoryLadder", b =>
+ {
+ b.Navigation("BooksLink");
+ });
+
+ modelBuilder.Entity("DataLayer.Contributor", b =>
+ {
+ b.Navigation("BooksLink");
+ });
+
+ modelBuilder.Entity("DataLayer.Series", b =>
+ {
+ b.Navigation("BooksLink");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.cs b/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.cs
new file mode 100644
index 00000000..63e0eabe
--- /dev/null
+++ b/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.cs
@@ -0,0 +1,47 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DataLayer.Postgres.Migrations
+{
+ ///
+ public partial class AddDownloadAttemptFailures : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DownloadAttemptFailures",
+ columns: table => new
+ {
+ DownloadAttemptFailureId = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ AudibleProductId = table.Column(type: "text", nullable: false),
+ Account = table.Column(type: "text", nullable: false),
+ Kind = table.Column(type: "integer", nullable: false),
+ ConsecutiveFailures = table.Column(type: "integer", nullable: false),
+ LastFailedAtUtcTicks = table.Column(type: "bigint", nullable: false),
+ RetryAfterUtcTicks = table.Column(type: "bigint", nullable: false),
+ Reason = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DownloadAttemptFailures", x => x.DownloadAttemptFailureId);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DownloadAttemptFailures_Account_AudibleProductId",
+ table: "DownloadAttemptFailures",
+ columns: new[] { "Account", "AudibleProductId" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DownloadAttemptFailures");
+ }
+ }
+}
diff --git a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs
index 1e4cffb9..59e0e8ce 100644
--- a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs
+++ b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs
@@ -201,6 +201,45 @@ namespace DataLayer.Postgres.Migrations
});
});
+ modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
+ {
+ b.Property("DownloadAttemptFailureId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DownloadAttemptFailureId"));
+
+ b.Property("Account")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("AudibleProductId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ConsecutiveFailures")
+ .HasColumnType("integer");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("LastFailedAtUtcTicks")
+ .HasColumnType("bigint");
+
+ b.Property("Reason")
+ .HasColumnType("text");
+
+ b.Property("RetryAfterUtcTicks")
+ .HasColumnType("bigint");
+
+ b.HasKey("DownloadAttemptFailureId");
+
+ b.HasIndex("Account", "AudibleProductId")
+ .IsUnique();
+
+ b.ToTable("DownloadAttemptFailures");
+ });
+
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
{
b.Property("DownloadHistoryId")
diff --git a/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.Designer.cs b/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.Designer.cs
new file mode 100644
index 00000000..35a9fda5
--- /dev/null
+++ b/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.Designer.cs
@@ -0,0 +1,557 @@
+//
+using System;
+using DataLayer;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace DataLayer.Migrations
+{
+ [DbContext(typeof(LibationContext))]
+ [Migration("20260816160439_AddDownloadAttemptFailures")]
+ partial class AddDownloadAttemptFailures
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
+
+ modelBuilder.Entity("CategoryCategoryLadder", b =>
+ {
+ b.Property("_categoriesCategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("_categoryLaddersCategoryLadderId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
+
+ b.HasIndex("_categoryLaddersCategoryLadderId");
+
+ b.ToTable("CategoryCategoryLadder");
+ });
+
+ modelBuilder.Entity("DataLayer.Book", b =>
+ {
+ b.Property("BookId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AudibleProductId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ContentType")
+ .HasColumnType("INTEGER");
+
+ b.Property("DatePublished")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("IsAbridged")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsSpatial")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasColumnType("TEXT");
+
+ b.Property("LengthInMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("PictureId")
+ .HasColumnType("TEXT");
+
+ b.Property("PictureLarge")
+ .HasColumnType("TEXT");
+
+ b.Property("Subtitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("BookId");
+
+ b.HasIndex("AudibleProductId");
+
+ b.ToTable("Books");
+ });
+
+ modelBuilder.Entity("DataLayer.BookCategory", b =>
+ {
+ b.Property("BookId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CategoryLadderId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("BookId", "CategoryLadderId");
+
+ b.HasIndex("BookId");
+
+ b.HasIndex("CategoryLadderId");
+
+ b.ToTable("BookCategory");
+ });
+
+ modelBuilder.Entity("DataLayer.BookContributor", b =>
+ {
+ b.Property("BookId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ContributorId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Role")
+ .HasColumnType("INTEGER");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("BookId", "ContributorId", "Role");
+
+ b.HasIndex("BookId");
+
+ b.HasIndex("ContributorId");
+
+ b.ToTable("BookContributor");
+ });
+
+ modelBuilder.Entity("DataLayer.Category", b =>
+ {
+ b.Property("CategoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AudibleCategoryId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("CategoryId");
+
+ b.HasIndex("AudibleCategoryId");
+
+ b.ToTable("Categories");
+ });
+
+ modelBuilder.Entity("DataLayer.CategoryLadder", b =>
+ {
+ b.Property("CategoryLadderId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("CategoryLadderId");
+
+ b.ToTable("CategoryLadders");
+ });
+
+ modelBuilder.Entity("DataLayer.Contributor", b =>
+ {
+ b.Property("ContributorId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AudibleContributorId")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ContributorId");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Contributors");
+
+ b.HasData(
+ new
+ {
+ ContributorId = -1,
+ Name = ""
+ });
+ });
+
+ modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
+ {
+ b.Property("DownloadAttemptFailureId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Account")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("AudibleProductId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ConsecutiveFailures")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastFailedAtUtcTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("Reason")
+ .HasColumnType("TEXT");
+
+ b.Property("RetryAfterUtcTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("DownloadAttemptFailureId");
+
+ b.HasIndex("Account", "AudibleProductId")
+ .IsUnique();
+
+ b.ToTable("DownloadAttemptFailures");
+ });
+
+ modelBuilder.Entity("DataLayer.DownloadHistory", b =>
+ {
+ b.Property("DownloadHistoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AudibleProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("Bytes")
+ .HasColumnType("INTEGER");
+
+ b.Property("CompletedAtUtcTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsAudiblePlus")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("DownloadHistoryId");
+
+ b.HasIndex("CompletedAtUtcTicks");
+
+ b.ToTable("DownloadHistory");
+ });
+
+ modelBuilder.Entity("DataLayer.LibraryBook", b =>
+ {
+ b.Property("BookId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AbsentFromLastScan")
+ .HasColumnType("INTEGER");
+
+ b.Property("Account")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("DateAdded")
+ .HasColumnType("TEXT");
+
+ b.Property("IncludedUntil")
+ .HasColumnType("TEXT");
+
+ b.Property("IsAudiblePlus")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsDeleted")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("BookId");
+
+ b.ToTable("LibraryBooks");
+ });
+
+ modelBuilder.Entity("DataLayer.Series", b =>
+ {
+ b.Property("SeriesId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AudibleSeriesId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("SeriesId");
+
+ b.HasIndex("AudibleSeriesId");
+
+ b.ToTable("Series");
+ });
+
+ modelBuilder.Entity("DataLayer.SeriesBook", b =>
+ {
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Order")
+ .HasColumnType("TEXT");
+
+ b.HasKey("SeriesId", "BookId");
+
+ b.HasIndex("BookId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("SeriesBook");
+ });
+
+ modelBuilder.Entity("CategoryCategoryLadder", b =>
+ {
+ b.HasOne("DataLayer.Category", null)
+ .WithMany()
+ .HasForeignKey("_categoriesCategoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("DataLayer.CategoryLadder", null)
+ .WithMany()
+ .HasForeignKey("_categoryLaddersCategoryLadderId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("DataLayer.Book", b =>
+ {
+ b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
+ {
+ b1.Property("BookId")
+ .HasColumnType("INTEGER");
+
+ b1.Property("OverallRating")
+ .HasColumnType("REAL");
+
+ b1.Property("PerformanceRating")
+ .HasColumnType("REAL");
+
+ b1.Property("StoryRating")
+ .HasColumnType("REAL");
+
+ b1.HasKey("BookId");
+
+ b1.ToTable("Books");
+
+ b1.WithOwner()
+ .HasForeignKey("BookId");
+ });
+
+ b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
+ {
+ b1.Property("SupplementId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b1.Property("BookId")
+ .HasColumnType("INTEGER");
+
+ b1.Property("Url")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b1.HasKey("SupplementId");
+
+ b1.HasIndex("BookId");
+
+ b1.ToTable("Supplement");
+
+ b1.WithOwner("Book")
+ .HasForeignKey("BookId");
+
+ b1.Navigation("Book");
+ });
+
+ b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
+ {
+ b1.Property("BookId")
+ .HasColumnType("INTEGER");
+
+ b1.Property("BookStatus")
+ .HasColumnType("INTEGER");
+
+ b1.Property("IsFinished")
+ .HasColumnType("INTEGER");
+
+ b1.Property("LastDownloaded")
+ .HasColumnType("TEXT");
+
+ b1.Property("LastDownloadedFileVersion")
+ .HasColumnType("TEXT");
+
+ b1.Property("LastDownloadedFormat")
+ .HasColumnType("INTEGER");
+
+ b1.Property("LastDownloadedVersion")
+ .HasColumnType("TEXT");
+
+ b1.Property("PdfStatus")
+ .HasColumnType("INTEGER");
+
+ b1.Property("Tags")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b1.HasKey("BookId");
+
+ b1.ToTable("UserDefinedItem", (string)null);
+
+ b1.WithOwner("Book")
+ .HasForeignKey("BookId");
+
+ b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
+ {
+ b2.Property("UserDefinedItemBookId")
+ .HasColumnType("INTEGER");
+
+ b2.Property("OverallRating")
+ .HasColumnType("REAL");
+
+ b2.Property("PerformanceRating")
+ .HasColumnType("REAL");
+
+ b2.Property("StoryRating")
+ .HasColumnType("REAL");
+
+ b2.HasKey("UserDefinedItemBookId");
+
+ b2.ToTable("UserDefinedItem");
+
+ b2.WithOwner()
+ .HasForeignKey("UserDefinedItemBookId");
+ });
+
+ b1.Navigation("Book");
+
+ b1.Navigation("Rating")
+ .IsRequired();
+ });
+
+ b.Navigation("Rating")
+ .IsRequired();
+
+ b.Navigation("Supplements");
+
+ b.Navigation("UserDefinedItem")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("DataLayer.BookCategory", b =>
+ {
+ b.HasOne("DataLayer.Book", "Book")
+ .WithMany("CategoriesLink")
+ .HasForeignKey("BookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
+ .WithMany("BooksLink")
+ .HasForeignKey("CategoryLadderId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Book");
+
+ b.Navigation("CategoryLadder");
+ });
+
+ modelBuilder.Entity("DataLayer.BookContributor", b =>
+ {
+ b.HasOne("DataLayer.Book", "Book")
+ .WithMany("ContributorsLink")
+ .HasForeignKey("BookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("DataLayer.Contributor", "Contributor")
+ .WithMany("BooksLink")
+ .HasForeignKey("ContributorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Book");
+
+ b.Navigation("Contributor");
+ });
+
+ modelBuilder.Entity("DataLayer.LibraryBook", b =>
+ {
+ b.HasOne("DataLayer.Book", "Book")
+ .WithOne()
+ .HasForeignKey("DataLayer.LibraryBook", "BookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Book");
+ });
+
+ modelBuilder.Entity("DataLayer.SeriesBook", b =>
+ {
+ b.HasOne("DataLayer.Book", "Book")
+ .WithMany("SeriesLink")
+ .HasForeignKey("BookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("DataLayer.Series", "Series")
+ .WithMany("BooksLink")
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Book");
+
+ b.Navigation("Series");
+ });
+
+ modelBuilder.Entity("DataLayer.Book", b =>
+ {
+ b.Navigation("CategoriesLink");
+
+ b.Navigation("ContributorsLink");
+
+ b.Navigation("SeriesLink");
+ });
+
+ modelBuilder.Entity("DataLayer.CategoryLadder", b =>
+ {
+ b.Navigation("BooksLink");
+ });
+
+ modelBuilder.Entity("DataLayer.Contributor", b =>
+ {
+ b.Navigation("BooksLink");
+ });
+
+ modelBuilder.Entity("DataLayer.Series", b =>
+ {
+ b.Navigation("BooksLink");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.cs b/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.cs
new file mode 100644
index 00000000..69eca2ba
--- /dev/null
+++ b/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.cs
@@ -0,0 +1,46 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace DataLayer.Migrations
+{
+ ///
+ public partial class AddDownloadAttemptFailures : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DownloadAttemptFailures",
+ columns: table => new
+ {
+ DownloadAttemptFailureId = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ AudibleProductId = table.Column(type: "TEXT", nullable: false),
+ Account = table.Column(type: "TEXT", nullable: false),
+ Kind = table.Column(type: "INTEGER", nullable: false),
+ ConsecutiveFailures = table.Column(type: "INTEGER", nullable: false),
+ LastFailedAtUtcTicks = table.Column(type: "INTEGER", nullable: false),
+ RetryAfterUtcTicks = table.Column(type: "INTEGER", nullable: false),
+ Reason = table.Column(type: "TEXT", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DownloadAttemptFailures", x => x.DownloadAttemptFailureId);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DownloadAttemptFailures_Account_AudibleProductId",
+ table: "DownloadAttemptFailures",
+ columns: new[] { "Account", "AudibleProductId" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DownloadAttemptFailures");
+ }
+ }
+}
diff --git a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs
index 4a5c84d4..3787d1bf 100644
--- a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs
+++ b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs
@@ -188,6 +188,43 @@ namespace DataLayer.Migrations
});
});
+ modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
+ {
+ b.Property("DownloadAttemptFailureId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Account")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("AudibleProductId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ConsecutiveFailures")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastFailedAtUtcTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("Reason")
+ .HasColumnType("TEXT");
+
+ b.Property("RetryAfterUtcTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("DownloadAttemptFailureId");
+
+ b.HasIndex("Account", "AudibleProductId")
+ .IsUnique();
+
+ b.ToTable("DownloadAttemptFailures");
+ });
+
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
{
b.Property("DownloadHistoryId")
diff --git a/Source/DataLayer/Configurations/DownloadAttemptFailureConfig.cs b/Source/DataLayer/Configurations/DownloadAttemptFailureConfig.cs
new file mode 100644
index 00000000..b4ceb100
--- /dev/null
+++ b/Source/DataLayer/Configurations/DownloadAttemptFailureConfig.cs
@@ -0,0 +1,16 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations;
+
+internal class DownloadAttemptFailureConfig : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(f => f.DownloadAttemptFailureId);
+
+ // One row per title per account, upserted on each failure: the point is to remember the latest
+ // verdict, not to accumulate a history.
+ entity.HasIndex(f => new { f.Account, f.AudibleProductId }).IsUnique();
+ }
+}
diff --git a/Source/DataLayer/EfClasses/DownloadAttemptFailure.cs b/Source/DataLayer/EfClasses/DownloadAttemptFailure.cs
new file mode 100644
index 00000000..a9d7ec77
--- /dev/null
+++ b/Source/DataLayer/EfClasses/DownloadAttemptFailure.cs
@@ -0,0 +1,98 @@
+using System;
+
+namespace DataLayer;
+
+///
+/// Why a download attempt failed, coarse enough to choose how long to wait before trying again.
+/// Persisted as an int; do not renumber.
+///
+public enum DownloadFailureKind
+{
+ ///
+ /// Audible refused a content license and named an eligibility reason: the title is not owned, is not in
+ /// the Plus catalog, or the account is not entitled to it. Changes only when the account or the catalog
+ /// changes, so this is worth waiting a long time on.
+ ///
+ LicenseDenied = 0,
+
+ ///
+ /// Audible accepted the request but has no downloadable asset, as for a preorder that has not been
+ /// released. Expected to start working by itself once the title is published.
+ ///
+ AssetUnavailable = 1,
+
+ ///
+ /// Looks like a service interruption or throttling rather than a decision about this title. Retried soon.
+ ///
+ ServiceInterruption = 2,
+}
+
+///
+/// The most recent failed attempt to download one title, so that a title Audible has just refused is not
+/// requested again on every run. One row per (account, title): the same ASIN can be refused on one account
+/// and downloadable on another.
+///
+/// Nothing here is permanent. always names a time, so a title held back
+/// because of an outage, throttling or an unreleased preorder starts being attempted again on its own.
+///
+///
+/// The database is deliberately the home for this instead of a file under LibationFiles: in Docker,
+/// LibationFiles is a throwaway directory inside the container and only the database is on a volume, so a
+/// file-based record would forget every failure on each container start - exactly the case this fixes.
+///
+///
+public class DownloadAttemptFailure
+{
+ internal int DownloadAttemptFailureId { get; private set; }
+
+ public string AudibleProductId { get; private set; }
+
+ /// The the attempt was made with.
+ public string Account { get; private set; }
+
+ public DownloadFailureKind Kind { get; private set; }
+
+ /// Failures in a row without an intervening success. Drives how long the next wait is.
+ public int ConsecutiveFailures { get; private set; }
+
+ ///
+ /// UTC ticks rather than a DateTime so range queries mean the same thing on SQLite and PostgreSQL.
+ /// Local time is for display only.
+ ///
+ public long LastFailedAtUtcTicks { get; private set; }
+
+ /// When this title becomes eligible for another automatic attempt, in UTC ticks.
+ public long RetryAfterUtcTicks { get; private set; }
+
+ /// One line from Audible, kept so the user can be told why without re-requesting a license.
+ public string? Reason { get; private set; }
+
+ public DateTimeOffset LastFailedAt => new(LastFailedAtUtcTicks, TimeSpan.Zero);
+ public DateTimeOffset RetryAfter => new(RetryAfterUtcTicks, TimeSpan.Zero);
+
+ private DownloadAttemptFailure()
+ {
+ // for EF
+ AudibleProductId = null!;
+ Account = null!;
+ }
+
+ public DownloadAttemptFailure(string audibleProductId, string account, DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset lastFailedAt, DateTimeOffset retryAfter, string? reason)
+ {
+ AudibleProductId = audibleProductId;
+ Account = account;
+ Record(kind, consecutiveFailures, lastFailedAt, retryAfter, reason);
+ }
+
+ public void Record(DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset lastFailedAt, DateTimeOffset retryAfter, string? reason)
+ {
+ Kind = kind;
+ ConsecutiveFailures = consecutiveFailures;
+ LastFailedAtUtcTicks = lastFailedAt.UtcTicks;
+ RetryAfterUtcTicks = retryAfter.UtcTicks;
+ Reason = reason;
+ }
+
+ public override string ToString()
+ => $"{AudibleProductId} {Kind} x{ConsecutiveFailures}, retry after {RetryAfter.ToLocalTime()}";
+}
diff --git a/Source/DataLayer/LibationContext.cs b/Source/DataLayer/LibationContext.cs
index 795c26d6..2d88f246 100644
--- a/Source/DataLayer/LibationContext.cs
+++ b/Source/DataLayer/LibationContext.cs
@@ -27,6 +27,7 @@ public class LibationContext : DbContext, INotifyDisposed
public DbSet Categories { get; private set; }
public DbSet CategoryLadders { get; private set; }
public DbSet DownloadHistory { get; private set; }
+ public DbSet DownloadAttemptFailures { get; private set; }
public event EventHandler? ObjectDisposed;
public override void Dispose()
@@ -58,6 +59,7 @@ public class LibationContext : DbContext, INotifyDisposed
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
modelBuilder.ApplyConfiguration(new DownloadHistoryConfig());
+ modelBuilder.ApplyConfiguration(new DownloadAttemptFailureConfig());
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs
index 56cd007f..4e6bedae 100644
--- a/Source/FileLiberator/DownloadDecryptBook.cs
+++ b/Source/FileLiberator/DownloadDecryptBook.cs
@@ -28,6 +28,8 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable !libraryBook.Book.AudioExists;
+ protected override bool RecordsAttemptFailures => true;
+
public override async Task CancelAsync()
{
if (abDownloader is not null) await abDownloader.CancelAsync();
diff --git a/Source/FileLiberator/DownloadFailureClassifier.cs b/Source/FileLiberator/DownloadFailureClassifier.cs
new file mode 100644
index 00000000..cacd2a68
--- /dev/null
+++ b/Source/FileLiberator/DownloadFailureClassifier.cs
@@ -0,0 +1,88 @@
+using AudibleApi;
+using AudibleApi.Common;
+using DataLayer;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace FileLiberator;
+
+/// What Libation understood about a failed download attempt, and how long to wait because of it.
+public sealed record DownloadFailureDiagnosis(DownloadFailureKind Kind, string Reason);
+
+///
+/// Recognises the download failures that mean "asking again right now will fail the same way": Audible
+/// refusing a license, and Audible having no audio to deliver.
+///
+/// Only failures recognised here are recorded and waited on. Everything else - a dropped connection, a
+/// decrypt error, a full disk - keeps the long-standing behaviour of being retried on the next run, because
+/// there is no reason to believe the next attempt fails for the same reason.
+///
+///
+public static class DownloadFailureClassifier
+{
+ ///
+ /// Substring in Audible's Sable error when no audio asset exists for the title, which is what an
+ /// unreleased preorder looks like. Shared with ,
+ /// which pairs it with an error code to spot a much narrower case.
+ ///
+ private const string NoAudioAssetMarker = "acr:null";
+
+ public static bool TryClassify(Exception ex, [NotNullWhen(true)] out DownloadFailureDiagnosis? diagnosis)
+ {
+ diagnosis = Classify(ex);
+ return diagnosis is not null;
+ }
+
+ public static DownloadFailureDiagnosis? Classify(Exception? ex)
+ => ex switch
+ {
+ null => null,
+ ContentLicenseDeniedException denied => ClassifyLicenseDenial(denied),
+ ApiErrorException api => ClassifyApiError(api),
+ // A rethrown Widevine failure arrives wrapped by whichever step gave up on it.
+ _ => Classify(ex.InnerException)
+ };
+
+ ///
+ /// Audible attaches a rejection reason per validation type. GenericError is Audible declining to
+ /// say why, which in practice means an outage or throttling rather than a decision about the title; the
+ /// GUI already treats it that way when it offers guidance. Anything else names an eligibility problem
+ /// with the account or the title, which will not change within the hour.
+ ///
+ private static DownloadFailureDiagnosis ClassifyLicenseDenial(ContentLicenseDeniedException ex)
+ {
+ LicenseDenialReason?[] reasons = [ex.Ownership, ex.AYCL, ex.Membership, ex.Client];
+
+ var looksLikeOutage
+ = ex.AYCL?.RejectionReason is null or RejectionReason.GenericError
+ || reasons.Any(r => r?.RejectionReason is RejectionReason.GenericError);
+
+ return new DownloadFailureDiagnosis(
+ looksLikeOutage ? DownloadFailureKind.ServiceInterruption : DownloadFailureKind.LicenseDenied,
+ BuildLicenseDenialReason(reasons) ?? ex.Message);
+ }
+
+ /// The most specific message Audible gave, prefixed with which check it failed.
+ private static string? BuildLicenseDenialReason(IEnumerable reasons)
+ => reasons
+ .Where(r => !string.IsNullOrWhiteSpace(r?.Message))
+ .Select(r => r!.ValidationType is { Length: > 0 } type ? $"{type}: {r.Message}" : r.Message)
+ .FirstOrDefault();
+
+ ///
+ /// A license request that fails with no content reference (acr:null) means Audible has nothing to
+ /// deliver for this title yet, which is what a preorder that has not been released looks like.
+ ///
+ private static DownloadFailureDiagnosis? ClassifyApiError(ApiErrorException ex)
+ {
+ if (ex.RequestUri?.Contains("/licenserequest", StringComparison.OrdinalIgnoreCase) is not true
+ || ex.JsonMessage?.Contains(NoAudioAssetMarker, StringComparison.Ordinal) is not true)
+ return null;
+
+ return new DownloadFailureDiagnosis(
+ DownloadFailureKind.AssetUnavailable,
+ "Audible returned no audio for this title. An unreleased preorder looks like this; so does a title Audible has not finished preparing.");
+ }
+}
diff --git a/Source/FileLiberator/Processable.cs b/Source/FileLiberator/Processable.cs
index 813029ab..22ad9dae 100644
--- a/Source/FileLiberator/Processable.cs
+++ b/Source/FileLiberator/Processable.cs
@@ -1,4 +1,5 @@
-using DataLayer;
+using ApplicationServices;
+using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
@@ -42,6 +43,13 @@ public abstract class Processable
/// True == success
public abstract Task ProcessAsync(LibraryBook libraryBook);
+ ///
+ /// Whether a refusal from Audible during this step should be remembered, so a scheduled run stops asking
+ /// for the same license every time. Only the audiobook download does: it is the request Audible refuses,
+ /// and the record gates that same request.
+ ///
+ protected virtual bool RecordsAttemptFailures => false;
+
// when used in foreach: stateful. deferred execution
public IEnumerable GetValidLibraryBooks(IEnumerable library)
=> library.Where(libraryBook =>
@@ -62,15 +70,42 @@ public abstract class Processable
Account = libraryBook.Account?.ToMask() ?? "[empty]"
});
- var status
- = (await ProcessAsync(libraryBook))
- ?? new StatusHandler { "Processable should never return a null status" };
+ StatusHandler status;
+ try
+ {
+ status
+ = (await ProcessAsync(libraryBook))
+ ?? new StatusHandler { "Processable should never return a null status" };
+ }
+ catch (Exception ex)
+ {
+ RecordAttemptFailure(libraryBook, ex);
+ throw;
+ }
+ finally
+ {
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
+ }
- GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
+ if (status.IsSuccess && RecordsAttemptFailures)
+ DownloadAttemptFailureStore.Clear(libraryBook);
return status;
}
+ ///
+ /// Recorded here rather than in each host so the CLI, the GUI queue and anything added later all remember
+ /// a refusal the same way. Failures Libation cannot attribute to Audible are left unrecorded and keep
+ /// being retried on the next run.
+ ///
+ private void RecordAttemptFailure(LibraryBook libraryBook, Exception ex)
+ {
+ if (!RecordsAttemptFailures || !DownloadFailureClassifier.TryClassify(ex, out var diagnosis))
+ return;
+
+ DownloadAttemptFailureStore.Record(libraryBook, diagnosis.Kind, diagnosis.Reason);
+ }
+
public async Task TryProcessAsync(LibraryBook libraryBook)
=> Validate(libraryBook)
? await ProcessAsync(libraryBook)
diff --git a/Source/LibationCli/Options/LiberateOptions.cs b/Source/LibationCli/Options/LiberateOptions.cs
index a78cd163..93551735 100644
--- a/Source/LibationCli/Options/LiberateOptions.cs
+++ b/Source/LibationCli/Options/LiberateOptions.cs
@@ -49,6 +49,9 @@ public class LiberateOptions : ProcessableOptionsBase
#endregion
+ // --force means "attempt everything", which includes the titles Audible recently refused.
+ protected override bool HonorsDeferredRetries => !Force;
+
protected override async Task ProcessAsync()
{
if (!RunDownloadLimit.TryCreate(LimitBooks, LimitMB, LimitGB, PdfOnly, out runLimit, out var limitError))
@@ -174,6 +177,11 @@ public class LiberateOptions : ProcessableOptionsBase
{
lb.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated;
lb.Book.UserDefinedItem.SetPdfStatus(LiberatedStatus.NotLiberated);
+
+ // The status above is set on an untracked copy, so the central clear in updateUserDefinedItem
+ // never sees it. Asking for this title is the user overriding any wait Libation was observing,
+ // and the wait must restart from the beginning if the attempt fails again.
+ DownloadAttemptFailureStore.Clear(lb);
}
}
diff --git a/Source/LibationCli/Options/_ProcessableOptionsBase.cs b/Source/LibationCli/Options/_ProcessableOptionsBase.cs
index 16c95904..6d65c219 100644
--- a/Source/LibationCli/Options/_ProcessableOptionsBase.cs
+++ b/Source/LibationCli/Options/_ProcessableOptionsBase.cs
@@ -84,9 +84,16 @@ public abstract class ProcessableOptionsBase : OptionsBase
/// How much this run may download before it stops, or null for a verb without a per-run limit.
protected virtual RunDownloadLimit? RunLimit => null;
+ ///
+ /// Whether this run should leave alone the titles Audible recently refused. False for a run that names
+ /// its titles or passes --force: an explicit request is always attempted.
+ ///
+ protected virtual bool HonorsDeferredRetries => false;
+
protected async Task RunAsync(Processable Processable, Action? config = null, Action? notFound = null)
{
var skippedForDailyLimit = 0;
+ var deferredThisRun = new List();
var runLimitReached = false;
// Needs no guard against pdf or convert runs, unlike the daily limit below: the tracker counts only
@@ -114,14 +121,42 @@ public abstract class ProcessableOptionsBase : OptionsBase
}
else
{
+ // Read once, before the first book: a run that spends hours downloading must not start skipping
+ // titles because of failures it recorded itself a moment ago.
+ var deferrals = HonorsDeferredRetries && Processable is DownloadDecryptBook
+ ? DownloadDeferrals.Load(DateTimeOffset.Now)
+ : DownloadDeferrals.None;
+
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
foreach (var lb in Processable.GetValidLibraryBooks(libraryBooks))
{
+ if (deferrals.Find(lb) is DeferredDownload deferred)
+ {
+ deferredThisRun.Add(deferred);
+ Serilog.Log.Logger.Information(
+ "Not attempting {libraryBook} yet. {@DebugInfo}",
+ lb.LogFriendly(),
+ new { deferred.Kind, deferred.ConsecutiveFailures, deferred.Reason, RetryAfter = deferred.RetryAfter.ToLocalTime() });
+ continue;
+ }
+
if (!await ProcessOrStopAsync(lb, false))
break;
}
}
+ if (deferredThisRun.Count > 0)
+ {
+ var now = DateTimeOffset.Now;
+ foreach (var line in DeferredDownloadUserMessage.BuildCliSkippedLines(deferredThisRun, now))
+ Console.WriteLine(line);
+
+ Serilog.Log.Logger.Information(
+ "Skipped {deferredCount} titles that recently failed to download. Skipped: {skipped}",
+ deferredThisRun.Count,
+ DeferredDownloadUserMessage.BuildLogBreakdown(deferredThisRun));
+ }
+
if (skippedForDailyLimit > 0)
{
var summary = DailyDownloadLimitUserMessage.BuildCliSkippedSummary(skippedForDailyLimit);
@@ -219,18 +254,40 @@ public abstract class ProcessableOptionsBase : OptionsBase
{
Console.Error.WriteLine(WidevineRecommendation.BuildLogSummary(libraryBook.Book.TitleWithSubtitle));
Serilog.Log.Logger.Error(ex, "ADRM license unavailable (Sable acr:null) {@DebugInfo}", new { Book = libraryBook.LogFriendly() });
+ ReportNextAttempt(libraryBook);
}
catch (ContentLicenseDeniedException clEx)
{
foreach (var line in ContentLicenseDeniedCliSummary.Lines(clEx))
Console.Error.WriteLine(line);
Serilog.Log.Logger.Error(clEx, "Content license denied {@DebugInfo}", new { Book = libraryBook.LogFriendly() });
+ ReportNextAttempt(libraryBook);
}
catch (Exception ex)
{
- var msg = "Error processing book. Skipping. This book will be tried again on next attempt. For options of skipping or marking as error, retry with main Libation app.";
+ var msg = "Error processing book. Skipping. For options of skipping or marking as error, retry with main Libation app.";
Console.Error.WriteLine(msg + ". See log for more details.");
Serilog.Log.Logger.Error(ex, $"{msg} {{@DebugInfo}}", new { Book = libraryBook.LogFriendly() });
+
+ if (!ReportNextAttempt(libraryBook))
+ Console.Error.WriteLine("This book will be tried again on next attempt.");
}
}
+
+ ///
+ /// Says when a title Libation has decided to wait on will be attempted again, so a scheduled run explains
+ /// its own silence on the next several runs rather than appearing to have forgotten the title.
+ ///
+ /// True when the title is being waited on.
+ private static bool ReportNextAttempt(LibraryBook libraryBook)
+ {
+ var now = DateTimeOffset.Now;
+ if (DownloadAttemptFailureStore.Find(libraryBook, now) is not DeferredDownload deferred)
+ return false;
+
+ Console.Error.WriteLine(
+ $"Not attempting this title again {DeferredDownloadUserMessage.DescribeWhen(deferred.RetryAfter, now)}. "
+ + "To try it sooner, name it: libationcli liberate " + libraryBook.Book.AudibleProductId);
+ return true;
+ }
}
diff --git a/Source/LibationUiBase/ProcessQueue/BackupRequest.cs b/Source/LibationUiBase/ProcessQueue/BackupRequest.cs
index 4cb663df..e164e998 100644
--- a/Source/LibationUiBase/ProcessQueue/BackupRequest.cs
+++ b/Source/LibationUiBase/ProcessQueue/BackupRequest.cs
@@ -1,5 +1,7 @@
+using ApplicationServices;
using DataLayer;
using Dinah.Core;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -20,8 +22,9 @@ internal sealed class BackupRequest
public static readonly SkipReason AlreadyDownloaded = new("Already downloaded");
public static readonly SkipReason PreviousError = new("Previously failed to download", "set the download status to 'Not Downloaded' to try again");
public static readonly SkipReason AbsentFromLastScan = new("Absent from your last library scan", "run Scan, or `libationcli scan`, then try again");
+ public static readonly SkipReason WaitingToRetry = new("Waiting before trying again after a recent failure", "download the title on its own to try it now");
- public static readonly SkipReason[] All = [AlreadyDownloaded, PreviousError, AbsentFromLastScan];
+ public static readonly SkipReason[] All = [AlreadyDownloaded, PreviousError, AbsentFromLastScan, WaitingToRetry];
}
/// The titles the caller asked to back up, including the ones that cannot be queued.
@@ -30,32 +33,49 @@ internal sealed class BackupRequest
public int SkippedCount => RequestedCount - Queueable.Length;
public int Skipped(SkipReason reason) => skipped.GetValueOrDefault(reason);
+ /// The titles left out because Libation is waiting before attempting them again.
+ public IReadOnlyList Deferred { get; }
+
private readonly Dictionary skipped;
- private BackupRequest(int requestedCount, LibraryBook[] queueable, Dictionary skipped)
+ private BackupRequest(int requestedCount, LibraryBook[] queueable, Dictionary skipped, IReadOnlyList deferred)
{
RequestedCount = requestedCount;
Queueable = queueable;
this.skipped = skipped;
+ Deferred = deferred;
}
- public static BackupRequest Create(IEnumerable libraryBooks)
+ ///
+ /// The titles to leave alone for now. Pass for a request the user
+ /// made about specific titles, which must always be attempted.
+ ///
+ public static BackupRequest Create(IEnumerable libraryBooks, DownloadDeferrals? deferrals = null)
{
+ deferrals ??= DownloadDeferrals.None;
+
var requestedCount = 0;
var queueable = new List();
var skipped = new Dictionary();
+ var deferred = new List();
foreach (var libraryBook in libraryBooks)
{
requestedCount++;
- if (GetSkipReason(libraryBook) is not SkipReason reason)
+ // A title needing only its PDF is never waited on: the audiobook download is what Audible refused.
+ if (libraryBook.NeedsBookDownload && deferrals.Find(libraryBook) is DeferredDownload waiting)
+ {
+ deferred.Add(waiting);
+ skipped[SkipReason.WaitingToRetry] = skipped.GetValueOrDefault(SkipReason.WaitingToRetry) + 1;
+ }
+ else if (GetSkipReason(libraryBook) is not SkipReason reason)
queueable.Add(libraryBook);
else
skipped[reason] = skipped.GetValueOrDefault(reason) + 1;
}
- return new BackupRequest(requestedCount, [.. queueable], skipped);
+ return new BackupRequest(requestedCount, [.. queueable], skipped, deferred);
}
/// Null when the title can be queued. Absent outranks status: Downloadable is false either way.
@@ -89,6 +109,30 @@ internal sealed class BackupRequest
foreach (var (reason, count) in Breakdown())
sb.AppendLine($"{reason.Label}: {count}{(reason.Advice is "" ? "" : $" ({reason.Advice})")}");
+ if (Deferred.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(BuildDeferredDetail(DateTimeOffset.Now));
+ }
+
+ return sb.ToString().TrimEnd();
+ }
+
+ ///
+ /// What Audible said about the waited-on titles and when they come back. Without this the dialog reports a
+ /// wait with no way to find out its cause short of reading the log.
+ ///
+ public string BuildDeferredDetail(DateTimeOffset now)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("Why Libation is waiting:");
+
+ foreach (var group in Deferred.GroupBy(d => d.Kind).OrderBy(g => g.Key))
+ {
+ sb.AppendLine($"- {group.First().KindLabel} ({"title".PluralizeWithCount(group.Count())}). "
+ + $"Next attempt {DeferredDownloadUserMessage.DescribeWhen(group.Min(d => d.RetryAfter), now)}.");
+ }
+
return sb.ToString().TrimEnd();
}
diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
index 52a65e1d..40ff26fc 100644
--- a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
+++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
@@ -241,7 +241,9 @@ public class ProcessQueueViewModel : ReactiveObject
}
else
{
- var request = BackupRequest.Create(libraryBooks);
+ // Titles Audible recently refused are left out of a multi-book request but never out of a
+ // single-title one: picking one title is the user overriding the wait.
+ var request = BackupRequest.Create(libraryBooks, DownloadDeferrals.Load(DateTimeOffset.Now));
if (request.Queueable.Length == 0)
{
@@ -269,6 +271,9 @@ public class ProcessQueueViewModel : ReactiveObject
request.RequestedCount,
request.BuildSkippedLogSummary());
+ if (request.Deferred.Count > 0)
+ AddQueueLogEntry(request.BuildDeferredDetail(DateTimeOffset.Now));
+
// May no-op when free space is unknown (common on UNC); see DiskSpaceBackupPreflight.
if (!await DiskSpaceBackupPreflight.ConfirmBulkBackupAsync(request.Queueable.Length, config, backupQueueAlreadyRunning: Running))
return false;