From f806af96322e4b3ce44406764cf486a5837388cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 19:52:04 +0000 Subject: [PATCH] fix(queue): explain a multi-book download that queues nothing The multi-book branch of QueueDownloadDecryptAsync returned false with no log entry and no message whenever UnLiberated() came back empty, so a request Libation understood and declined looked exactly like a dead button. Callers that pre-filter with UnLiberated() land here with an empty list, which is the common way to hit it. Classify each title that cannot be queued and report the breakdown: already downloaded, previously failed, absent from the last scan, or a series parent with no audio of its own. Log it either way; show it only when a person is waiting, so the automatic post-scan download stays silent. Fixes #1940 Co-authored-by: rmcrackan --- .../ViewModels/MainVM.BackupCounts.cs | 2 +- .../ViewModels/MainVM.Liberate.cs | 5 +- .../ProcessQueue/BackupRequest.cs | 126 ++++++++++++++++++ .../ProcessQueue/ProcessQueueViewModel.cs | 50 +++++-- Source/LibationWinForms/Form1.Liberate.cs | 5 +- Source/LibationWinForms/Form1._NonUI.cs | 2 +- 6 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 Source/LibationUiBase/ProcessQueue/BackupRequest.cs diff --git a/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs b/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs index 7aa38750..6a681b8e 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs @@ -90,7 +90,7 @@ partial class MainVM && stats.PendingBooks + stats.pdfsNotDownloaded > 0) { // RunWorkerCompleted has no SynchronizationContext; queue items require the UI thread. - await Dispatcher.UIThread.InvokeAsync(async () => await BackupAllBooksAsync(stats.LibraryBooks)); + await Dispatcher.UIThread.InvokeAsync(async () => await BackupAllBooksAsync(stats.LibraryBooks, notifyIfNothingQueued: false)); } } } diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs index 5eb2d567..536e1174 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs @@ -21,13 +21,14 @@ partial class MainVM await BackupAllBooksAsync(books); } - private async Task BackupAllBooksAsync(IEnumerable books) + /// False for the automatic post-scan download, which runs unattended. + private async Task BackupAllBooksAsync(IEnumerable books, bool notifyIfNothingQueued = true) { try { var unliberated = books.UnLiberated().ToArray(); - if (await ProcessQueue.QueueDownloadDecryptAsync(unliberated)) + if (await ProcessQueue.QueueDownloadDecryptAsync(unliberated, notifyIfNothingQueued: notifyIfNothingQueued)) setQueueCollapseState(false); } catch (Exception ex) diff --git a/Source/LibationUiBase/ProcessQueue/BackupRequest.cs b/Source/LibationUiBase/ProcessQueue/BackupRequest.cs new file mode 100644 index 00000000..f9fb19a5 --- /dev/null +++ b/Source/LibationUiBase/ProcessQueue/BackupRequest.cs @@ -0,0 +1,126 @@ +using DataLayer; +using Dinah.Core; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace LibationUiBase.ProcessQueue; + +/// Why a title in a backup request cannot be queued for download. +internal enum BackupSkipReason +{ + AlreadyDownloaded, + PreviousError, + AbsentFromLastScan, + NoAudioOfItsOwn +} + +/// +/// Splits a multi-book backup request into the titles that can be queued and the reason each of the rest +/// was left out, so a request Libation understands and declines can be explained instead of ignored. +/// +internal sealed class BackupRequest +{ + public const string NothingQueuedCaption = "Download not queued"; + + /// Reasons are reported in this order, which runs from the most to the least common. + private static readonly BackupSkipReason[] ReasonOrder = + [ + BackupSkipReason.AlreadyDownloaded, + BackupSkipReason.PreviousError, + BackupSkipReason.AbsentFromLastScan, + BackupSkipReason.NoAudioOfItsOwn + ]; + + /// The titles the caller asked to back up, including the ones that cannot be queued. + public int RequestedCount { get; } + public LibraryBook[] Queueable { get; } + public IReadOnlyDictionary SkippedByReason { get; } + public int SkippedCount => RequestedCount - Queueable.Length; + + private BackupRequest(int requestedCount, LibraryBook[] queueable, IReadOnlyDictionary skippedByReason) + { + RequestedCount = requestedCount; + Queueable = queueable; + SkippedByReason = skippedByReason; + } + + public static BackupRequest Create(IEnumerable libraryBooks) + { + var requestedCount = 0; + var queueable = new List(); + var skipped = new Dictionary(); + + foreach (var libraryBook in libraryBooks) + { + requestedCount++; + + if (GetSkipReason(libraryBook) is not BackupSkipReason reason) + queueable.Add(libraryBook); + else + skipped[reason] = skipped.GetValueOrDefault(reason) + 1; + } + + return new BackupRequest(requestedCount, [.. queueable], skipped); + } + + /// + /// Null when the title can be queued. The order of the checks mirrors LibraryBook.Downloadable: a title + /// absent from the last scan is reported as such no matter what its download status says. + /// + private static BackupSkipReason? GetSkipReason(LibraryBook libraryBook) + => libraryBook.NeedsBookDownload || libraryBook.NeedsPdfDownload ? null + : libraryBook.AbsentFromLastScan ? BackupSkipReason.AbsentFromLastScan + : libraryBook.Book.ContentType is not (ContentType.Product or ContentType.Episode) ? BackupSkipReason.NoAudioOfItsOwn + : libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.Error ? BackupSkipReason.PreviousError + : BackupSkipReason.AlreadyDownloaded; + + /// A compact breakdown for the log, eg: "already downloaded: 3, absent from last scan: 1". + public string BuildSkippedLogSummary() + => SkippedCount == 0 + ? "none" + : string.Join(", ", OrderedReasons().Select(r => $"{LogName(r.Reason)}: {r.Count}")); + + /// The dialog body shown when a backup request produced nothing to queue. + public string BuildNothingQueuedBody() + { + if (RequestedCount == 0) + return """ + Libation found no titles that need downloading. + + Titles that are already downloaded, and titles that were absent from your last library scan, are not queued. + """; + + var sb = new StringBuilder(); + sb.AppendLine($"None of the {"title".PluralizeWithCount(RequestedCount)} could be queued for download."); + sb.AppendLine(); + + foreach (var (reason, count) in OrderedReasons()) + sb.AppendLine($"{Describe(reason)}: {count}"); + + return sb.ToString().TrimEnd(); + } + + private IEnumerable<(BackupSkipReason Reason, int Count)> OrderedReasons() + => ReasonOrder + .Where(SkippedByReason.ContainsKey) + .Select(reason => (reason, SkippedByReason[reason])); + + private static string LogName(BackupSkipReason reason) + => reason switch + { + BackupSkipReason.AlreadyDownloaded => "already downloaded", + BackupSkipReason.PreviousError => "previous error", + BackupSkipReason.AbsentFromLastScan => "absent from last scan", + _ => "no audio of its own" + }; + + private static string Describe(BackupSkipReason reason) + => reason switch + { + BackupSkipReason.AlreadyDownloaded => "Already downloaded", + BackupSkipReason.PreviousError => "Previously failed to download (set the download status to 'Not Downloaded' to try again)", + BackupSkipReason.AbsentFromLastScan => "Absent from your last library scan (run Scan, or `libationcli scan`, then try again)", + _ => "Series or podcast parent, which has no audio of its own" + }; +} diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs index a560337e..52a65e1d 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs @@ -186,7 +186,11 @@ public class ProcessQueueViewModel : ReactiveObject AddToQueue(procs); } - public async Task QueueDownloadDecryptAsync(IList libraryBooks, Configuration? config = null) + /// + /// Whether to tell the user when a multi-book request queued nothing. Always logged either way. Automated + /// callers (auto-download after a scan) pass false so a routine no-op cannot put a dialog on screen. + /// + public async Task QueueDownloadDecryptAsync(IList libraryBooks, Configuration? config = null, bool notifyIfNothingQueued = true) { config ??= Configuration.Instance; if (!await IsBooksDirectoryValidAsync(config)) @@ -237,20 +241,42 @@ public class ProcessQueueViewModel : ReactiveObject } else { - var toLiberate = libraryBooks.UnLiberated().ToArray(); + var request = BackupRequest.Create(libraryBooks); - if (toLiberate.Length > 0) + if (request.Queueable.Length == 0) { - // May no-op when free space is unknown (common on UNC); see DiskSpaceBackupPreflight. - if (!await DiskSpaceBackupPreflight.ConfirmBulkBackupAsync(toLiberate.Length, config, backupQueueAlreadyRunning: Running)) - return false; + // This branch used to return with no log entry and no message, so a request Libation had + // understood and declined was indistinguishable from a dead button. + Serilog.Log.Logger.Information( + "Download not queued: none of the {requested} requested titles need downloading. Skipped: {skipped}", + request.RequestedCount, + request.BuildSkippedLogSummary()); - Serilog.Log.Logger.Information("Begin backup of {count} library books", toLiberate.Length); - AddDownloadDecrypt(toLiberate, config); - return true; + if (notifyIfNothingQueued) + await MessageBoxBase.Show( + request.BuildNothingQueuedBody(), + BackupRequest.NothingQueuedCaption, + MessageBoxButtons.OK, + MessageBoxIcon.Information); + + return false; } + + if (request.SkippedCount > 0) + Serilog.Log.Logger.Information( + "Skipping {skippedCount} of {requested} requested titles. Skipped: {skipped}", + request.SkippedCount, + request.RequestedCount, + request.BuildSkippedLogSummary()); + + // May no-op when free space is unknown (common on UNC); see DiskSpaceBackupPreflight. + if (!await DiskSpaceBackupPreflight.ConfirmBulkBackupAsync(request.Queueable.Length, config, backupQueueAlreadyRunning: Running)) + return false; + + Serilog.Log.Logger.Information("Begin backup of {count} library books", request.Queueable.Length); + AddDownloadDecrypt(request.Queueable, config); + return true; } - return false; } private async Task IsBooksDirectoryValidAsync(Configuration config) @@ -334,7 +360,9 @@ public class ProcessQueueViewModel : ReactiveObject private void addDownloadDecryptCore(IList entries, Configuration config) { var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray(); - Serilog.Log.Logger.Information("Queueing {count} books ofr download/decrypt", procs.Length); + Serilog.Log.Logger.Information("Queueing {count} books for download/decrypt", procs.Length); + if (procs.Length < entries.Count) + Serilog.Log.Logger.Information("{count} of the requested books are already in the queue and were not added again", entries.Count - procs.Length); AddToQueue(procs); ProcessBookViewModel Create(LibraryBook entry) diff --git a/Source/LibationWinForms/Form1.Liberate.cs b/Source/LibationWinForms/Form1.Liberate.cs index 2b1c6751..9903c79a 100644 --- a/Source/LibationWinForms/Form1.Liberate.cs +++ b/Source/LibationWinForms/Form1.Liberate.cs @@ -19,12 +19,13 @@ public partial class Form1 await BackupAllBooksAsync(library); } - private async Task BackupAllBooksAsync(IEnumerable books) + /// False for the automatic post-scan download, which runs unattended. + private async Task BackupAllBooksAsync(IEnumerable books, bool notifyIfNothingQueued = true) { try { var unliberated = books.UnLiberated().ToArray(); - if (await processBookQueue1.ViewModel.QueueDownloadDecryptAsync(unliberated)) + if (await processBookQueue1.ViewModel.QueueDownloadDecryptAsync(unliberated, notifyIfNothingQueued: notifyIfNothingQueued)) SetQueueCollapseState(false); } catch (Exception ex) diff --git a/Source/LibationWinForms/Form1._NonUI.cs b/Source/LibationWinForms/Form1._NonUI.cs index dc5d9876..38bd6e72 100644 --- a/Source/LibationWinForms/Form1._NonUI.cs +++ b/Source/LibationWinForms/Form1._NonUI.cs @@ -44,7 +44,7 @@ public partial class Form1 return; // RunWorkerCompleted has no SynchronizationContext; queue items require the UI thread. - this.UIThreadAsync(() => _ = BackupAllBooksAsync(libraryStats.LibraryBooks)); + this.UIThreadAsync(() => _ = BackupAllBooksAsync(libraryStats.LibraryBooks, notifyIfNothingQueued: false)); } private void AudibleApiStorage_LoadError(object? sender, AccountSettingsLoadErrorEventArgs e)