mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
A license denial left no trace: BookStatus stayed NotLiberated, so every liberate run asked again. Only the GUI's bad-book dialog could mark a title Error, and license denials take their own path and never reach that dialog, so a headless install had no way at all to stop the retries. A cron schedule then re-requested the same refused licenses every run and printed the same warning block for each one, which is both wasted API traffic and the log noise reported in issue #1947. Record the refusal instead, with a wait that doubles per consecutive failure: one day for an eligibility denial (up to 30), six hours for a title Audible has no audio for yet such as an unreleased preorder (up to 7 days), one hour when the denial names GenericError, which the GUI already reads as an outage (up to 12). Nothing is permanent - every kind is attempted again on its own. Only failures attributable to Audible are recorded. A dropped connection, a decrypt error or a full disk keeps being retried on the next run as before. Naming a title, --force, and setting a download status all clear the record: asking for a title explicitly overrides the wait. Co-authored-by: rmcrackan <rmcrackan@gmail.com>
177 lines
6.2 KiB
C#
177 lines
6.2 KiB
C#
using DataLayer;
|
|
using Dinah.Core;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace ApplicationServices;
|
|
|
|
/// <summary>
|
|
/// Reads and writes the record of refused download attempts that keeps a scheduled run from asking Audible
|
|
/// for the same license every time.
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class DownloadAttemptFailureStore
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Titles whose wait has not elapsed. Empty when the query fails, so downloading carries on.</summary>
|
|
public static IReadOnlyList<DeferredDownload> 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 [];
|
|
}
|
|
}
|
|
|
|
/// <summary>The current wait for one title, or null when it is ready to be attempted. Null when the query fails.</summary>
|
|
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<DeferredDownload> Project(IQueryable<DownloadAttemptFailure> 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();
|
|
|
|
/// <summary>Audible's messages can run long; the full text is already in the log.</summary>
|
|
private static string? Truncate(string? reason)
|
|
=> reason is null || reason.Length <= 400 ? reason : reason[..400];
|
|
}
|