diff --git a/Source/ApplicationServices/DownloadHistoryStore.cs b/Source/ApplicationServices/DownloadHistoryStore.cs
new file mode 100644
index 00000000..f1d86b09
--- /dev/null
+++ b/Source/ApplicationServices/DownloadHistoryStore.cs
@@ -0,0 +1,85 @@
+using DataLayer;
+using LibationFileManager;
+using Microsoft.EntityFrameworkCore;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ApplicationServices;
+
+///
+/// Reads and writes the record of successful audiobook downloads backing the daily download limit.
+///
+/// Caches nothing: every read is a fresh query, so a queue paused for hours sees entries age out of the
+/// rolling window, and downloads performed by a concurrently running CLI or second container are counted too.
+///
+///
+public static class DownloadHistoryStore
+{
+ /// Kept a little beyond the 24 hour window so the table stays small without losing anything in use.
+ private static readonly TimeSpan RetentionPeriod = TimeSpan.FromHours(48);
+
+ ///
+ /// Records a finished download. Never throws: bookkeeping must not fail a download that already succeeded.
+ ///
+ public static void Record(string? audibleProductId, bool isAudiblePlus, long bytes, DateTimeOffset? completedAt = null)
+ {
+ try
+ {
+ var when = completedAt ?? DateTimeOffset.Now;
+
+ using var context = DbContexts.GetContext();
+ context.DownloadHistory.Add(new DownloadHistory(when, audibleProductId, isAudiblePlus, bytes));
+
+ var cutoff = (when - RetentionPeriod).UtcTicks;
+ var expired = context.DownloadHistory.Where(dh => dh.CompletedAtUtcTicks < cutoff);
+ context.DownloadHistory.RemoveRange(expired);
+
+ context.SaveChanges();
+
+ Serilog.Log.Logger.Debug(
+ "Recorded download for the daily download limit. {@DebugInfo}",
+ new { audibleProductId, isAudiblePlus, bytes, completedAt = when });
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(
+ ex,
+ "Failed to record a completed download for the daily download limit. The download itself succeeded. {@DebugInfo}",
+ new { audibleProductId, isAudiblePlus, bytes });
+ }
+ }
+
+ /// Downloads completed at or after . Empty when the query fails.
+ public static IReadOnlyList GetSince(DateTimeOffset since)
+ {
+ try
+ {
+ var ticks = since.UtcTicks;
+
+ using var context = DbContexts.GetContext();
+ return context.DownloadHistory
+ .AsNoTracking()
+ .Where(dh => dh.CompletedAtUtcTicks >= ticks)
+ .OrderBy(dh => dh.CompletedAtUtcTicks)
+ .Select(dh => new { dh.CompletedAtUtcTicks, dh.AudibleProductId, dh.IsAudiblePlus, dh.Bytes })
+ .ToList()
+ .Select(dh => new DownloadHistoryEntry(
+ new DateTimeOffset(dh.CompletedAtUtcTicks, TimeSpan.Zero),
+ dh.AudibleProductId,
+ dh.IsAudiblePlus,
+ dh.Bytes))
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ // Failing open is the safer default: a broken query must not block downloading.
+ Serilog.Log.Logger.Error(ex, "Failed to read download history. Treating the last 24 hours as empty.");
+ return [];
+ }
+ }
+
+ /// The rolling window the daily download limit uses.
+ public static IReadOnlyList GetCurrentWindow(DateTimeOffset now)
+ => GetSince(now - DailyDownloadLimit.Window);
+}
diff --git a/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.Designer.cs b/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.Designer.cs
new file mode 100644
index 00000000..445746a2
--- /dev/null
+++ b/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.Designer.cs
@@ -0,0 +1,539 @@
+//
+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("20260814170831_AddDownloadHistory")]
+ partial class AddDownloadHistory
+ {
+ ///
+ 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.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/20260814170831_AddDownloadHistory.cs b/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.cs
new file mode 100644
index 00000000..14e14f88
--- /dev/null
+++ b/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.cs
@@ -0,0 +1,43 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DataLayer.Postgres.Migrations
+{
+ ///
+ public partial class AddDownloadHistory : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DownloadHistory",
+ columns: table => new
+ {
+ DownloadHistoryId = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ CompletedAtUtcTicks = table.Column(type: "bigint", nullable: false),
+ AudibleProductId = table.Column(type: "text", nullable: true),
+ IsAudiblePlus = table.Column(type: "boolean", nullable: false),
+ Bytes = table.Column(type: "bigint", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DownloadHistory", x => x.DownloadHistoryId);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DownloadHistory_CompletedAtUtcTicks",
+ table: "DownloadHistory",
+ column: "CompletedAtUtcTicks");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DownloadHistory");
+ }
+ }
+}
diff --git a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs
index 553524af..1e4cffb9 100644
--- a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs
+++ b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs
@@ -17,7 +17,7 @@ namespace DataLayer.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -201,6 +201,33 @@ namespace DataLayer.Postgres.Migrations
});
});
+ 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")
diff --git a/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.Designer.cs b/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.Designer.cs
new file mode 100644
index 00000000..bd84c535
--- /dev/null
+++ b/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.Designer.cs
@@ -0,0 +1,520 @@
+//
+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("20260814170839_AddDownloadHistory")]
+ partial class AddDownloadHistory
+ {
+ ///
+ 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.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/20260814170839_AddDownloadHistory.cs b/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.cs
new file mode 100644
index 00000000..18de3fc7
--- /dev/null
+++ b/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.cs
@@ -0,0 +1,42 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace DataLayer.Migrations
+{
+ ///
+ public partial class AddDownloadHistory : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DownloadHistory",
+ columns: table => new
+ {
+ DownloadHistoryId = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ CompletedAtUtcTicks = table.Column(type: "INTEGER", nullable: false),
+ AudibleProductId = table.Column(type: "TEXT", nullable: true),
+ IsAudiblePlus = table.Column(type: "INTEGER", nullable: false),
+ Bytes = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DownloadHistory", x => x.DownloadHistoryId);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DownloadHistory_CompletedAtUtcTicks",
+ table: "DownloadHistory",
+ column: "CompletedAtUtcTicks");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DownloadHistory");
+ }
+ }
+}
diff --git a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs
index 8404fda1..4a5c84d4 100644
--- a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs
+++ b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
- modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
@@ -188,6 +188,31 @@ namespace DataLayer.Migrations
});
});
+ 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")
diff --git a/Source/DataLayer/Configurations/DownloadHistoryConfig.cs b/Source/DataLayer/Configurations/DownloadHistoryConfig.cs
new file mode 100644
index 00000000..d09792c1
--- /dev/null
+++ b/Source/DataLayer/Configurations/DownloadHistoryConfig.cs
@@ -0,0 +1,15 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations;
+
+internal class DownloadHistoryConfig : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(dh => dh.DownloadHistoryId);
+
+ // Every read is a range query over the rolling window, and every write prunes by age.
+ entity.HasIndex(dh => dh.CompletedAtUtcTicks);
+ }
+}
diff --git a/Source/DataLayer/EfClasses/DownloadHistory.cs b/Source/DataLayer/EfClasses/DownloadHistory.cs
new file mode 100644
index 00000000..f64f2e23
--- /dev/null
+++ b/Source/DataLayer/EfClasses/DownloadHistory.cs
@@ -0,0 +1,45 @@
+using System;
+
+namespace DataLayer;
+
+///
+/// One successful audiobook download, recorded so the opt-in daily download limit survives restarts.
+/// Recorded for every download regardless of whether a limit is configured: a user who turns the limit
+/// on after a heavy session gets a limit that reflects what actually happened.
+///
+/// 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.
+///
+///
+public class DownloadHistory
+{
+ internal int DownloadHistoryId { get; private set; }
+
+ ///
+ /// UTC ticks rather than a DateTime so range queries mean the same thing on SQLite and PostgreSQL, and so a
+ /// window that spans a DST change stays exactly 24 hours. Local time is for display only.
+ ///
+ public long CompletedAtUtcTicks { get; private set; }
+
+ public string? AudibleProductId { get; private set; }
+
+ public bool IsAudiblePlus { get; private set; }
+
+ /// Size on disk of the files written to the Books directory for this title.
+ public long Bytes { get; private set; }
+
+ public DateTimeOffset CompletedAt => new(CompletedAtUtcTicks, TimeSpan.Zero);
+
+ private DownloadHistory() { }
+
+ public DownloadHistory(DateTimeOffset completedAt, string? audibleProductId, bool isAudiblePlus, long bytes)
+ {
+ CompletedAtUtcTicks = completedAt.UtcTicks;
+ AudibleProductId = audibleProductId;
+ IsAudiblePlus = isAudiblePlus;
+ Bytes = bytes;
+ }
+
+ public override string ToString()
+ => $"{AudibleProductId} {CompletedAt.ToLocalTime()} {(IsAudiblePlus ? "Plus" : "owned")} {Bytes} bytes";
+}
diff --git a/Source/DataLayer/LibationContext.cs b/Source/DataLayer/LibationContext.cs
index 26d99e7c..795c26d6 100644
--- a/Source/DataLayer/LibationContext.cs
+++ b/Source/DataLayer/LibationContext.cs
@@ -26,6 +26,7 @@ public class LibationContext : DbContext, INotifyDisposed
public DbSet Series { get; private set; }
public DbSet Categories { get; private set; }
public DbSet CategoryLadders { get; private set; }
+ public DbSet DownloadHistory { get; private set; }
public event EventHandler? ObjectDisposed;
public override void Dispose()
@@ -56,6 +57,7 @@ public class LibationContext : DbContext, INotifyDisposed
modelBuilder.ApplyConfiguration(new CategoryConfig());
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
+ modelBuilder.ApplyConfiguration(new DownloadHistoryConfig());
// 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 4e68aa29..56cd007f 100644
--- a/Source/FileLiberator/DownloadDecryptBook.cs
+++ b/Source/FileLiberator/DownloadDecryptBook.cs
@@ -113,6 +113,7 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable
+ /// Records the finished download for the daily download limit. Always recorded, even when no limit is
+ /// configured, so that turning the limit on later reflects downloads already performed.
+ ///
+ private static void RecordDownloadForDailyLimit(LibraryBook libraryBook, List movedFiles)
+ {
+ try
+ {
+ // Files have already been moved, so these paths are their final locations in the Books directory.
+ var bytes = movedFiles.Sum(f => File.Exists(f.FilePath) ? new FileInfo(f.FilePath).Length : 0);
+ DownloadHistoryStore.Record(libraryBook.Book.AudibleProductId, libraryBook.IsAudiblePlus, bytes);
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(ex, "Failed to measure a completed download for the daily download limit. The download itself succeeded. {@Book}", libraryBook.LogFriendly());
+ }
+ }
+
/// Read the audio format from the audio file's metadata.
public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile)
{
diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs
index 56ca826c..fba1586f 100644
--- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs
+++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs
@@ -152,9 +152,8 @@ public partial class ProcessQueueControl : UserControl
public async void CancelAllBtn_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
- Queue?.ClearQueue();
- if (Queue?.Current is not null)
- await Queue.Current.CancelAsync();
+ if (_viewModel is ProcessQueueViewModel vm)
+ await vm.CancelAllAsync();
}
public void ClearFinishedBtn_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
@@ -182,9 +181,8 @@ public partial class ProcessQueueControl : UserControl
private async void cancelAllBtn_Click(object? sender, EventArgs e)
{
- Queue?.ClearQueue();
- if (Queue?.Current is not null)
- await Queue.Current.CancelAsync();
+ if (_viewModel is ProcessQueueViewModel vm)
+ await vm.CancelAllAsync();
}
private void btnClearFinished_Click(object? sender, EventArgs e)
diff --git a/Source/LibationCli/ContentLicenseDeniedCliSummary.cs b/Source/LibationCli/ContentLicenseDeniedCliSummary.cs
index 8b56e636..9cdaefe1 100644
--- a/Source/LibationCli/ContentLicenseDeniedCliSummary.cs
+++ b/Source/LibationCli/ContentLicenseDeniedCliSummary.cs
@@ -1,4 +1,6 @@
+using ApplicationServices;
using AudibleApi;
+using LibationFileManager;
using System;
using System.Collections.Generic;
@@ -22,5 +24,39 @@ internal static class ContentLicenseDeniedCliSummary
yield return $"Membership: {mem}";
if (ex.AYCL?.Message is { } aycl && !string.IsNullOrWhiteSpace(aycl))
yield return $"AYCL (aka: Plus catalog): {aycl}";
+
+ foreach (var line in SuggestDailyLimitLines())
+ yield return line;
+ }
+
+ ///
+ /// Audible never says "you are being throttled", so this suggestion relies on Libation's own record of recent
+ /// downloads. Silent unless that record makes throttling a plausible explanation and no limit is set yet.
+ ///
+ private static IEnumerable SuggestDailyLimitLines()
+ {
+ string? suggestion;
+ try
+ {
+ var now = DateTimeOffset.Now;
+ suggestion = DailyDownloadLimitUserMessage.BuildSuggestionParagraph(
+ Configuration.Instance,
+ DownloadHistoryStore.GetCurrentWindow(now),
+ now);
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(ex, "Failed to build the daily download limit suggestion");
+ yield break;
+ }
+
+ if (suggestion is null)
+ yield break;
+
+ Serilog.Log.Logger.Information("Suggesting a daily download limit after a license denial. {Suggestion}", suggestion);
+
+ yield return string.Empty;
+ foreach (var line in suggestion.Split('\n'))
+ yield return line.TrimEnd('\r');
}
}
diff --git a/Source/LibationCli/Options/LiberateOptions.cs b/Source/LibationCli/Options/LiberateOptions.cs
index be009f1f..048941ee 100644
--- a/Source/LibationCli/Options/LiberateOptions.cs
+++ b/Source/LibationCli/Options/LiberateOptions.cs
@@ -68,7 +68,15 @@ public class LiberateOptions : ProcessableOptionsBase
}
PrepareBookForLiberate(libraryBook, isTargetedRun: true);
- await ProcessOneAsync(GetProcessable(licenseInfo), libraryBook, true);
+
+ var processable = GetProcessable(licenseInfo);
+ if (IsSkippedByDailyLimit(processable, libraryBook))
+ {
+ Console.WriteLine(DailyDownloadLimitUserMessage.BuildCliSkippedSummary(1));
+ return;
+ }
+
+ await ProcessOneAsync(processable, libraryBook, true);
}
private static DownloadOptions.LicenseInfo? ReadLicenseFromFile(string licFile)
diff --git a/Source/LibationCli/Options/_ProcessableOptionsBase.cs b/Source/LibationCli/Options/_ProcessableOptionsBase.cs
index 9605c76a..caec078f 100644
--- a/Source/LibationCli/Options/_ProcessableOptionsBase.cs
+++ b/Source/LibationCli/Options/_ProcessableOptionsBase.cs
@@ -83,6 +83,8 @@ public abstract class ProcessableOptionsBase : OptionsBase
protected async Task RunAsync(Processable Processable, Action? config = null, Action? notFound = null)
{
+ var skippedForDailyLimit = 0;
+
var productIds = GetProductIds().ToArray();
if (productIds.Length > 0)
{
@@ -91,7 +93,10 @@ public abstract class ProcessableOptionsBase : OptionsBase
if (DbContexts.GetLibraryBook_Flat_NoTracking(asin, caseSensative: false) is LibraryBook lb)
{
config?.Invoke(lb);
- await ProcessOneAsync(Processable, lb, true);
+ if (IsSkippedByDailyLimit(Processable, lb))
+ skippedForDailyLimit++;
+ else
+ await ProcessOneAsync(Processable, lb, true);
}
else
{
@@ -108,15 +113,64 @@ public abstract class ProcessableOptionsBase : OptionsBase
foreach (var lb in Processable.GetValidLibraryBooks(libraryBooks))
{
config?.Invoke(lb);
- await ProcessOneAsync(Processable, lb, false);
+ if (IsSkippedByDailyLimit(Processable, lb))
+ skippedForDailyLimit++;
+ else
+ await ProcessOneAsync(Processable, lb, false);
}
}
+ if (skippedForDailyLimit > 0)
+ {
+ var summary = DailyDownloadLimitUserMessage.BuildCliSkippedSummary(skippedForDailyLimit);
+ Console.WriteLine(summary);
+ Serilog.Log.Logger.Information(summary);
+ }
+
var done = "Done. All books have been processed";
Console.WriteLine(done);
Serilog.Log.Logger.Information(done);
}
+ protected bool announcedDailyLimit;
+
+ ///
+ /// True when the user's daily download limit covers this title and has been reached. Unlike the GUI queue the
+ /// CLI never waits: a command-line run must not sit idle for hours, and in Docker the entrypoint has to return
+ /// so its own sleep loop keeps working. Skipped titles stay un-liberated and are retried on the next run.
+ ///
+ protected bool IsSkippedByDailyLimit(Processable processable, LibraryBook libraryBook)
+ {
+ // Only audiobook downloads are limited, and only titles the configured scope covers, so the common
+ // case of no limit (or a Plus-only limit against an owned title) costs nothing.
+ if (processable is not DownloadDecryptBook
+ || !DailyDownloadLimit.AppliesTo(libraryBook.IsAudiblePlus, Configuration.Instance))
+ return false;
+
+ var now = DateTimeOffset.Now;
+ var allowance = DailyDownloadLimit.Evaluate(Configuration.Instance, DownloadHistoryStore.GetCurrentWindow(now), now);
+
+ if (!allowance.Blocks(libraryBook.IsAudiblePlus))
+ return false;
+
+ if (!announcedDailyLimit)
+ {
+ announcedDailyLimit = true;
+ foreach (var line in DailyDownloadLimitUserMessage.BuildCliSkippedLines(allowance))
+ {
+ Console.Error.WriteLine(line);
+ Serilog.Log.Logger.Information(line);
+ }
+ }
+
+ Serilog.Log.Logger.Information(
+ "Daily download limit reached; skipping {libraryBook}. {@DebugInfo}",
+ libraryBook.LogFriendly(),
+ new { allowance.Scope, allowance.Unit, allowance.Quantity, allowance.UsedBooks, allowance.UsedBytes, allowance.NextCapacityAt });
+
+ return true;
+ }
+
protected async Task ProcessOneAsync(Processable Processable, LibraryBook libraryBook, bool validate)
{
try
diff --git a/Source/LibationFileManager/Configuration.HelpText.cs b/Source/LibationFileManager/Configuration.HelpText.cs
index 28808002..863d4bd6 100644
--- a/Source/LibationFileManager/Configuration.HelpText.cs
+++ b/Source/LibationFileManager/Configuration.HelpText.cs
@@ -141,6 +141,29 @@ public partial class Configuration
This may take a while, depending on the number of audio files in the folder and the speed of your storage device.
""" },
+ {nameof(DailyDownloadLimit), """
+ Stop downloading once you have downloaded this much
+ within the last 24 hours. This is a rolling window,
+ not a calendar day: capacity frees up 24 hours after
+ each download, not at midnight.
+
+ Only downloads that Libation completed successfully
+ are counted. Books you download from the Audible app
+ or website are not counted, because Libation cannot
+ see them.
+
+ "Plus titles only" limits just the Audible Plus
+ catalog titles (the ones with the orange plus badge),
+ which is where Audible is known to throttle heavy
+ use. Titles you own keep downloading.
+ """ },
+ {nameof(DailyDownloadLimitUnit), """
+ MB and GB are approximate. Libation does not know
+ precisely how large an audiobook is before it has
+ been downloaded, so it assumes about 400 MB per book
+ when deciding whether another download would exceed
+ your limit.
+ """ },
{nameof(ImportPlusTitles), """
When enabled, books from the Audible Plus catalog (titles you stream or borrow under your membership, not purchased) are imported into Libation.
diff --git a/Source/LibationFileManager/Configuration.Logging.cs b/Source/LibationFileManager/Configuration.Logging.cs
index a02ef15e..67bf2242 100644
--- a/Source/LibationFileManager/Configuration.Logging.cs
+++ b/Source/LibationFileManager/Configuration.Logging.cs
@@ -240,6 +240,8 @@ public partial class Configuration
_ = CreationTime;
_ = LastWriteTime;
_ = BadBook;
+ _ = DailyDownloadLimit;
+ _ = DailyDownloadLimitUnit;
}
catch (InvalidConfigurationValueException ex)
{
diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs
index e9ba729d..f5ce0adb 100644
--- a/Source/LibationFileManager/Configuration.PersistentSettings.cs
+++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs
@@ -280,6 +280,28 @@ public partial class Configuration
Ignore = 3
}
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum DailyLimitScope
+ {
+ [Description("No limit")]
+ NoLimit = 0,
+ [Description("Plus titles only")]
+ PlusOnly = 1,
+ [Description("All books")]
+ AllBooks = 2
+ }
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum DailyLimitUnit
+ {
+ [Description("books")]
+ Books = 0,
+ [Description("MB")]
+ MB = 1,
+ [Description("GB")]
+ GB = 2
+ }
+
[JsonConverter(typeof(StringEnumConverter))]
public enum Theme
{
@@ -408,6 +430,20 @@ public partial class Configuration
}
}
+ #region daily download limit
+
+ [Description("Daily download limit (rolling 24 hours):")]
+ public DailyLimitScope DailyDownloadLimit { get => GetNonString(defaultValue: DailyLimitScope.NoLimit); set => SetNonString(value); }
+
+ /// Clamped so a hand-edited Settings.json holding 0 or a negative number cannot block all downloads.
+ [Description("Limit:")]
+ public int DailyDownloadLimitQuantity { get => Math.Max(1, GetNonString(defaultValue: 50)); set => SetNonString(Math.Max(1, value)); }
+
+ [Description("Unit:")]
+ public DailyLimitUnit DailyDownloadLimitUnit { get => GetNonString(defaultValue: DailyLimitUnit.Books); set => SetNonString(value); }
+
+ #endregion
+
#region templates: custom file naming
[Description("Edit how filename characters are replaced")]
diff --git a/Source/LibationFileManager/DailyDownloadLimit.cs b/Source/LibationFileManager/DailyDownloadLimit.cs
new file mode 100644
index 00000000..3cdae8ce
--- /dev/null
+++ b/Source/LibationFileManager/DailyDownloadLimit.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LibationFileManager;
+
+/// One successful audiobook download recorded by Libation.
+/// When the download finished. Compared against , so a
+/// queue that runs for days across a DST change still measures exactly 24 hours.
+/// Size on disk of the files written to the Books directory for this title.
+public record DownloadHistoryEntry(DateTimeOffset CompletedAt, string? AudibleProductId, bool IsAudiblePlus, long Bytes);
+
+///
+/// Decides whether another audiobook may be downloaded, given the user's opt-in daily limit and the
+/// downloads Libation recorded in the last 24 hours. Pure: no clock, no I/O, no caching. Callers pass
+/// a fresh now and a fresh history read on every check so a paused queue can resume days later.
+///
+public static class DailyDownloadLimit
+{
+ /// Rolling window. Not a calendar day: capacity frees up 24 hours after each download.
+ public static readonly TimeSpan Window = TimeSpan.FromHours(24);
+
+ ///
+ /// Below this many recent downloads, a license denial is unlikely to be Audible throttling, so
+ /// suggesting a daily limit would point the user at the wrong problem.
+ ///
+ public const int SuggestionMinimumRecentDownloads = 10;
+
+ private const long BytesPerMB = 1024L * 1024;
+ private const long BytesPerGB = 1024L * 1024 * 1024;
+
+ ///
+ /// The limit state for the configured scope. "Unlimited" is expressed by and a null
+ /// rather than a sentinel count, so no caller can mistake it for a real number.
+ ///
+ /// Whether one more counted download would stay within the limit.
+ /// Downloads inside the window that count against the configured scope.
+ /// Display only, null when unlimited. Approximate in MB/GB mode.
+ /// When enough of the window ages out to allow another download. Null when not blocked.
+ public readonly record struct Allowance(
+ bool IsLimited,
+ bool AllowsAnother,
+ Configuration.DailyLimitScope Scope,
+ Configuration.DailyLimitUnit Unit,
+ int Quantity,
+ int UsedBooks,
+ long UsedBytes,
+ int? RemainingBooks,
+ DateTimeOffset? NextCapacityAt)
+ {
+ /// True when this title specifically cannot be downloaded right now.
+ public bool Blocks(bool isPlus) => IsLimited && !AllowsAnother && ScopeCounts(Scope, isPlus);
+
+ /// Limit expressed in bytes, or null when the limit is a book count.
+ public long? LimitBytes => Unit is Configuration.DailyLimitUnit.Books ? null : ToBytes(Quantity, Unit);
+ }
+
+ /// Recent activity regardless of the limit setting, for the throttling suggestion.
+ public readonly record struct RecentActivity(int TotalDownloads, int PlusDownloads, long TotalBytes);
+
+ /// Whether the configured scope subjects this title to the limit at all.
+ public static bool AppliesTo(bool isPlus, Configuration config) => ScopeCounts(config.DailyDownloadLimit, isPlus);
+
+ private static bool ScopeCounts(Configuration.DailyLimitScope scope, bool isPlus)
+ => scope switch
+ {
+ Configuration.DailyLimitScope.AllBooks => true,
+ Configuration.DailyLimitScope.PlusOnly => isPlus,
+ _ => false
+ };
+
+ private static long ToBytes(int quantity, Configuration.DailyLimitUnit unit)
+ => unit switch
+ {
+ Configuration.DailyLimitUnit.MB => quantity * BytesPerMB,
+ Configuration.DailyLimitUnit.GB => quantity * BytesPerGB,
+ _ => 0
+ };
+
+ public static Allowance Evaluate(Configuration config, IReadOnlyList history, DateTimeOffset now)
+ {
+ var scope = config.DailyDownloadLimit;
+ var unit = config.DailyDownloadLimitUnit;
+ var quantity = Math.Max(1, config.DailyDownloadLimitQuantity);
+
+ if (scope is Configuration.DailyLimitScope.NoLimit)
+ return new Allowance(false, true, scope, unit, quantity, 0, 0, null, null);
+
+ var counted = history
+ .Where(e => e.CompletedAt > now - Window && ScopeCounts(scope, e.IsAudiblePlus))
+ .OrderBy(e => e.CompletedAt)
+ .ToList();
+
+ var usedBooks = counted.Count;
+ var usedBytes = counted.Sum(e => e.Bytes);
+
+ // A byte limit smaller than one estimated book would otherwise block downloading forever, and the
+ // user would see "limit reached" having downloaded nothing.
+ var allowsAnother = usedBooks == 0 || WouldFit(usedBytes, usedBooks);
+
+ return new Allowance(
+ IsLimited: true,
+ AllowsAnother: allowsAnother,
+ Scope: scope,
+ Unit: unit,
+ Quantity: quantity,
+ UsedBooks: usedBooks,
+ UsedBytes: usedBytes,
+ RemainingBooks: RemainingBooks(usedBytes, usedBooks, allowsAnother),
+ NextCapacityAt: allowsAnother ? null : NextCapacityAt(counted, usedBytes));
+
+ bool WouldFit(long bytes, int books)
+ => unit is Configuration.DailyLimitUnit.Books
+ ? books < quantity
+ : bytes + DiskSpaceHelper.EstimatedBytesPerAudiobookBackup <= ToBytes(quantity, unit);
+
+ int RemainingBooks(long bytes, int books, bool allows)
+ {
+ if (unit is Configuration.DailyLimitUnit.Books)
+ return Math.Max(0, quantity - books);
+
+ var free = ToBytes(quantity, unit) - bytes;
+ var whole = free <= 0 ? 0 : (int)Math.Min(int.MaxValue, free / DiskSpaceHelper.EstimatedBytesPerAudiobookBackup);
+ // Keep the count honest about the always-allow-one rule.
+ return whole == 0 && allows ? 1 : whole;
+ }
+
+ // Walk oldest first: capacity returns when enough entries have aged out for one more book to fit.
+ DateTimeOffset? NextCapacityAt(List countedOldestFirst, long bytes)
+ {
+ long freed = 0;
+ for (var i = 0; i < countedOldestFirst.Count; i++)
+ {
+ freed += countedOldestFirst[i].Bytes;
+ if (WouldFit(bytes - freed, countedOldestFirst.Count - i - 1))
+ return countedOldestFirst[i].CompletedAt + Window;
+ }
+ return countedOldestFirst.Count == 0 ? null : countedOldestFirst[^1].CompletedAt + Window;
+ }
+ }
+
+ public static RecentActivity SummarizeRecent(IReadOnlyList history, DateTimeOffset now)
+ {
+ var recent = history.Where(e => e.CompletedAt > now - Window).ToList();
+ return new RecentActivity(recent.Count, recent.Count(e => e.IsAudiblePlus), recent.Sum(e => e.Bytes));
+ }
+}
diff --git a/Source/LibationFileManager/DailyDownloadLimitUserMessage.cs b/Source/LibationFileManager/DailyDownloadLimitUserMessage.cs
new file mode 100644
index 00000000..1504d087
--- /dev/null
+++ b/Source/LibationFileManager/DailyDownloadLimitUserMessage.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+
+namespace LibationFileManager;
+
+///
+/// User-facing copy for the opt-in daily download limit, plus the suggestion to turn it on when Audible
+/// looks like it is throttling. Lives here rather than in LibationUiBase because LibationCli needs the same
+/// wording and does not reference LibationUiBase.
+///
+public static class DailyDownloadLimitUserMessage
+{
+ public const string DialogCaption = "Daily download limit reached";
+
+ private const string SettingsLocation = "Settings > Download/Decrypt > Daily download limit";
+
+ /// Shown once when the queue pauses. Informational only: the queue resumes on its own.
+ public static string BuildQueuePausedBody(DailyDownloadLimit.Allowance allowance, string bookTitleWithSubtitle)
+ => $"""
+ Libation has paused before downloading {bookTitleWithSubtitle} because you have reached your daily download limit.
+
+ {DescribeUsage(allowance)}
+
+ Nothing is lost and nothing was cancelled. Your books are still queued, and Libation will continue on its own {DescribeResumption(allowance)}.
+
+ To download more now, change or turn off the limit in {SettingsLocation}. Libation picks up the new setting within a few seconds, so there is no need to requeue anything. To stop instead, use Cancel All in the process queue.
+ """;
+
+ /// Per-book status shown in the queue while paused. Recomputed on each re-check.
+ public static string BuildWaitingStatus(DailyDownloadLimit.Allowance allowance)
+ => allowance.NextCapacityAt is DateTimeOffset next
+ ? $"Daily limit reached, resumes about {next.ToLocalTime():t}"
+ : "Daily limit reached, waiting";
+
+ public static string BuildQueueLogEntry(DailyDownloadLimit.Allowance allowance, string bookTitleWithSubtitle)
+ => $"Daily download limit reached ({DescribeUsage(allowance)}). Waiting before downloading {bookTitleWithSubtitle}. "
+ + $"Libation will continue on its own {DescribeResumption(allowance)}, or change the limit in {SettingsLocation}.";
+
+ public static string BuildDeferredLogEntry(DailyDownloadLimit.Allowance allowance, string bookTitleWithSubtitle)
+ => $"Daily download limit reached for Audible Plus titles ({DescribeUsage(allowance)}). "
+ + $"Moved {bookTitleWithSubtitle} to the end of the queue and continued with titles the limit does not cover.";
+
+ /// stderr lines for the CLI, which skips blocked titles instead of waiting.
+ public static IEnumerable BuildCliSkippedLines(DailyDownloadLimit.Allowance allowance)
+ {
+ yield return $"Daily download limit reached: {DescribeUsage(allowance)}.";
+ yield return $"Skipping the titles it covers. Capacity returns {DescribeResumption(allowance)}.";
+ yield return $"To change or turn off the limit, set \"{nameof(Configuration.DailyDownloadLimit)}\" in Settings.json (or use {SettingsLocation} in the Libation app).";
+ }
+
+ public static string BuildCliSkippedSummary(int skippedCount)
+ => $"Skipped {skippedCount} title(s) because of your daily download limit. They remain un-liberated and will be tried on the next run.";
+
+ ///
+ /// Suggests turning the limit on after a license denial that looks like Audible throttling. Returns null when
+ /// the suggestion would be unhelpful: a limit is already configured, or too little was downloaded recently for
+ /// throttling to be a plausible explanation.
+ ///
+ public static string? BuildSuggestionParagraph(Configuration config, IReadOnlyList history, DateTimeOffset now)
+ {
+ if (config.DailyDownloadLimit is not Configuration.DailyLimitScope.NoLimit)
+ return null;
+
+ var recent = DailyDownloadLimit.SummarizeRecent(history, now);
+ if (recent.TotalDownloads < DailyDownloadLimit.SuggestionMinimumRecentDownloads)
+ return null;
+
+ var plus = recent.PlusDownloads == recent.TotalDownloads
+ ? "all of them from the Plus catalog"
+ : $"{recent.PlusDownloads} of them from the Plus catalog";
+
+ return $"""
+ Libation successfully downloaded {recent.TotalDownloads} titles in the last 24 hours, {plus}. That is the kind of volume that leads Audible to deny licenses for a day or two.
+
+ To have Libation pace itself from now on, turn on a daily download limit in {SettingsLocation}. "Plus titles only" with a limit of 50 books is a reasonable starting point; titles you own are not affected. Libation counts only the downloads it performs, over a rolling 24 hours.
+ """;
+ }
+
+ private static string DescribeUsage(DailyDownloadLimit.Allowance allowance)
+ {
+ var scope = allowance.Scope is Configuration.DailyLimitScope.PlusOnly ? "Audible Plus titles" : "books";
+
+ return allowance.LimitBytes is long limitBytes
+ ? $"your limit is {allowance.Quantity} {allowance.Unit} of {scope} per 24 hours, and Libation has downloaded about {DiskSpaceHelper.FormatBytes(allowance.UsedBytes)} of {DiskSpaceHelper.FormatBytes(limitBytes)} in the last 24 hours ({allowance.UsedBooks} title(s))"
+ : $"your limit is {allowance.Quantity} {scope} per 24 hours, and Libation has downloaded {allowance.UsedBooks} in the last 24 hours";
+ }
+
+ private static string DescribeResumption(DailyDownloadLimit.Allowance allowance)
+ => allowance.NextCapacityAt is DateTimeOffset next
+ ? $"at about {next.ToLocalTime():t} ({next.ToLocalTime():d}), when the oldest of those downloads is more than 24 hours old"
+ : "once the oldest of those downloads is more than 24 hours old";
+}
diff --git a/Source/LibationFileManager/DiskSpaceHelper.cs b/Source/LibationFileManager/DiskSpaceHelper.cs
index 9498080d..5c846084 100644
--- a/Source/LibationFileManager/DiskSpaceHelper.cs
+++ b/Source/LibationFileManager/DiskSpaceHelper.cs
@@ -31,6 +31,16 @@ public static class DiskSpaceHelper
Books = 2,
}
+ /// Single byte formatter for user-facing copy, so free space and download limits read the same way.
+ public static string FormatBytes(long bytes)
+ {
+ const long gb = 1024L * 1024 * 1024;
+ if (bytes >= gb)
+ return $"{bytes / (double)gb:F1} GB";
+ const long mb = 1024 * 1024;
+ return $"{bytes / (double)mb:F0} MB";
+ }
+
public static bool IsDiskFullException(Exception? ex)
{
for (var current = ex; current is not null; current = current.InnerException)
diff --git a/Source/LibationUiBase/ContentLicenseDeniedUserMessage.cs b/Source/LibationUiBase/ContentLicenseDeniedUserMessage.cs
index 3559c3f9..8a6df300 100644
--- a/Source/LibationUiBase/ContentLicenseDeniedUserMessage.cs
+++ b/Source/LibationUiBase/ContentLicenseDeniedUserMessage.cs
@@ -1,3 +1,7 @@
+using ApplicationServices;
+using LibationFileManager;
+using System;
+
namespace LibationUiBase;
///
@@ -19,7 +23,7 @@ public static class ContentLicenseDeniedUserMessage
Heavy use of the Audible Plus catalog in a short time can also produce "license denied" responses; community reports often involve on the order of dozens of titles — Audible does not publish a fixed limit. Waiting 24 to 48 hours before trying again is usually enough.
If the problem continues after several days, open an issue on Libation's GitHub and include your logs.
- """;
+ """ + AppendSuggestion();
/// License denied on an Audible Plus title — often rate limiting, not a Libation defect.
public static string BuildDialogBodyForPlusCatalog(string bookTitleWithSubtitle)
@@ -31,5 +35,33 @@ public static class ContentLicenseDeniedUserMessage
Try waiting 24 to 48 hours and liberate again. If it still fails after several days, open an issue on Libation's GitHub with logs.
If you should not have access to this title (for example it left Plus before you downloaded), confirm in the Audible app or website.
- """;
+ """ + AppendSuggestion();
+
+ ///
+ /// Audible reports no distinct "throttled" reason, so this suggestion is what turns a guess into evidence:
+ /// it only appears when Libation's own record shows enough recent downloads for throttling to be plausible,
+ /// and when the user has no daily limit configured yet. Logged as well as shown.
+ ///
+ private static string AppendSuggestion()
+ {
+ try
+ {
+ var now = DateTimeOffset.Now;
+ var suggestion = DailyDownloadLimitUserMessage.BuildSuggestionParagraph(
+ Configuration.Instance,
+ DownloadHistoryStore.GetCurrentWindow(now),
+ now);
+
+ if (suggestion is null)
+ return string.Empty;
+
+ Serilog.Log.Logger.Information("Suggesting a daily download limit after a license denial. {Suggestion}", suggestion);
+ return Environment.NewLine + Environment.NewLine + suggestion;
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(ex, "Failed to build the daily download limit suggestion");
+ return string.Empty;
+ }
+ }
}
diff --git a/Source/LibationUiBase/DiskFullUserMessage.cs b/Source/LibationUiBase/DiskFullUserMessage.cs
index 18a0a9de..c0bc286f 100644
--- a/Source/LibationUiBase/DiskFullUserMessage.cs
+++ b/Source/LibationUiBase/DiskFullUserMessage.cs
@@ -76,12 +76,5 @@ public static class DiskFullUserMessage
return "Libation paths";
}
- private static string FormatBytes(long bytes)
- {
- const long gb = 1024L * 1024 * 1024;
- if (bytes >= gb)
- return $"{bytes / (double)gb:F1} GB";
- const long mb = 1024 * 1024;
- return $"{bytes / (double)mb:F0} MB";
- }
+ private static string FormatBytes(long bytes) => DiskSpaceHelper.FormatBytes(bytes);
}
diff --git a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs
index 84e031d4..abe84375 100644
--- a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs
+++ b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs
@@ -63,7 +63,19 @@ public class ProcessBookViewModel : ReactiveObject
public bool IsDownloading => Status is ProcessBookStatus.Working;
public bool Queued => Status is ProcessBookStatus.Queued;
- public string StatusText => (Result, LibraryBook.IsAudiblePlus) switch
+ ///
+ /// Transient status shown instead of the usual text, e.g. while the queue waits on the daily download limit.
+ /// Null when the book's own state should speak for itself.
+ ///
+ public string? StatusOverride { get => field; set { RaiseAndSetIfChanged(ref field, value); RaisePropertyChanged(nameof(StatusText)); } }
+
+ ///
+ /// True when this queue item downloads an audiobook, so the daily download limit applies to it.
+ /// PDF-only and mp3-conversion items are never limited.
+ ///
+ public bool IncludesBookDownload { get; private set; }
+
+ public string StatusText => StatusOverride ?? (Result, LibraryBook.IsAudiblePlus) switch
{
(ProcessBookResult.Success, _) => "Finished",
(ProcessBookResult.Cancelled, _) => "Cancelled",
@@ -267,7 +279,11 @@ public class ProcessBookViewModel : ReactiveObject
}
public ProcessBookViewModel AddDownloadPdf() => AddProcessable();
- public ProcessBookViewModel AddDownloadDecryptBook() => AddProcessable();
+ public ProcessBookViewModel AddDownloadDecryptBook()
+ {
+ IncludesBookDownload = true;
+ return AddProcessable();
+ }
public ProcessBookViewModel AddConvertToMp3() => AddProcessable();
public ProcessBookViewModel AddUploadToAudiobookshelf() => AddProcessable();
public ProcessBookViewModel AddSimulateBadBookFailure() => AddProcessable();
diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
index 9ca113c1..cadc2afb 100644
--- a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
+++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
@@ -19,12 +19,22 @@ public record LogEntry(DateTime LogDate, string LogMessage)
public class ProcessQueueViewModel : ReactiveObject
{
+ ///
+ /// How often a queue paused on the daily download limit re-checks. Short and fixed: never a delay computed
+ /// from when capacity is expected, so a change of setting or the window rolling over is picked up promptly.
+ ///
+ private static readonly TimeSpan DailyLimitPollInterval = TimeSpan.FromSeconds(15);
+
public ObservableCollection LogEntries { get; } = new();
public TrackedQueue Queue { get; } = new();
private readonly BadBookSessionContext _badBookSession = new();
public Task? QueueRunner { get; private set; }
public bool Running => !QueueRunner?.IsCompleted ?? false;
+ /// Set by ; watched by the daily download limit wait loop.
+ private volatile bool cancelAllRequested;
+ private bool dailyLimitMessageShownThisRun;
+
public ProcessQueueViewModel()
{
Queue.QueuedCountChanged += Queue_QueuedCountChanged;
@@ -97,6 +107,21 @@ public class ProcessQueueViewModel : ReactiveObject
private void ProcessBook_LogWritten(object? sender, string logMessage)
=> Invoke(() => LogEntries.Add(new(DateTime.Now, logMessage.Trim())));
+ private void AddQueueLogEntry(string logMessage)
+ => Invoke(() => LogEntries.Add(new(DateTime.Now, logMessage.Trim())));
+
+ ///
+ /// Clears the queue and cancels the book being processed. Also ends a pause on the daily download limit,
+ /// which is why both UIs call this instead of manipulating the queue directly.
+ ///
+ public async Task CancelAllAsync()
+ {
+ cancelAllRequested = true;
+ Queue.ClearQueue();
+ if (Queue.Current is ProcessBookViewModel current)
+ await current.CancelAsync();
+ }
+
#region Add Books to Queue
public async Task QueueDownloadPdfAsync(IList libraryBooks, Configuration? config = null)
@@ -341,6 +366,125 @@ public class ProcessQueueViewModel : ReactiveObject
}
#endregion
+
+ #region Daily download limit
+
+ private enum DailyLimitGate
+ {
+ /// The limit does not stop this book right now.
+ Proceed,
+ /// This book is limited but something else in the queue is not; try it later.
+ Defer,
+ /// The user cancelled the queue while it was waiting.
+ Cancelled
+ }
+
+ ///
+ /// Runs immediately before a book downloads, never at queueing time, so the queue keeps its contents and a
+ /// user can raise or turn off the limit mid-run. Every iteration re-reads the setting, re-queries the
+ /// history and re-reads the clock: a queue left alone for days must resume by itself as its oldest
+ /// downloads age out of the rolling window.
+ ///
+ ///
+ /// How many books this queue run has already moved to the back for the limit. Compared against the live
+ /// queued count so the rotation cannot continue indefinitely.
+ ///
+ private async Task WaitForDailyLimitAsync(ProcessBookViewModel nextBook, int deferralsSoFar)
+ {
+ if (!nextBook.IncludesBookDownload)
+ return DailyLimitGate.Proceed;
+
+ var paused = false;
+
+ while (true)
+ {
+ if (cancelAllRequested)
+ {
+ nextBook.StatusOverride = null;
+ return DailyLimitGate.Cancelled;
+ }
+
+ var now = DateTimeOffset.Now;
+ var allowance = DailyDownloadLimit.Evaluate(Configuration.Instance, DownloadHistoryStore.GetCurrentWindow(now), now);
+
+ if (!allowance.Blocks(nextBook.LibraryBook.IsAudiblePlus))
+ {
+ nextBook.StatusOverride = null;
+ if (paused)
+ {
+ var resumed = $"Daily download limit: capacity is available again. Resuming with {nextBook.LibraryBook.Book.TitleWithSubtitle}.";
+ Serilog.Log.Logger.Information("Daily download limit no longer blocks {libraryBook}. Resuming the queue.", nextBook.LibraryBook.LogFriendly());
+ AddQueueLogEntry(resumed);
+ }
+ return DailyLimitGate.Proceed;
+ }
+
+ // Under "Plus titles only" a mixed queue can keep going; do not stall owned titles behind a Plus title.
+ if (deferralsSoFar < QueuedCount && AnyOtherQueuedBookAllowed(nextBook, allowance))
+ {
+ nextBook.StatusOverride = null;
+ Serilog.Log.Logger.Information(
+ "Daily download limit blocks {libraryBook}. Moving it to the end of the queue and continuing with titles the limit does not cover.",
+ nextBook.LibraryBook.LogFriendly());
+ AddQueueLogEntry(DailyDownloadLimitUserMessage.BuildDeferredLogEntry(allowance, nextBook.LibraryBook.Book.TitleWithSubtitle));
+ return DailyLimitGate.Defer;
+ }
+
+ if (!paused)
+ {
+ paused = true;
+ Serilog.Log.Logger.Information(
+ "Daily download limit reached; pausing the queue before {libraryBook}. {@DebugInfo}",
+ nextBook.LibraryBook.LogFriendly(),
+ new { allowance.Scope, allowance.Unit, allowance.Quantity, allowance.UsedBooks, allowance.UsedBytes, allowance.NextCapacityAt });
+ AddQueueLogEntry(DailyDownloadLimitUserMessage.BuildQueueLogEntry(allowance, nextBook.LibraryBook.Book.TitleWithSubtitle));
+ ShowDailyLimitMessageOncePerRun(allowance, nextBook.LibraryBook.Book.TitleWithSubtitle);
+ }
+
+ nextBook.StatusOverride = DailyDownloadLimitUserMessage.BuildWaitingStatus(allowance);
+
+ await Task.Delay(DailyLimitPollInterval);
+ }
+ }
+
+ /// Moves the book being held back to the end of the queue without counting it as completed.
+ private void RequeueLast(ProcessBookViewModel book)
+ {
+ Queue.ClearCurrent();
+ Queue.Enqueue([book]);
+ }
+
+ private bool AnyOtherQueuedBookAllowed(ProcessBookViewModel nextBook, DailyDownloadLimit.Allowance allowance)
+ => Queue.Any(b =>
+ b is not null
+ && !ReferenceEquals(b, nextBook)
+ && b.Status is ProcessBookStatus.Queued
+ && (!b.IncludesBookDownload || !allowance.Blocks(b.LibraryBook.IsAudiblePlus)));
+
+ ///
+ /// Deliberately not awaited. This dialog only completes when the user dismisses it, and a queue that is
+ /// waiting must be free to resume by itself hours later with nobody at the keyboard. Shown once per queue
+ /// run so a multi-day drip-feed does not stack up a dialog per day; later pauses use the log and status.
+ ///
+ private void ShowDailyLimitMessageOncePerRun(DailyDownloadLimit.Allowance allowance, string bookTitleWithSubtitle)
+ {
+ if (dailyLimitMessageShownThisRun)
+ return;
+
+ dailyLimitMessageShownThisRun = true;
+
+ _ = MessageBoxBase.Show(
+ DailyDownloadLimitUserMessage.BuildQueuePausedBody(allowance, bookTitleWithSubtitle),
+ DailyDownloadLimitUserMessage.DialogCaption,
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Information)
+ .ContinueWith(
+ t => Serilog.Log.Logger.Error(t.Exception, "Failed to show the daily download limit message"),
+ TaskContinuationOptions.OnlyOnFaulted);
+ }
+
+ #endregion
+
public event EventHandler? ProcessStart;
public event EventHandler? ProcessEnd;
private async Task QueueLoop()
@@ -350,12 +494,16 @@ public class ProcessQueueViewModel : ReactiveObject
Serilog.Log.Logger.Information("Begin processing queue");
_badBookSession.Reset();
+ cancelAllRequested = false;
+ dailyLimitMessageShownThisRun = false;
RunningTime = string.Empty;
ProgressBarVisible = true;
var startingTime = DateTime.Now;
bool shownLicenseGuidanceMessage = false;
bool shownWidevineGuidanceMessage = false;
bool shownDiskFullMessage = false;
+ // Bounds the daily-limit deferral rotation, so a book can never be shuffled to the back forever.
+ int consecutiveDeferrals = 0;
using var counterTimer = new System.Threading.Timer(_ => RunningTime = timeToStr(DateTime.Now - startingTime), null, 0, 500);
@@ -367,6 +515,27 @@ public class ProcessQueueViewModel : ReactiveObject
continue;
}
+ // Checked here rather than at queueing time so the queue keeps its contents and the user can
+ // change the limit mid-run. Deferral keeps a mixed queue moving under "Plus titles only".
+ var gate = await WaitForDailyLimitAsync(nextBook, consecutiveDeferrals);
+
+ if (gate is DailyLimitGate.Defer)
+ {
+ consecutiveDeferrals++;
+ RequeueLast(nextBook);
+ continue;
+ }
+
+ consecutiveDeferrals = 0;
+
+ if (gate is DailyLimitGate.Cancelled)
+ {
+ Serilog.Log.Logger.Information("Queue was cancelled while waiting on the daily download limit.");
+ nextBook.Result = ProcessBookResult.Cancelled;
+ nextBook.Status = ProcessBookStatus.Cancelled;
+ continue;
+ }
+
Serilog.Log.Logger.Information("Begin processing queued item: '{item_LibraryBook}'", nextBook.LibraryBook);
SpeedLimit = nextBook.Configuration.DownloadSpeedLimit / 1024m / 1024;
ProcessStart?.Invoke(this, nextBook);
diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs
index ca995b05..aabe7d4c 100644
--- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs
+++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs
@@ -67,11 +67,7 @@ internal partial class ProcessQueueControl : UserControl
}
private async void cancelAllBtn_Click(object? sender, EventArgs e)
- {
- ViewModel.Queue.ClearQueue();
- if (ViewModel.Queue.Current is not null)
- await ViewModel.Queue.Current.CancelAsync();
- }
+ => await ViewModel.CancelAllAsync();
private void btnClearFinished_Click(object? sender, EventArgs e)
{