Files
Libation/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs
T
Allamagoosa 492d2e9ae3 Cancel the book, not the queue, at the daily download limit
The gate read a queue-wide cancelAllRequested flag, which then had to be cleared
again - and whatever clears it can un-cancel a book that is still parked. Clearing
it at the end of the cancellation drain was the window you described; scoping it to
the run moved that window into AddToQueue, which cleared it so newly queued books
would not inherit an old cancellation. Same bug, different trigger.

WaitForDailyLimitAsync already waits on behalf of one specific book, so it now asks
whether that book was cancelled. Cancel All reaches a parked book through
ProcessBookViewModel.CancelAsync like any other active book - it is on the active
list, having been dequeued before the gate - so nothing has to be set or cleared at
the queue level and the flag is gone entirely, along with both of its clear sites.
Books queued afterwards are untouched by construction rather than by timing.

CancelAsync records the request before its early return, because a book that has
not started a step is precisely the case that matters. That also makes cancelling
a single parked book work, which it previously did not.
2026-08-21 07:50:14 -07:00

574 lines
21 KiB
C#

using ApplicationServices;
using AudibleApi;
using AudibleApi.Common;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileLiberator;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationUiBase.ProcessQueue;
public enum ProcessBookResult
{
None,
Success,
Cancelled,
ValidationFail,
FailedRetry,
FailedSkip,
FailedAbort,
LicenseDenied,
LicenseDeniedPossibleOutage,
/// <summary>ADRM licenserequest failed with Sable acr:null; Widevine may work (see WidevineRecommendation).</summary>
WidevineRecommended,
/// <summary>Volume full on write; queue should stop (see ProcessQueueViewModel queue loop).</summary>
DiskFull
}
public enum ProcessBookStatus
{
Queued,
Cancelled,
Working,
Completed,
Failed
}
/// <summary>
/// This is the viewmodel for queued processables
/// </summary>
public class ProcessBookViewModel : ReactiveObject
{
public LibraryBook LibraryBook { get; protected set; }
public Configuration Configuration { get; }
private readonly BadBookSessionContext? _badBookSession;
#region Properties exposed to the view
public ProcessBookResult Result { get => field; set { RaiseAndSetIfChanged(ref field, value); RaisePropertyChanged(nameof(StatusText)); } }
public ProcessBookStatus Status { get => field; set { RaiseAndSetIfChanged(ref field, value); RaisePropertyChanged(nameof(IsFinished)); RaisePropertyChanged(nameof(IsDownloading)); RaisePropertyChanged(nameof(Queued)); } }
public string? Narrator { get => field; set => RaiseAndSetIfChanged(ref field, value); }
public string? Author { get => field; set => RaiseAndSetIfChanged(ref field, value); }
public string? Title { get => field; set => RaiseAndSetIfChanged(ref field, value); }
public int Progress { get => field; protected set => RaiseAndSetIfChanged(ref field, value); }
public TimeSpan TimeRemaining { get => field; set { RaiseAndSetIfChanged(ref field, value); ETA = $"ETA: {value:mm\\:ss}"; } }
public string? ETA { get => field; private set => RaiseAndSetIfChanged(ref field, value); }
public object? Cover { get => field; protected set => RaiseAndSetIfChanged(ref field, value); }
public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working;
public bool IsDownloading => Status is ProcessBookStatus.Working;
public bool Queued => Status is ProcessBookStatus.Queued;
/// <summary>
/// Transient status shown instead of the usual text, e.g. while the queue waits on the daily download limit.
/// Null when the book's own state should speak for itself.
/// </summary>
public string? StatusOverride { get => field; set { RaiseAndSetIfChanged(ref field, value); RaisePropertyChanged(nameof(StatusText)); } }
/// <summary>
/// True when this queue item downloads an audiobook, so the daily download limit applies to it.
/// PDF-only and mp3-conversion items are never limited.
/// </summary>
public bool IncludesBookDownload { get; private set; }
public string StatusText => StatusOverride ?? (Result, LibraryBook.IsAudiblePlus) switch
{
(ProcessBookResult.Success, _) => "Finished",
(ProcessBookResult.Cancelled, _) => "Cancelled",
(ProcessBookResult.ValidationFail, _) => "Validation fail",
(ProcessBookResult.FailedRetry, _) => "Error, will retry later",
(ProcessBookResult.FailedSkip, _) => "Error, Skipping",
(ProcessBookResult.FailedAbort, _) => "Error, Abort",
(ProcessBookResult.LicenseDenied, true) => "License denied (Plus; often temporary)",
(ProcessBookResult.LicenseDenied, false) => "License Denied",
(ProcessBookResult.LicenseDeniedPossibleOutage, _) => "Possible Service Interruption",
(ProcessBookResult.WidevineRecommended, _) => WidevineRecommendationUserMessage.QueueStatusText,
(ProcessBookResult.DiskFull, _) => "Disk full, queue stopped",
_ => Status.ToString(),
};
#endregion
#region Process Queue Logging
public event EventHandler<string>? LogWritten;
private void OnLogWritten(string text) => LogWritten?.Invoke(this, text.Trim());
private void LogError(string? message, Exception? ex = null)
{
OnLogWritten(message ?? "Automated backup: error");
if (ex is not null)
OnLogWritten("ERROR: " + ex.Message);
}
private void LogInfo(string text) => OnLogWritten(text);
#endregion
protected Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke();
protected void NextProcessable() => _currentProcessable = null;
private Processable? _currentProcessable;
/// <summary> A series of Processable actions to perform on this book </summary>
protected Queue<Func<Processable>> Processes { get; } = new();
public ProcessBookViewModel(LibraryBook libraryBook, Configuration configuration, BadBookSessionContext? badBookSession = null)
{
LibraryBook = libraryBook;
Configuration = configuration;
_badBookSession = badBookSession;
Title = LibraryBook.Book.TitleWithSubtitle;
Author = LibraryBook.Book.AuthorNames;
Narrator = LibraryBook.Book.NarratorNames;
var pictureId = LibraryBook.Book.PictureId ?? LibraryBook.Book.PictureLarge;
if (string.IsNullOrEmpty(pictureId))
return;
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(pictureId, PictureSize._80x80));
if (isDefault)
PictureStorage.PictureCached += PictureStorage_PictureCached;
Cover = BaseUtil.LoadImage(picture, PictureSize._80x80);
}
private void PictureStorage_PictureCached(object? sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == LibraryBook.Book.PictureId)
{
Cover = BaseUtil.LoadImage(e.Picture, PictureSize._80x80);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
public async Task<ProcessBookResult> ProcessOneAsync()
{
ProcessBookResult result = ProcessBookResult.None;
try
{
// Run every queued step (decrypt, PDF, MP3, ...) in order so disk-full and other failures
// from later steps are returned to the queue loop. Do not chain via Processable.Completed.
while (Processes.Count > 0)
{
var processable = CurrentProcessable;
result = await ExecuteProcessableAsync(processable);
UnlinkProcessable(processable);
NextProcessable();
if (result is not ProcessBookResult.Success)
break;
}
}
finally
{
// DiskFull skips the per-book Abort/Retry/Ignore dialog; the queue shows one disk-full message instead.
if (result == ProcessBookResult.None)
result = await GetFailureActionAsync(LibraryBook);
var status = result switch
{
ProcessBookResult.Success => ProcessBookStatus.Completed,
ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled,
_ => ProcessBookStatus.Failed,
};
Status = status;
}
Result = result;
return result;
}
private async Task<ProcessBookResult> ExecuteProcessableAsync(Processable processable)
{
string procName = processable.Name;
ProcessBookResult result = ProcessBookResult.None;
try
{
// Optional steps (e.g. PDF when the book has no supplement) use Validate() to mean "does not apply".
// Skip them instead of treating ProcessSingleAsync's "Validation failed" as a queue failure.
if (!processable.Validate(LibraryBook))
return ProcessBookResult.Success;
LinkProcessable(processable);
var statusHandler = await processable.ProcessSingleAsync(LibraryBook, validate: true);
if (statusHandler.IsSuccess)
result = ProcessBookResult.Success;
else if (statusHandler.Errors.Contains("Cancelled"))
{
LogInfo($"{procName}: Process was cancelled - {LibraryBook.Book}");
result = ProcessBookResult.Cancelled;
}
else if (statusHandler.Errors.Contains("Validation failed"))
{
LogInfo($"{procName}: Validation failed - {LibraryBook.Book}");
result = ProcessBookResult.ValidationFail;
}
else
{
foreach (var errorMessage in statusHandler.Errors)
LogError($"{procName}: {errorMessage}");
// Prefer disk-full detection over generic retry; avoids treating truncated .aaxc as a normal failure.
if (statusHandler.Errors.Any(DiskSpaceHelper.ErrorMessageIndicatesDiskFull))
{
LogInfo($"{procName}: Disk is full. Free space or change Books / In progress in Settings. - {LibraryBook.Book}");
result = ProcessBookResult.DiskFull;
}
}
}
catch (ApiErrorException ex) when (WidevineRecommendation.ShouldRecommendWidevine(ex, Configuration))
{
Serilog.Log.Logger.Error(ex, "ADRM license unavailable (Sable acr:null) for {Book}", LibraryBook.LogFriendly());
LogInfo($"{procName}: {WidevineRecommendationUserMessage.BuildLogSummary(LibraryBook.Book.TitleWithSubtitle)}");
result = ProcessBookResult.WidevineRecommended;
}
catch (ContentLicenseDeniedException ldex)
{
Serilog.Log.Logger.Error(ldex, "Content license was denied for {Book}", LibraryBook.LogFriendly());
if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError)
{
LogInfo($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDeniedPossibleOutage;
}
else if (LibraryBook.IsAudiblePlus)
{
LogInfo($"{procName}: Content license denied for this Audible Plus catalog title. Audible often throttles license requests after heavy Plus use; try again in 1 to 2 days. If you should not have access, check the Audible app. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDenied;
}
else
{
LogInfo($"{procName}: Content license denied. Check your Audible account to see if you have access to this title. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDenied;
}
}
// HRESULT 0x80070070 / known messages from the OS when a volume is full (including many SMB shares).
catch (Exception ex) when (DiskSpaceHelper.IsDiskFullException(ex))
{
Serilog.Log.Logger.Error(ex, "Disk full during {ProcName} for {{@Book}}", procName, LibraryBook.LogFriendly());
LogInfo($"{procName}: Disk is full. Free space or change Books / In progress in Settings. - {LibraryBook.Book}");
result = ProcessBookResult.DiskFull;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"Unhandled exception in {procName} for {{@Book}}", LibraryBook.LogFriendly());
LogError(procName, ex);
}
return result;
}
private volatile bool cancellationRequested;
/// <summary>
/// Whether cancellation has been asked for on this book, whether or not there was anything
/// running to cancel at the time.
/// </summary>
/// <remarks>
/// A book held at the daily download limit has been taken off the queue but has not started a
/// step, so <see cref="CancelAsync"/> finds nothing to call and the book carries on waiting.
/// This is how the gate learns to stop rather than resume it. Per book rather than per queue on
/// purpose: a queue-wide flag has to be cleared again at some point, and whatever clears it can
/// un-cancel a book that is still parked - which is the same bug in a different place.
/// </remarks>
public bool CancellationRequested => cancellationRequested;
/// <summary>
/// Cancels this book's running step, if it has one. Safe to call on a book that has already
/// finished processing.
/// </summary>
/// <remarks>
/// Reads the <see cref="_currentProcessable"/> field rather than <see cref="CurrentProcessable"/>.
/// The property is lazy - <c>_currentProcessable ??= Processes.Dequeue().Invoke()</c> - so on a
/// book that has run its last step the field is null and <see cref="Processes"/> is empty, and
/// reading it throws "Queue empty.". That is not a narrow race: every book waiting in the bad
/// book dialog is in exactly that state, because <c>ProcessOneAsync</c> reaches the dialog from
/// its <c>finally</c> after the processable loop has drained. Reading the field also avoids
/// dequeuing from a non-thread-safe <see cref="Queue{T}"/> on the cancelling thread while the
/// book's own loop is reading it.
/// </remarks>
public async Task CancelAsync()
{
// Recorded before the early return, because the case where there is nothing to cancel is the
// one that matters. See CancellationRequested.
cancellationRequested = true;
// Deliberately the field, not the property. See remarks.
var processable = _currentProcessable;
if (processable is not AudioDecodable audioDecodable)
return;
try
{
await audioDecodable.CancelAsync();
}
catch (Exception ex)
{
// Not CurrentProcessable.Name - that would throw a second time, out of the catch.
LogError($"{audioDecodable.Name}: Error while cancelling", ex);
}
}
public ProcessBookViewModel AddDownloadPdf() => AddProcessable<DownloadPdf>();
public ProcessBookViewModel AddDownloadDecryptBook()
{
IncludesBookDownload = true;
return AddProcessable<DownloadDecryptBook>();
}
public ProcessBookViewModel AddConvertToMp3() => AddProcessable<ConvertToMp3>();
public ProcessBookViewModel AddUploadToAudiobookshelf() => AddProcessable<UploadToAudiobookshelf>();
public ProcessBookViewModel AddSimulateBadBookFailure() => AddProcessable<SimulateBadBookFailure>();
private ProcessBookViewModel AddProcessable<T>() where T : Processable, IProcessable<T>
{
Processes.Enqueue(() => T.Create(Configuration));
return this;
}
public override string ToString() => LibraryBook.ToString();
#region Subscribers and Unsubscribers
private void LinkProcessable(Processable processable)
{
processable.Begin += Processable_Begin;
processable.Completed += Processable_Completed;
processable.StatusUpdate += Processable_StatusUpdate;
processable.StreamingProgressChanged += Streamable_StreamingProgressChanged;
processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining;
if (processable is AudioDecodable audioDecodable)
{
audioDecodable.RequestCoverArt += AudioDecodable_RequestCoverArt;
audioDecodable.TitleDiscovered += AudioDecodable_TitleDiscovered;
audioDecodable.AuthorsDiscovered += AudioDecodable_AuthorsDiscovered;
audioDecodable.NarratorsDiscovered += AudioDecodable_NarratorsDiscovered;
audioDecodable.CoverImageDiscovered += AudioDecodable_CoverImageDiscovered;
}
}
private void UnlinkProcessable(Processable processable)
{
processable.Begin -= Processable_Begin;
processable.Completed -= Processable_Completed;
processable.StatusUpdate -= Processable_StatusUpdate;
processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged;
processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining;
if (processable is AudioDecodable audioDecodable)
{
audioDecodable.RequestCoverArt -= AudioDecodable_RequestCoverArt;
audioDecodable.TitleDiscovered -= AudioDecodable_TitleDiscovered;
audioDecodable.AuthorsDiscovered -= AudioDecodable_AuthorsDiscovered;
audioDecodable.NarratorsDiscovered -= AudioDecodable_NarratorsDiscovered;
audioDecodable.CoverImageDiscovered -= AudioDecodable_CoverImageDiscovered;
}
}
#endregion
#region AudioDecodable event handlers
private void AudioDecodable_TitleDiscovered(object? sender, string title) => Title = title;
private void AudioDecodable_AuthorsDiscovered(object? sender, string authors) => Author = authors;
private void AudioDecodable_NarratorsDiscovered(object? sender, string narrators) => Narrator = narrators;
private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt)
=> Cover = BaseUtil.LoadImage(coverArt, PictureSize._80x80);
private byte[]? AudioDecodable_RequestCoverArt(object? sender, EventArgs e)
{
var pictureId = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High
? LibraryBook.Book.PictureLarge ?? LibraryBook.Book.PictureId
: LibraryBook.Book.PictureId;
if (pictureId is null)
return null;
byte[] coverData = PictureStorage.GetPictureSynchronously(new PictureDefinition(pictureId, PictureSize.Native));
AudioDecodable_CoverImageDiscovered(this, coverData);
return coverData;
}
#endregion
#region Streamable event handlers
private void Streamable_StreamingTimeRemaining(object? sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining;
private void Streamable_StreamingProgressChanged(object? sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress)
{
if (!downloadProgress.ProgressPercentage.HasValue)
return;
if (downloadProgress.ProgressPercentage == 0)
TimeRemaining = TimeSpan.Zero;
else
Progress = (int)downloadProgress.ProgressPercentage;
}
#endregion
#region Processable event handlers
private void Processable_Begin(object? sender, LibraryBook libraryBook)
{
Status = ProcessBookStatus.Working;
if (sender is Processable processable)
LogInfo($"{Environment.NewLine}{processable.Name} Step, Begin: {libraryBook.Book}");
Title = libraryBook.Book.TitleWithSubtitle;
Author = libraryBook.Book.AuthorNames;
Narrator = libraryBook.Book.NarratorNames;
}
private void Processable_StatusUpdate(object? sender, string statusUpdate)
=> LogInfo(statusUpdate);
private void Processable_Completed(object? sender, LibraryBook libraryBook)
{
if (sender is Processable processable)
LogInfo($"{processable.Name} Step, Completed: {libraryBook.Book}");
}
#endregion
#region Failure Handler
protected async Task<ProcessBookResult> GetFailureActionAsync(LibraryBook libraryBook)
{
const DialogResult SkipResult = DialogResult.Ignore;
LogError($"ERROR. All books have not been processed. Book failed: {libraryBook.Book}");
DialogResult dialogResult = Configuration.BadBook switch
{
Configuration.BadBookAction.Abort => DialogResult.Abort,
Configuration.BadBookAction.Retry => DialogResult.Retry,
Configuration.BadBookAction.Ignore => DialogResult.Ignore,
Configuration.BadBookAction.Ask or _ => await AskBadBookActionAsync(libraryBook)
};
if (dialogResult == SkipResult)
{
await libraryBook.UpdateBookStatusAsync(LiberatedStatus.Error);
LogInfo($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}");
}
return dialogResult is SkipResult ? ProcessBookResult.FailedSkip
: dialogResult is DialogResult.Abort ? ProcessBookResult.FailedAbort
: ProcessBookResult.FailedRetry;
}
/// <summary>
/// Asks the user what to do with a failed book, at most one dialog at a time. Without the gate,
/// three books failing together put three modals on screen racing to set the same session
/// override, and the user answers a question two of them no longer needed to ask.
/// </summary>
private async Task<DialogResult> AskBadBookActionAsync(LibraryBook libraryBook)
{
if (_badBookSession is null)
return await ShowRetryDialogAsync(libraryBook);
if (_badBookSession.Override is Configuration.BadBookAction alreadyAnswered)
return ToDialogResult(alreadyAnswered);
await _badBookSession.DialogGate.WaitAsync();
try
{
// Re-checked after the wait: the book ahead of us may have answered "apply to all".
if (_badBookSession.Override is Configuration.BadBookAction answeredWhileWaiting)
return ToDialogResult(answeredWhileWaiting);
return await ShowRetryDialogAsync(libraryBook);
}
finally
{
_badBookSession.DialogGate.Release();
}
}
protected async Task<DialogResult> ShowRetryDialogAsync(LibraryBook libraryBook)
{
string details;
try
{
static string trunc(string str)
=> string.IsNullOrWhiteSpace(str) ? "[empty]"
: (str.Length > 50) ? $"{str.Truncate(47)}..."
: str;
details = $"""
Title: {libraryBook.Book.TitleWithSubtitle}
ID: {libraryBook.Book.AudibleProductId}
Author: {trunc(libraryBook.Book.AuthorNames)}
Narr: {trunc(libraryBook.Book.NarratorNames)}
""";
}
catch
{
details = "[Error retrieving details]";
}
var skipDialogText = $"""
An error occurred while trying to process this book.
{details}
- ABORT: Stop processing books.
- RETRY: Skip this book for now, but retry if it is requeued. Continue processing the queued books.
- IGNORE: Permanently ignore this book. Continue processing the queued books. (Will not try this book again later.)
Check "Apply to all remaining books" to use your choice for the rest of this queue.
Check "Remember in Settings" to save your choice in Download/Decrypt settings.
""";
try
{
var result = await BadBookActionDialogBase.Show(skipDialogText, "Skip this book?");
// Abort is a statement about the run, not about this one book, so it becomes the session
// answer whether or not "apply to all" was checked. Without this a user who aborts with
// several books in flight is asked the same question again by every one of them, and the
// run they just stopped keeps prompting.
if (result.ApplyToAll || result.Action is DialogResult.Abort)
_badBookSession?.Override = ToBadBookAction(result.Action);
if (result.RememberInSettings)
Configuration.BadBook = ToBadBookAction(result.Action);
return result.Action;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error showing retry dialog. Defaulting to 'Retry'; action.");
return DialogResult.Retry;
}
}
private static DialogResult ToDialogResult(Configuration.BadBookAction action)
=> action switch
{
Configuration.BadBookAction.Abort => DialogResult.Abort,
Configuration.BadBookAction.Retry => DialogResult.Retry,
Configuration.BadBookAction.Ignore => DialogResult.Ignore,
_ => DialogResult.Retry
};
private static Configuration.BadBookAction ToBadBookAction(DialogResult action)
=> action switch
{
DialogResult.Abort => Configuration.BadBookAction.Abort,
DialogResult.Retry => Configuration.BadBookAction.Retry,
DialogResult.Ignore => Configuration.BadBookAction.Ignore,
_ => Configuration.BadBookAction.Retry
};
#endregion
}