When there's a problem downloading a book, you get the option to skip the file temporarily or permanently. This can be useful with extremely old audible titles where the modern download may no longer be supported

This commit is contained in:
Robert McRackan 2020-12-21 16:25:42 -05:00
parent 642a500f87
commit d25c32ff45
5 changed files with 198 additions and 111 deletions

View File

@ -137,7 +137,7 @@ namespace FileLiberator
// create final directory. move each file into it. MOVE AUDIO FILE LAST // create final directory. move each file into it. MOVE AUDIO FILE LAST
// new dir: safetitle_limit50char + " [" + productId + "]" // new dir: safetitle_limit50char + " [" + productId + "]"
var destinationDir = getDestDir(product); var destinationDir = AudibleFileStorage.Audio.GetDestDir(product.Title, product.AudibleProductId);
Directory.CreateDirectory(destinationDir); Directory.CreateDirectory(destinationDir);
var sortedFiles = getProductFilesSorted(product, outputAudioFilename); var sortedFiles = getProductFilesSorted(product, outputAudioFilename);
@ -158,18 +158,6 @@ namespace FileLiberator
return destinationDir; return destinationDir;
} }
private static string getDestDir(Book product)
{
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
var underscoreIndex = product.Title.IndexOf(':');
var titleDir
= underscoreIndex < 4
? product.Title
: product.Title.Substring(0, underscoreIndex);
var finalDir = FileUtility.GetValidFilename(AudibleFileStorage.BooksDirectory, titleDir, null, product.AudibleProductId);
return finalDir;
}
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename) private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
{ {
// files are: temp path\author\[asin].ext // files are: temp path\author\[asin].ext

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ApplicationServices; using ApplicationServices;
@ -16,38 +17,37 @@ namespace FileLiberator
// //
/// <summary>Process the first valid product. Create default context</summary> // when used in foreach: stateful. deferred execution
/// <returns>Returns either the status handler from the process, or null if all books have been processed</returns> public static IEnumerable<LibraryBook> GetValidLibraryBooks(this IProcessable processable)
public static async Task<StatusHandler> ProcessFirstValidAsync(this IProcessable processable) => DbContexts.GetContext()
{ .GetLibrary_Flat_NoTracking()
var libraryBook = processable.getNextValidBook(); .Where(libraryBook => processable.Validate(libraryBook));
if (libraryBook == null)
return null;
return await processBookAsync(processable, libraryBook); public static LibraryBook GetSingleLibraryBook(string productId)
}
/// <summary>Process the first valid product. Create default context</summary>
/// <returns>Returns either the status handler from the process, or null if all books have been processed</returns>
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, string productId)
{ {
using var context = DbContexts.GetContext(); using var context = DbContexts.GetContext();
var libraryBook = context var libraryBook = context
.Library .Library
.GetLibrary() .GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId); .SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
return libraryBook;
}
/// <summary>Process the first valid product. Create default context</summary>
/// <returns>Returns either the status handler from the process, or null if all books have been processed</returns>
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, LibraryBook libraryBook)
{
if (libraryBook == null) if (libraryBook == null)
return null; return null;
if (!processable.Validate(libraryBook)) if (!processable.Validate(libraryBook))
return new StatusHandler { "Validation failed" }; return new StatusHandler { "Validation failed" };
return await processBookAsync(processable, libraryBook); return await processable.ProcessBookAsync_NoValidation(libraryBook);
} }
private static async Task<StatusHandler> processBookAsync(IProcessable processable, LibraryBook libraryBook) public static async Task<StatusHandler> ProcessBookAsync_NoValidation(this IProcessable processable, LibraryBook libraryBook)
{ {
Serilog.Log.Logger.Information("Begin " + nameof(processBookAsync) + " {@DebugInfo}", new Serilog.Log.Logger.Information("Begin " + nameof(ProcessBookAsync_NoValidation) + " {@DebugInfo}", new
{ {
libraryBook.Book.Title, libraryBook.Book.Title,
libraryBook.Book.AudibleProductId, libraryBook.Book.AudibleProductId,
@ -63,17 +63,6 @@ namespace FileLiberator
return status; return status;
} }
private static LibraryBook getNextValidBook(this IProcessable processable)
{
var libraryBooks = DbContexts.GetContext().GetLibrary_Flat_NoTracking();
foreach (var libraryBook in libraryBooks)
if (processable.Validate(libraryBook))
return libraryBook;
return null;
}
public static async Task<StatusHandler> TryProcessAsync(this IProcessable processable, LibraryBook libraryBook) public static async Task<StatusHandler> TryProcessAsync(this IProcessable processable, LibraryBook libraryBook)
=> processable.Validate(libraryBook) => processable.Validate(libraryBook)
? await processable.ProcessAsync(libraryBook) ? await processable.ProcessAsync(libraryBook)

View File

@ -11,76 +11,77 @@ namespace FileManager
// could add images here, but for now images are stored in a well-known location // could add images here, but for now images are stored in a well-known location
public enum FileType { Unknown, Audio, AAX, PDF } public enum FileType { Unknown, Audio, AAX, PDF }
/// <summary> /// <summary>
/// Files are large. File contents are never read by app. /// Files are large. File contents are never read by app.
/// Paths are varied. /// Paths are varied.
/// Files are written during download/decrypt/backup/liberate. /// Files are written during download/decrypt/backup/liberate.
/// Paths are read at app launch and during download/decrypt/backup/liberate. /// Paths are read at app launch and during download/decrypt/backup/liberate.
/// Many files are often looked up at once /// Many files are often looked up at once
/// </summary> /// </summary>
public sealed class AudibleFileStorage : Enumeration<AudibleFileStorage> public abstract class AudibleFileStorage : Enumeration<AudibleFileStorage>
{ {
#region static public abstract string[] Extensions { get; }
public static AudibleFileStorage Audio { get; } public abstract string StorageDirectory { get; }
public static AudibleFileStorage AAX { get; }
public static AudibleFileStorage PDF { get; }
public static string DownloadsInProgress { get; } #region static
public static string DecryptInProgress { get; } public static AudioFileStorage Audio { get; } = new AudioFileStorage();
public static string BooksDirectory => Configuration.Instance.Books; public static AudibleFileStorage AAX { get; } = new AaxFileStorage();
// not customizable. don't move to config public static AudibleFileStorage PDF { get; } = new PdfFileStorage();
public static string DownloadsFinal { get; }
= new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("DownloadsFinal").FullName;
static AudibleFileStorage() public static string DownloadsInProgress
{ {
#region init DecryptInProgress get
if (!Configuration.Instance.DecryptInProgressEnum.In("WinTemp", "LibationFiles")) {
Configuration.Instance.DecryptInProgressEnum = "WinTemp"; if (!Configuration.Instance.DownloadsInProgressEnum.In("WinTemp", "LibationFiles"))
var M4bRootDir Configuration.Instance.DownloadsInProgressEnum = "WinTemp";
= Configuration.Instance.DecryptInProgressEnum == "WinTemp" // else "LibationFiles" var AaxRootDir
? Configuration.WinTemp = Configuration.Instance.DownloadsInProgressEnum == "WinTemp"
: Configuration.Instance.LibationFiles; ? Configuration.WinTemp
DecryptInProgress = Path.Combine(M4bRootDir, "DecryptInProgress"); : Configuration.Instance.LibationFiles;
Directory.CreateDirectory(DecryptInProgress);
#endregion
#region init DownloadsInProgress return Directory.CreateDirectory(Path.Combine(AaxRootDir, "DownloadsInProgress")).FullName;
if (!Configuration.Instance.DownloadsInProgressEnum.In("WinTemp", "LibationFiles")) }
Configuration.Instance.DownloadsInProgressEnum = "WinTemp"; }
var AaxRootDir
= Configuration.Instance.DownloadsInProgressEnum == "WinTemp" // else "LibationFiles"
? Configuration.WinTemp
: Configuration.Instance.LibationFiles;
DownloadsInProgress = Path.Combine(AaxRootDir, "DownloadsInProgress");
Directory.CreateDirectory(DownloadsInProgress);
#endregion
#region init BooksDirectory public static string DecryptInProgress
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books)) {
Configuration.Instance.Books = Path.Combine(Configuration.Instance.LibationFiles, "Books"); get
Directory.CreateDirectory(Configuration.Instance.Books); {
#endregion if (!Configuration.Instance.DecryptInProgressEnum.In("WinTemp", "LibationFiles"))
Configuration.Instance.DecryptInProgressEnum = "WinTemp";
// must do this in static ctor, not w/inline properties var M4bRootDir
// static properties init before static ctor so these dir.s would still be null = Configuration.Instance.DecryptInProgressEnum == "WinTemp"
Audio = new AudibleFileStorage(FileType.Audio, BooksDirectory, "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac"); ? Configuration.WinTemp
AAX = new AudibleFileStorage(FileType.AAX, DownloadsFinal, "aax"); : Configuration.Instance.LibationFiles;
PDF = new AudibleFileStorage(FileType.PDF, BooksDirectory, "pdf", "zip");
return Directory.CreateDirectory(Path.Combine(M4bRootDir, "DecryptInProgress")).FullName;
}
}
// not customizable. don't move to config
public static string DownloadsFinal => new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("DownloadsFinal").FullName;
public static string BooksDirectory
{
get
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Path.Combine(Configuration.Instance.LibationFiles, "Books");
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
}
} }
#endregion #endregion
#region instance #region instance
public FileType FileType => (FileType)Value; public FileType FileType => (FileType)Value;
public string StorageDirectory => DisplayName;
private IEnumerable<string> extensions_noDots { get; } private IEnumerable<string> extensions_noDots { get; }
private string extAggr { get; } private string extAggr { get; }
private AudibleFileStorage(FileType fileType, string storageDirectory, params string[] extensions) : base((int)fileType, storageDirectory) protected AudibleFileStorage(FileType fileType) : base((int)fileType, fileType.ToString())
{ {
extensions_noDots = extensions.Select(ext => ext.Trim('.')).ToList(); extensions_noDots = Extensions.Select(ext => ext.Trim('.')).ToList();
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}"); extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
} }
@ -90,30 +91,84 @@ namespace FileManager
/// - a directory name has the product id and an audio file is immediately inside /// - a directory name has the product id and an audio file is immediately inside
/// - any audio filename contains the product id /// - any audio filename contains the product id
/// </summary> /// </summary>
public bool Exists(string productId) public bool Exists(string productId) => GetPath(productId) != null;
=> GetPath(productId) != null;
public string GetPath(string productId) public string GetPath(string productId)
{ {
{ var cachedFile = FilePathCache.GetPath(productId, FileType);
var cachedFile = FilePathCache.GetPath(productId, FileType); if (cachedFile != null)
if (cachedFile != null) return cachedFile;
return cachedFile;
}
var firstOrNull = var firstOrNull =
Directory Directory
.EnumerateFiles(StorageDirectory, "*.*", SearchOption.AllDirectories) .EnumerateFiles(StorageDirectory, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => Regex.IsMatch(s, $@"{productId}.*?\.({extAggr})$", RegexOptions.IgnoreCase)); .FirstOrDefault(s => Regex.IsMatch(s, $@"{productId}.*?\.({extAggr})$", RegexOptions.IgnoreCase));
if (firstOrNull is null) if (firstOrNull is null)
return null; return null;
FilePathCache.Upsert(productId, FileType, firstOrNull); FilePathCache.Upsert(productId, FileType, firstOrNull);
return firstOrNull; return firstOrNull;
} }
public string GetDestDir(string title, string asin)
{
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
var underscoreIndex = title.IndexOf(':');
var titleDir
= underscoreIndex < 4
? title
: title.Substring(0, underscoreIndex);
var finalDir = FileUtility.GetValidFilename(StorageDirectory, titleDir, null, asin);
return finalDir;
}
public bool IsFileTypeMatch(FileInfo fileInfo) public bool IsFileTypeMatch(FileInfo fileInfo)
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.')); => extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
#endregion #endregion
} }
public class AudioFileStorage : AudibleFileStorage
{
public const string SKIP_FILE_EXT = "libhack";
public override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac", SKIP_FILE_EXT };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => BooksDirectory;
public AudioFileStorage() : base(FileType.Audio) { }
public void CreateSkipFile(string title, string asin, string contents)
{
var path = FileUtility.GetValidFilename(GetDestDir(title, asin), title, SKIP_FILE_EXT, asin);
File.WriteAllText(path, contents);
}
}
public class AaxFileStorage : AudibleFileStorage
{
public override string[] Extensions { get; } = new[] { "aax" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => DownloadsFinal;
public AaxFileStorage() : base(FileType.AAX) { }
}
public class PdfFileStorage : AudibleFileStorage
{
public override string[] Extensions { get; } = new[] { "pdf", "zip" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => BooksDirectory;
public PdfFileStorage() : base(FileType.PDF) { }
}
} }

View File

@ -13,7 +13,7 @@
<!-- <PublishSingleFile>true</PublishSingleFile> --> <!-- <PublishSingleFile>true</PublishSingleFile> -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Version>4.1.4.1</Version> <Version>4.1.5.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -288,13 +288,22 @@ namespace LibationWinForms.BookLiberation
{ {
automatedBackupsForm.Show(); automatedBackupsForm.Show();
// processable.ProcessFirstValidAsync used to be encapsulated elsewhere. however, support for 'skip this time only' requires state. iterators provide this state for free. therefore: use foreach/iterator here
try try
{ {
var shouldContinue = true; foreach (var libraryBook in processable.GetValidLibraryBooks())
while (shouldContinue)
{ {
var statusHandler = await processable.ProcessFirstValidAsync(); try
shouldContinue = validateStatus(statusHandler, automatedBackupsForm); {
var statusHandler = await processable.ProcessBookAsync_NoValidation(libraryBook);
if (!validateStatus(statusHandler, automatedBackupsForm))
break;
}
catch (Exception e)
{
automatedBackupsForm.AppendError(e);
DisplaySkipDialog(automatedBackupsForm, libraryBook, e);
}
} }
} }
catch (Exception ex) catch (Exception ex)
@ -311,17 +320,63 @@ namespace LibationWinForms.BookLiberation
try try
{ {
var statusHandler = await processable.ProcessSingleAsync(productId); var libraryBook = IProcessableExt.GetSingleLibraryBook(productId);
validateStatus(statusHandler, automatedBackupsForm);
try
{
var statusHandler = await processable.ProcessSingleAsync(libraryBook);
validateStatus(statusHandler, automatedBackupsForm);
}
catch (Exception ex)
{
automatedBackupsForm.AppendError(ex);
DisplaySkipDialog(automatedBackupsForm, libraryBook, ex);
}
} }
catch (Exception ex) catch (Exception e)
{ {
automatedBackupsForm.AppendError(ex); automatedBackupsForm.AppendError(e);
} }
automatedBackupsForm.FinalizeUI(); automatedBackupsForm.FinalizeUI();
} }
private static void DisplaySkipDialog(AutomatedBackupsForm automatedBackupsForm, LibraryBook libraryBook, Exception ex)
{
try
{
var text = @$"
The below error occurred while trying to process this book. Skip this book permanently?
- Click YES to skip this book permanently.
- Click NO to skip the book this time only. We'll try again later.
Error:
{ex.Message}
".Trim();
var dialogResult = System.Windows.Forms.MessageBox.Show(
text,
"Skip importing this book?",
System.Windows.Forms.MessageBoxButtons.YesNo,
System.Windows.Forms.MessageBoxIcon.Question);
if (dialogResult != System.Windows.Forms.DialogResult.Yes)
{
FileManager.AudibleFileStorage.Audio.CreateSkipFile(
libraryBook.Book.Title,
libraryBook.Book.AudibleProductId,
ex.Message + "\r\n|\r\n" + ex.StackTrace);
}
}
catch (Exception exc)
{
automatedBackupsForm.AppendText($"Error attempting to display {nameof(DisplaySkipDialog)}");
automatedBackupsForm.AppendError(exc);
}
}
private static bool validateStatus(StatusHandler statusHandler, AutomatedBackupsForm automatedBackupsForm) private static bool validateStatus(StatusHandler statusHandler, AutomatedBackupsForm automatedBackupsForm)
{ {
if (statusHandler == null) if (statusHandler == null)