mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
'Not Downloaded' reads as a claim about the file on disk, but the status it names is really an instruction about the future. Telling someone with a finished audiobook to set it 'Not Downloaded' asks them to assert something they know is false, which is why the question keeps coming up. 'Download Pending' says the same thing about intent without saying anything untrue about the file, and stands alone in a dropdown or a support reply where bare 'Pending' would not. The context menu carriers move from "Set Download status to 'X'" to "Mark as 'X'" so the word does not land twice in one breath, with the accelerator on P to stay clear of Downloaded's D. The persisted enum, the --not-downloaded CLI flag and the IsLiberated search tags are unchanged, so scripts and saved quick filters keep working. WinForms status combo boxes grow from 121 to 150px and the better-quality Mark button from 210 to 240px to fit the longer label. Docs carry 'previously "Not Downloaded"' on first mention, since years of Reddit and GitHub answers use the old name. Co-authored-by: rmcrackan <rmcrackan@gmail.com>
283 lines
11 KiB
C#
283 lines
11 KiB
C#
using ApplicationServices;
|
|
using DataLayer;
|
|
using Dinah.Core;
|
|
using Dinah.Core.ErrorHandling;
|
|
using Dinah.Core.Net.Http;
|
|
using FileManager;
|
|
using LibationFileManager;
|
|
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace FileLiberator;
|
|
|
|
public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDownload
|
|
{
|
|
public override string Name => "Download Pdf";
|
|
|
|
/// <summary>
|
|
/// A supplement is delivered through a content license, the same request the audiobook download makes, so
|
|
/// a refusal of one is a refusal of the other and belongs in the record that keeps a scheduled run from
|
|
/// asking again.
|
|
/// </summary>
|
|
protected override bool RecordsAttemptFailures => true;
|
|
|
|
/// <inheritdoc/>
|
|
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; }
|
|
|
|
/// <summary>
|
|
/// A title Audible has a supplement for, whose supplement is not downloaded and was not written off. The
|
|
/// status check matches <see cref="DataLayer.LibraryBookQueries.NeedsPdfDownload"/>, and treats
|
|
/// <see cref="LiberatedStatus.Error"/> as "don't retry" exactly as the audiobook download does through
|
|
/// <c>AudioExists</c>.
|
|
/// </summary>
|
|
public override bool Validate(LibraryBook libraryBook)
|
|
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
|
|
&& libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
|
|
|
|
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
|
{
|
|
OnBegin(libraryBook);
|
|
string? createdDirectory = null;
|
|
ObtainedLicense = null;
|
|
|
|
try
|
|
{
|
|
// A license this run already holds is used as it is. Requesting another would repeat the request
|
|
// the audiobook download just made, which is what filled the log in issue #1973.
|
|
var carriedLicense = LicenseInfo;
|
|
var license = carriedLicense ?? await requestLicenseAsync(libraryBook);
|
|
ObtainedLicense = license;
|
|
|
|
if (license.PdfUrl is not string downloadUrl)
|
|
return await noSupplementAvailableAsync(libraryBook);
|
|
|
|
var proposedDownloadFilePath = GetProposedDownloadFilePath(libraryBook);
|
|
createdDirectory = createDirectoryFor(proposedDownloadFilePath);
|
|
|
|
var result = await downloadAndVerifyAsync(libraryBook, downloadUrl, proposedDownloadFilePath);
|
|
|
|
// Audible's download links are signed and expire, and a carried license was granted before an
|
|
// audiobook download that may have taken hours. One fresh license is worth trying before the
|
|
// title is reported as failed; a license requested moments ago would only fail the same way.
|
|
if (!result.IsSuccess && carriedLicense is not null)
|
|
{
|
|
Serilog.Log.Logger.Information(
|
|
"Requesting a new license to fetch the supplement of {libraryBook}: the one from its audiobook download did not work.",
|
|
libraryBook.LogFriendly());
|
|
|
|
license = await requestLicenseAsync(libraryBook);
|
|
ObtainedLicense = license;
|
|
|
|
if (license.PdfUrl is not string freshDownloadUrl)
|
|
return await noSupplementAvailableAsync(libraryBook);
|
|
|
|
result = await downloadAndVerifyAsync(libraryBook, freshDownloadUrl, proposedDownloadFilePath);
|
|
}
|
|
|
|
await libraryBook.UpdatePdfStatusAsync(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
|
|
|
return result;
|
|
}
|
|
// Refusals are left to reach ProcessSingleAsync, which records them for every step alike. Everything
|
|
// else is still swallowed here, which is what stopped a missing PDF from taking the app down with it.
|
|
catch (Exception ex) when (DownloadFailureClassifier.Classify(ex) is null)
|
|
{
|
|
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
|
|
|
var result = new StatusHandler();
|
|
result.AddError($"Error downloading PDF. See log for details. Error summary: {ex.Message}");
|
|
|
|
return result;
|
|
}
|
|
finally
|
|
{
|
|
removeIfLeftEmpty(createdDirectory);
|
|
OnCompleted(libraryBook);
|
|
}
|
|
}
|
|
|
|
private async Task<DownloadOptions.LicenseInfo> requestLicenseAsync(LibraryBook libraryBook)
|
|
{
|
|
var api = await libraryBook.GetApiAsync();
|
|
return await DownloadOptions.GetSupplementLicenseAsync(api, libraryBook, Configuration);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Audible granted a license and it carried no supplement link, which is Audible saying this title has no
|
|
/// PDF to give. Written off the way the audiobook download writes off a title it should not attempt again:
|
|
/// <see cref="LiberatedStatus.Error"/> means "don't retry" for a supplement exactly as it does for audio,
|
|
/// and the same controls - a forced or named <c>liberate</c> run, Mark PDF as Download Pending - set it back.
|
|
/// <para>
|
|
/// Reported as a completed step and not as a failure. Nothing went wrong and nothing is left to attempt, and
|
|
/// a failure here would put the queue's Abort / Retry / Ignore question to the user about a book whose audio
|
|
/// is fine.
|
|
/// </para>
|
|
/// </summary>
|
|
private async Task<StatusHandler> noSupplementAvailableAsync(LibraryBook libraryBook)
|
|
{
|
|
const string explanation = "Audible has no PDF for this title, although the library listing says it has one. "
|
|
+ "Its PDF will not be asked for again; mark the PDF 'Download Pending' to try once more.";
|
|
|
|
Serilog.Log.Logger.Information(
|
|
"Audible granted a license for {libraryBook} that carried no supplement link, so it has no PDF to download. {explanation}",
|
|
libraryBook.LogFriendly(),
|
|
explanation);
|
|
|
|
OnStatusUpdate(explanation);
|
|
await libraryBook.UpdatePdfStatusAsync(LiberatedStatus.Error);
|
|
|
|
return new StatusHandler();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches the supplement and keeps it only if what came back is one. A transport failure is reported as a
|
|
/// failed status rather than thrown, so that a license worth retrying still gets retried.
|
|
/// </summary>
|
|
private async Task<StatusHandler> downloadAndVerifyAsync(LibraryBook libraryBook, string downloadUrl, string proposedDownloadFilePath)
|
|
{
|
|
string actualDownloadedFilePath;
|
|
try
|
|
{
|
|
actualDownloadedFilePath = await downloadPdfAsync(downloadUrl, proposedDownloadFilePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
|
return new StatusHandler { $"Error downloading PDF. See log for details. Error summary: {ex.Message}" };
|
|
}
|
|
|
|
var result = verifyDownload(actualDownloadedFilePath);
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
|
SetFileTime(libraryBook, actualDownloadedFilePath);
|
|
if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir)
|
|
SetDirectoryTime(libraryBook, outputDir);
|
|
}
|
|
else
|
|
{
|
|
// Keeping it would leave a file in the library that is not the supplement, under a name that
|
|
// says it is, and would stop the folder cleanup in ProcessAsync from running.
|
|
FileUtility.SaferDelete(actualDownloadedFilePath);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>The directory this run had to create, or null when it was already there.</summary>
|
|
private static string? createDirectoryFor(string filePath)
|
|
{
|
|
if (Path.GetDirectoryName(filePath) is not string directory || Directory.Exists(directory))
|
|
return null;
|
|
|
|
Directory.CreateDirectory(directory);
|
|
return directory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A PDF-only download is the one case that has to make the book's folder before it has anything to put
|
|
/// in it. Without this, every failed download would leave an empty folder in the library.
|
|
/// </summary>
|
|
private static void removeIfLeftEmpty(string? directory)
|
|
{
|
|
try
|
|
{
|
|
if (directory is not null && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
|
Directory.Delete(directory);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Serilog.Log.Logger.Debug(ex, "Could not remove the empty folder left by a failed PDF download: {directory}", directory);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Beside the book's audio files, in the folder the naming templates put that book in. The directory may
|
|
/// not exist yet; see <see cref="createDirectoryFor"/>.
|
|
/// <para>
|
|
/// The audio file is looked up first so a PDF joins the files already on disk even if they were named by
|
|
/// an older template or moved by hand. That lookup matches on the product id appearing in the path, so it
|
|
/// finds nothing for a library whose folder and file templates omit <c><id></c>, and nothing for a
|
|
/// book marked downloaded whose files are not on this machine. Falling back to the folder template rather
|
|
/// than to the Books directory itself keeps those PDFs with their book instead of loose in the library
|
|
/// root, where they also risk colliding with each other.
|
|
/// </para>
|
|
/// </summary>
|
|
internal string GetProposedDownloadFilePath(LibraryBook libraryBook)
|
|
{
|
|
var extension = Path.GetExtension(getdownloadUrl(libraryBook)) ?? ".pdf";
|
|
|
|
var destinationDir
|
|
= Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId))
|
|
?? AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration);
|
|
|
|
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension);
|
|
}
|
|
|
|
private static string? getdownloadUrl(LibraryBook libraryBook)
|
|
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
|
|
|
|
private async Task<string> downloadPdfAsync(string downloadUrl, string proposedDownloadFilePath)
|
|
{
|
|
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
|
|
|
var client = new HttpClient();
|
|
|
|
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
|
|
|
OnStatusUpdate(actualDownloadedFilePath);
|
|
return actualDownloadedFilePath;
|
|
}
|
|
|
|
/// <summary>
|
|
/// That the file exists says only that the server sent a body, and an Audible error is a 200 with a JSON
|
|
/// body like any other response. Dinah's downloader renames by Content-Disposition, so such a body lands
|
|
/// in the book's folder under whatever Audible called it and the title is recorded as having its PDF.
|
|
/// <para>
|
|
/// A supplement is whatever its URL says it is, usually a PDF and occasionally an archive, so only a file
|
|
/// named .pdf is held to the PDF header. Everything else is checked for the one thing no supplement of
|
|
/// any kind starts with: the opening character of a JSON or markup document.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static StatusHandler verifyDownload(string actualDownloadedFilePath)
|
|
{
|
|
if (!File.Exists(actualDownloadedFilePath))
|
|
return new StatusHandler { "Downloaded PDF cannot be found" };
|
|
|
|
var firstBytes = readFirstBytes(actualDownloadedFilePath);
|
|
|
|
if (firstBytes.Length == 0)
|
|
return new StatusHandler { "Downloaded PDF is empty" };
|
|
|
|
if (Path.GetExtension(actualDownloadedFilePath).EqualsInsensitive(".pdf")
|
|
&& !firstBytes.AsSpan().StartsWith("%PDF"u8))
|
|
return new StatusHandler { "What Audible returned is not a PDF. Nothing was saved; the PDF is still marked as not downloaded." };
|
|
|
|
if (firstBytes[0] is (byte)'{' or (byte)'[' or (byte)'<')
|
|
return new StatusHandler { "Audible returned a document instead of the supplement. Nothing was saved; the PDF is still marked as not downloaded." };
|
|
|
|
return new StatusHandler();
|
|
}
|
|
|
|
/// <summary>Enough to recognise a file signature, and past a byte-order mark if the body carries one.</summary>
|
|
private static byte[] readFirstBytes(string path)
|
|
{
|
|
var buffer = new byte[8];
|
|
|
|
using var stream = File.OpenRead(path);
|
|
var read = buffer[..stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false)];
|
|
|
|
return read.AsSpan().StartsWith("\uFEFF"u8) ? read[3..] : read;
|
|
}
|
|
|
|
public static DownloadPdf Create(Configuration config) => new() { Configuration = config };
|
|
private DownloadPdf() { }
|
|
}
|