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;