mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-14 22:58:07 -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>
89 lines
3.9 KiB
C#
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.");
|
|
}
|
|
}
|