Files
Libation/Source/FileLiberator/Processable.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

190 lines
6.5 KiB
C#

using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace FileLiberator;
public interface IProcessable<T> where T : IProcessable<T>
{
/// <summary>
/// Create a new instance of the Processable which uses a specific Configuration
/// </summary>
/// <param name="config">The <see cref="Configuration"/> this <typeparamref name="T"/> will use</param>
static abstract T Create(Configuration config);
}
public abstract class Processable
{
public abstract string Name { get; }
public event EventHandler<LibraryBook>? Begin;
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
public event EventHandler<string>? StatusUpdate;
/// <summary>Fired when a file is successfully saved to disk</summary>
public event EventHandler<(string id, string path)>? FileCreated;
public event EventHandler<DownloadProgress>? StreamingProgressChanged;
public event EventHandler<TimeSpan>? StreamingTimeRemaining;
public event EventHandler<LibraryBook>? Completed;
public required Configuration Configuration { get; init; }
protected Processable() { }
/// <returns>True == Valid</returns>
public abstract bool Validate(LibraryBook libraryBook);
/// <returns>True == success</returns>
public abstract Task<StatusHandler> ProcessAsync(LibraryBook libraryBook);
/// <summary>
/// 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.
/// </summary>
protected virtual bool RecordsAttemptFailures => false;
// when used in foreach: stateful. deferred execution
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
=> library.Where(libraryBook =>
Validate(libraryBook)
&& (!libraryBook.Book.IsEpisodeChild() || Configuration.DownloadEpisodes)
);
public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate)
{
if (validate && !Validate(libraryBook))
return new StatusHandler { "Validation failed" };
Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new
{
libraryBook.Book.TitleWithSubtitle,
libraryBook.Book.AudibleProductId,
libraryBook.Book.Locale,
Account = libraryBook.Account?.ToMask() ?? "[empty]"
});
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);
}
if (status.IsSuccess && RecordsAttemptFailures)
DownloadAttemptFailureStore.Clear(libraryBook);
return status;
}
/// <summary>
/// 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.
/// </summary>
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<StatusHandler> TryProcessAsync(LibraryBook libraryBook)
=> Validate(libraryBook)
? await ProcessAsync(libraryBook)
: new StatusHandler();
protected void OnBegin(LibraryBook libraryBook)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Begin), Book = libraryBook.LogFriendly() });
Begin?.Invoke(this, libraryBook);
}
protected void OnStatusUpdate(string statusUpdate)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StatusUpdate), Status = statusUpdate });
StatusUpdate?.Invoke(this, statusUpdate);
}
protected void OnFileCreated(LibraryBook libraryBook, string path)
{
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), libraryBook.Book.AudibleProductId, path });
FilePathCache.Insert(libraryBook.Book.AudibleProductId, path);
FileCreated?.Invoke(this, (libraryBook.Book.AudibleProductId, path));
}
protected void OnStreamingProgressChanged(DownloadProgress progress)
=> OnStreamingProgressChanged(null, progress);
protected void OnStreamingProgressChanged(object? _, DownloadProgress progress)
=> StreamingProgressChanged?.Invoke(this, progress);
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining)
=> OnStreamingTimeRemaining(null, timeRemaining);
protected void OnStreamingTimeRemaining(object? _, TimeSpan timeRemaining)
=> StreamingTimeRemaining?.Invoke(this, timeRemaining);
protected void OnCompleted(LibraryBook libraryBook)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });
Completed?.Invoke(this, libraryBook);
}
protected void SetFileTime(LibraryBook libraryBook, string file)
=> setFileSystemTime(libraryBook, new FileInfo(file));
protected void SetDirectoryTime(LibraryBook libraryBook, string file)
=> setFileSystemTime(libraryBook, new DirectoryInfo(file));
private void setFileSystemTime(LibraryBook libraryBook, FileSystemInfo fileInfo)
{
if (!fileInfo.Exists) return;
DateTime? getTimeValue(Configuration.DateTimeSource source) => source switch
{
Configuration.DateTimeSource.Added => libraryBook.DateAdded,
Configuration.DateTimeSource.Published => libraryBook.Book.DatePublished,
_ => null,
};
if (getTimeValue(Configuration.CreationTime) is { } creationUtc)
{
try
{
fileInfo.CreationTimeUtc = creationUtc;
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
Serilog.Log.Logger.Debug(ex, "Could not set creation time for {Path}; filesystem may not support it.", fileInfo.FullName);
}
}
if (getTimeValue(Configuration.LastWriteTime) is { } lastWriteUtc)
{
try
{
fileInfo.LastWriteTimeUtc = lastWriteUtc;
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
Serilog.Log.Logger.Debug(ex, "Could not set last write time for {Path}; filesystem may not support it.", fileInfo.FullName);
}
}
}
}