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>
190 lines
6.5 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|