Files
Libation/Source/FileLiberator/DownloadFailureClassifier.cs
T
Cursor Agentandrmcrackan 824ff10dd9 fix(download): stop asking Audible for a license it just refused
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>
2026-08-16 16:13:17 +00:00

89 lines
3.9 KiB
C#

using AudibleApi;
using AudibleApi.Common;
using DataLayer;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace FileLiberator;
/// <summary>What Libation understood about a failed download attempt, and how long to wait because of it.</summary>
public sealed record DownloadFailureDiagnosis(DownloadFailureKind Kind, string Reason);
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
public static class DownloadFailureClassifier
{
/// <summary>
/// Substring in Audible's Sable error when no audio asset exists for the title, which is what an
/// unreleased preorder looks like. Shared with <see cref="WidevineRecommendation.SableAcrNullMarker"/>,
/// which pairs it with an error code to spot a much narrower case.
/// </summary>
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)
};
/// <summary>
/// Audible attaches a rejection reason per validation type. <c>GenericError</c> 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.
/// </summary>
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);
}
/// <summary>The most specific message Audible gave, prefixed with which check it failed.</summary>
private static string? BuildLicenseDenialReason(IEnumerable<LicenseDenialReason?> reasons)
=> reasons
.Where(r => !string.IsNullOrWhiteSpace(r?.Message))
.Select(r => r!.ValidationType is { Length: > 0 } type ? $"{type}: {r.Message}" : r.Message)
.FirstOrDefault();
/// <summary>
/// A license request that fails with no content reference (<c>acr:null</c>) means Audible has nothing to
/// deliver for this title yet, which is what a preorder that has not been released looks like.
/// </summary>
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.");
}
}