mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-13 14:17:15 -04:00
Reported in issue #1973: a scheduled liberate run re-requested a content license for the same 59 titles every 15 minutes, 1397 refused requests in six hours, because nothing about a failed PDF was remembered and the PDF step asked Audible afresh every time. The two download paths have been converging for a while - a PDF is now named and placed by the audiobook path's own logic, and verified like one - and every part of this bug lives where that stopped short. Both steps ask Audible for the same license. The request asks for pdf_url alongside the content reference, and LicenseInfo dropped it, so DownloadPdf turned round and requested an identical license to read the field the first response had already returned. Carry PdfUrl on LicenseInfo and give both steps one ILicensedDownload contract: a license may be supplied to a step, and the one a step ended up using is published for the next step for the same title. The CLI and the GUI queue hand it on, so a title costs one license request per run however many steps want something from it. A carried license is retried once with a fresh one if it does not work, since Audible's links are signed and a long decrypt can run between the two steps. Where the audiobook step obtained no license there is nothing to hand on and the supplement step does not run, which deletes a bug rather than guarding it: Completed fires from a finally, so a refused audio download was followed at once by a PDF request that reproduced the refusal. Error now means the same for a PDF as for a book. The audiobook step has always skipped LiberatedStatus.Error through AudioExists, and NeedsPdfDownload agrees, but DownloadPdf selected on PdfExists and so retried an errored PDF forever. A license that is granted and carries no pdf_url - the 'No PDF URL available' in the report - is Audible saying the title has no PDF, and is written off that same way instead of failing identically on every run. It stays resettable by everything that resets a book: --force, a named title, Set PDF Not Downloaded. Refusals now reach ProcessSingleAsync, which has always recorded them for whichever step throws one; DownloadPdf swallowed everything and recorded nothing. It keeps swallowing what the classifier does not recognise, which is what stopped a missing PDF from taking the app down with it. A bulk CLI run leaves alone the titles the last scan did not find, by the same Downloadable rule every multi-title path in the app already uses, and the PDF back-fill pass waits on a refused title just as the first pass does. --force and a named title still attempt everything. Co-authored-by: rmcrackan <rmcrackan@gmail.com>
192 lines
6.3 KiB
C#
192 lines
6.3 KiB
C#
using ApplicationServices;
|
|
using DataLayer;
|
|
using Dinah.Core;
|
|
using LibationFileManager;
|
|
using LibationUiBase.StatusIcons;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace LibationUiBase.GridView;
|
|
|
|
//This Class holds all book entry status info to help the grid properly render entries.
|
|
//The reason this info is in here instead of GridEntry is because all of this info is needed
|
|
//for the "Liberate" column's display and sorting functions.
|
|
public class EntryStatus : ReactiveObject, IComparable
|
|
{
|
|
public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book);
|
|
public LiberatedStatus BookStatus
|
|
{
|
|
get
|
|
{
|
|
if (IsSeries) return default;
|
|
|
|
if ((DateTime.Now - lastBookUpdate).TotalSeconds > 2)
|
|
{
|
|
//Cache the BookStatus so AudibleFileStorage.AaxcExists isn't
|
|
//called multiple times per book while sorting the solumn.
|
|
bookStatus = LibraryCommands.Liberated_Status(Book);
|
|
lastBookUpdate = DateTime.Now;
|
|
}
|
|
|
|
return bookStatus;
|
|
}
|
|
}
|
|
|
|
public bool Expanded
|
|
{
|
|
get => field;
|
|
set
|
|
{
|
|
if (value != field)
|
|
{
|
|
field = value;
|
|
Invalidate(nameof(Expanded), nameof(ButtonImage));
|
|
}
|
|
}
|
|
}
|
|
public bool IsSeries { get; }
|
|
public bool IsEpisode { get; }
|
|
public bool IsBook => !IsSeries && !IsEpisode;
|
|
public bool IsUnavailable
|
|
=> !IsSeries
|
|
&& isAbsent
|
|
&& (
|
|
BookStatus is not LiberatedStatus.Liberated
|
|
|| PdfStatus is not null and not LiberatedStatus.Liberated
|
|
);
|
|
public double Opacity => !IsSeries && Book.UserDefinedItem.Tags.ContainsInsensitive("hidden") ? 0.4 : 1;
|
|
public object? ButtonImage => GetAndCacheIcon(GetLiberateIconDescriptor());
|
|
public string ToolTip => GetTooltip();
|
|
private Book Book { get; }
|
|
|
|
private DateTime lastBookUpdate;
|
|
private LiberatedStatus bookStatus;
|
|
private readonly bool isAbsent;
|
|
private readonly bool isAudiblePlus;
|
|
private static readonly Dictionary<LiberateIconDescriptor, object?> iconCache = [];
|
|
|
|
internal EntryStatus(LibraryBook libraryBook)
|
|
{
|
|
Book = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)).Book;
|
|
isAbsent = libraryBook.AbsentFromLastScan is true;
|
|
isAudiblePlus = libraryBook.IsAudiblePlus;
|
|
IsEpisode = Book.ContentType is ContentType.Episode;
|
|
IsSeries = Book.ContentType is ContentType.Parent;
|
|
}
|
|
|
|
/// <summary>Refresh BookStatus (so partial download files are checked again in the filesystem) and raise PropertyChanged for property names.</summary>
|
|
public void Invalidate(params string[] properties)
|
|
{
|
|
lastBookUpdate = default;
|
|
foreach (var property in properties)
|
|
RaisePropertyChanged(property);
|
|
}
|
|
|
|
/// <summary> Defines the Liberate column's sorting behavior </summary>
|
|
public int CompareTo(object? obj)
|
|
{
|
|
if (obj is not EntryStatus second) return -1;
|
|
|
|
if (IsSeries && !second.IsSeries) return -1;
|
|
else if (!IsSeries && second.IsSeries) return 1;
|
|
else if (IsSeries && second.IsSeries) return 0;
|
|
else if (IsUnavailable && !second.IsUnavailable) return 1;
|
|
else if (!IsUnavailable && second.IsUnavailable) return -1;
|
|
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
|
|
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
|
|
|
|
var statusCompare = BookStatus.CompareTo(second.BookStatus);
|
|
if (statusCompare != 0) return statusCompare;
|
|
else if (PdfStatus is null && second.PdfStatus is null) return 0;
|
|
else if (PdfStatus is null) return 1;
|
|
else if (second.PdfStatus is null) return -1;
|
|
else return PdfStatus.Value.CompareTo(second.PdfStatus.Value);
|
|
}
|
|
|
|
private LiberateIconDescriptor GetLiberateIconDescriptor()
|
|
{
|
|
var isDark = BaseUtil.IsDarkMode();
|
|
|
|
if (IsSeries)
|
|
return LiberateIconDescriptor.ForSeries(Expanded, isDark);
|
|
|
|
if (BookStatus == LiberatedStatus.Error)
|
|
return LiberateIconDescriptor.ForError(isDark);
|
|
|
|
var lamp = BookStatus switch
|
|
{
|
|
LiberatedStatus.Liberated => StoplightLamp.Green,
|
|
LiberatedStatus.PartialDownload => StoplightLamp.Yellow,
|
|
LiberatedStatus.NotLiberated => StoplightLamp.Red,
|
|
_ => throw new Exception("Unexpected liberation state")
|
|
};
|
|
|
|
var pdf = PdfStatus switch
|
|
{
|
|
LiberatedStatus.Liberated => PdfOverlay.Downloaded,
|
|
LiberatedStatus.NotLiberated => PdfOverlay.NotDownloaded,
|
|
LiberatedStatus.Error => PdfOverlay.NotDownloaded,
|
|
null => PdfOverlay.None,
|
|
_ => throw new Exception("Unexpected PDF state")
|
|
};
|
|
|
|
return LiberateIconDescriptor.ForBook(lamp, pdf, isAudiblePlus, isDark);
|
|
}
|
|
|
|
private string GetTooltip()
|
|
{
|
|
if (IsSeries)
|
|
return Expanded ? "Click to Collapse" : "Click to Expand";
|
|
|
|
if (IsUnavailable)
|
|
return "This book cannot be downloaded\nbecause it wasn't found during\nthe most recent library scan";
|
|
|
|
if (BookStatus == LiberatedStatus.Error)
|
|
return "Book downloaded ERROR";
|
|
|
|
string libState = BookStatus switch
|
|
{
|
|
LiberatedStatus.Liberated => "Liberated",
|
|
LiberatedStatus.PartialDownload => "File has been at least\r\npartially downloaded",
|
|
LiberatedStatus.NotLiberated => "Book NOT downloaded",
|
|
_ => throw new Exception("Unexpected liberation state")
|
|
};
|
|
|
|
string pdfState = PdfStatus switch
|
|
{
|
|
LiberatedStatus.Liberated => "\r\nPDF downloaded",
|
|
LiberatedStatus.NotLiberated => "\r\nPDF NOT downloaded",
|
|
// Set when Audible granted a license carrying no PDF link, which is by far the likeliest way a
|
|
// title arrives here, and says more than "ERROR" about why Libation has stopped asking.
|
|
LiberatedStatus.Error => "\r\nPDF could not be downloaded\r\nand will not be tried again",
|
|
null => "",
|
|
_ => throw new Exception("Unexpected PDF state")
|
|
};
|
|
|
|
var plusState = isAudiblePlus ? "\r\nAudible Plus title" : "";
|
|
|
|
var mouseoverText = libState + pdfState + plusState;
|
|
|
|
if (BookStatus == LiberatedStatus.NotLiberated ||
|
|
BookStatus == LiberatedStatus.PartialDownload ||
|
|
PdfStatus == LiberatedStatus.NotLiberated)
|
|
mouseoverText += "\r\nClick to download";
|
|
|
|
return mouseoverText;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load the shared rendering of an icon into this UI framework's image format. There are only a
|
|
/// couple dozen icons, and grid entries are created on several threads, so cache them all.
|
|
/// </summary>
|
|
private static object? GetAndCacheIcon(LiberateIconDescriptor descriptor)
|
|
{
|
|
lock (iconCache)
|
|
{
|
|
if (!iconCache.TryGetValue(descriptor, out var icon))
|
|
iconCache[descriptor] = icon = BaseUtil.LoadImage(StatusImageGenerator.GetPng(descriptor), PictureSize.Native);
|
|
return icon;
|
|
}
|
|
}
|
|
}
|