using ApplicationServices; using DataLayer; using Dinah.Core; using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace LibationUiBase.ProcessQueue; /// /// 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"; /// Why a title cannot be queued. Reported in declaration order. 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]; } /// The titles the caller asked to back up, including the ones that cannot be queued. public int RequestedCount { get; } public LibraryBook[] Queueable { get; } public int SkippedCount => RequestedCount - Queueable.Length; public int Skipped(SkipReason reason) => skipped.GetValueOrDefault(reason); /// The titles left out because Libation is waiting before attempting them again. public IReadOnlyList Deferred { get; } private readonly Dictionary skipped; private BackupRequest(int requestedCount, LibraryBook[] queueable, Dictionary skipped, IReadOnlyList deferred) { RequestedCount = requestedCount; Queueable = queueable; this.skipped = skipped; Deferred = deferred; } /// /// The titles to leave alone for now. Pass for a request the user /// made about specific titles, which must always be attempted. /// public static BackupRequest Create(IEnumerable libraryBooks, DownloadDeferrals? deferrals = null) { deferrals ??= DownloadDeferrals.None; var requestedCount = 0; var queueable = new List(); var skipped = new Dictionary(); var deferred = new List(); 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); } /// Null when the title can be queued. Absent outranks status: Downloadable is false either way. 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; /// A compact breakdown for the log, eg: "already downloaded: 3, absent from your last library scan: 1". public string BuildSkippedLogSummary() => SkippedCount == 0 ? "none" : string.Join(", ", Breakdown().Select(b => $"{b.Reason.Label.ToLowerInvariant()}: {b.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(); //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(); } /// /// 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. /// 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])); }