Files
Libation/Source/LibationUiBase/ProcessQueue/BackupRequest.cs
T
Cursor Agentandrmcrackan f0d344099a fix(scan): keep a book's supplement in step with what the last scan says
Only a newly imported book ever recorded a supplement, so a title that gained a
PDF after its first import never got one, and a title that lost its PDF went on
claiming one - which in issue #1973 is why three titles Audible has no PDF for
were still being asked for.

Sync from updateBook as well, and give Book set-semantics for the one supplement
Audible reports per title. The duplicate guard compared the incoming url to
itself, so it happened to mean 'this book already has a supplement' and a url
that had changed was silently ignored.

A supplement is dropped only when Audible says outright that no supplement url is
available. A missing url says nothing by itself: episodes come from the catalog,
which is never asked for pdf_url, so there it means 'not asked'. A PDF already
downloaded is left alone either way, since the file is on disk and the library
should go on saying so.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 18:36:02 +00:00

145 lines
5.9 KiB
C#

using ApplicationServices;
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace LibationUiBase.ProcessQueue;
/// <summary>
/// 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.
/// </summary>
internal sealed class BackupRequest
{
public const string NothingQueuedCaption = "Download not queued";
/// <summary>Why a title cannot be queued. Reported in declaration order.</summary>
internal sealed record SkipReason(string Label, string Advice = "")
{
public static readonly SkipReason AlreadyDownloaded = new("Already downloaded");
public static readonly SkipReason PreviousError = new("Previously failed to download", "set the download status to 'Not Downloaded' to try again");
public static readonly SkipReason AbsentFromLastScan = new(AbsentFromLastScanUserMessage.Label, AbsentFromLastScanUserMessage.Advice);
public static readonly SkipReason WaitingToRetry = new("Waiting before trying again after a recent failure", "download the title on its own to try it now");
public static readonly SkipReason[] All = [AlreadyDownloaded, PreviousError, AbsentFromLastScan, WaitingToRetry];
}
/// <summary>The titles the caller asked to back up, including the ones that cannot be queued.</summary>
public int RequestedCount { get; }
public LibraryBook[] Queueable { get; }
public int SkippedCount => RequestedCount - Queueable.Length;
public int Skipped(SkipReason reason) => skipped.GetValueOrDefault(reason);
/// <summary>The titles left out because Libation is waiting before attempting them again.</summary>
public IReadOnlyList<DeferredDownload> Deferred { get; }
private readonly Dictionary<SkipReason, int> skipped;
private BackupRequest(int requestedCount, LibraryBook[] queueable, Dictionary<SkipReason, int> skipped, IReadOnlyList<DeferredDownload> deferred)
{
RequestedCount = requestedCount;
Queueable = queueable;
this.skipped = skipped;
Deferred = deferred;
}
/// <param name="deferrals">
/// The titles to leave alone for now. Pass <see cref="DownloadDeferrals.None"/> for a request the user
/// made about specific titles, which must always be attempted.
/// </param>
public static BackupRequest Create(IEnumerable<LibraryBook> libraryBooks, DownloadDeferrals? deferrals = null)
{
deferrals ??= DownloadDeferrals.None;
var requestedCount = 0;
var queueable = new List<LibraryBook>();
var skipped = new Dictionary<SkipReason, int>();
var deferred = new List<DeferredDownload>();
foreach (var libraryBook in libraryBooks)
{
requestedCount++;
// Asked before the wait, so that a title needing nothing is reported as already downloaded rather
// than as waiting on a record left over from when it did need something.
if (GetSkipReason(libraryBook) is SkipReason reason)
skipped[reason] = skipped.GetValueOrDefault(reason) + 1;
// Whatever the title needs is waited on. A PDF is fetched through the same license request as the
// audiobook, so a title needing only its PDF would be refused for that exactly as its audio was.
else if (deferrals.Find(libraryBook) is DeferredDownload waiting)
{
deferred.Add(waiting);
skipped[SkipReason.WaitingToRetry] = skipped.GetValueOrDefault(SkipReason.WaitingToRetry) + 1;
}
else
queueable.Add(libraryBook);
}
return new BackupRequest(requestedCount, [.. queueable], skipped, deferred);
}
/// <summary>Null when the title can be queued. Absent outranks status: Downloadable is false either way.</summary>
private static SkipReason? GetSkipReason(LibraryBook libraryBook)
=> libraryBook.NeedsBookDownload || libraryBook.NeedsPdfDownload ? null
: libraryBook.AbsentFromLastScan ? SkipReason.AbsentFromLastScan
: libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.Error ? SkipReason.PreviousError
: SkipReason.AlreadyDownloaded;
/// <summary>A compact breakdown for the log, eg: "already downloaded: 3, absent from your last library scan: 1".</summary>
public string BuildSkippedLogSummary()
=> SkippedCount == 0
? "none"
: string.Join(", ", Breakdown().Select(b => $"{b.Reason.Label.ToLowerInvariant()}: {b.Count}"));
/// <summary>The dialog body shown when a backup request produced nothing to queue.</summary>
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();
//the count comes before the advice so the numbers stay scannable
foreach (var (reason, count) in Breakdown())
sb.AppendLine($"{reason.Label}: {count}{(reason.Advice is "" ? "" : $" ({reason.Advice})")}");
if (Deferred.Count > 0)
{
sb.AppendLine();
sb.AppendLine(BuildDeferredDetail(DateTimeOffset.Now));
}
return sb.ToString().TrimEnd();
}
/// <summary>
/// What Audible said about the waited-on titles and when they come back. Without this the dialog reports a
/// wait with no way to find out its cause short of reading the log.
/// </summary>
public string BuildDeferredDetail(DateTimeOffset now)
{
var sb = new StringBuilder();
sb.AppendLine("Why Libation is waiting:");
foreach (var group in Deferred.GroupBy(d => d.Kind).OrderBy(g => g.Key))
{
sb.AppendLine($"- {group.First().KindLabel} ({"title".PluralizeWithCount(group.Count())}). "
+ $"Next attempt {DeferredDownloadUserMessage.DescribeWhen(group.Min(d => d.RetryAfter), now)}.");
}
return sb.ToString().TrimEnd();
}
private IEnumerable<(SkipReason Reason, int Count)> Breakdown()
=> SkipReason.All.Where(skipped.ContainsKey).Select(reason => (reason, skipped[reason]));
}