using System; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; using DataLayer; using Dinah.Core; using Dinah.Core.ErrorHandling; using Dinah.Core.Windows.Forms; using FileLiberator; namespace LibationWinForms.BookLiberation { // decouple serilog and form. include convenience factory method public class LogMe { public event EventHandler LogInfo; public event EventHandler LogErrorString; public event EventHandler<(Exception, string)> LogError; private LogMe() { LogInfo += (_, text) => Serilog.Log.Logger.Information($"Automated backup: {text}"); LogErrorString += (_, text) => Serilog.Log.Logger.Error(text); LogError += (_, tuple) => Serilog.Log.Logger.Error(tuple.Item1, tuple.Item2 ?? "Automated backup: error"); } public static LogMe RegisterForm(AutomatedBackupsForm form = null) { var logMe = new LogMe(); if (form is null) return logMe; logMe.LogInfo += (_, text) => form?.WriteLine(text); logMe.LogErrorString += (_, text) => form?.WriteLine(text); logMe.LogError += (_, tuple) => { form?.WriteLine(tuple.Item2 ?? "Automated backup: error"); form?.WriteLine("ERROR: " + tuple.Item1.Message); }; return logMe; } public void Info(string text) => LogInfo?.Invoke(this, text); public void Error(string text) => LogErrorString?.Invoke(this, text); public void Error(Exception ex, string text = null) => LogError?.Invoke(this, (ex, text)); } public static class ProcessorAutomationController { public static async Task BackupSingleBookAsync(string productId, EventHandler completedAction = null) { Serilog.Log.Logger.Information("Begin " + nameof(BackupSingleBookAsync) + " {@DebugInfo}", new { productId }); var backupBook = getWiredUpBackupBook(completedAction); (Action unsubscribeEvents, LogMe logMe) = attachToBackupsForm(backupBook); var libraryBook = IProcessableExt.GetSingleLibraryBook(productId); // continue even if libraryBook is null. we'll display even that in the processing box await new BackupSingle(logMe, backupBook, libraryBook).RunBackupAsync(); unsubscribeEvents(); } public static async Task BackupAllBooksAsync(EventHandler completedAction = null) { Serilog.Log.Logger.Information("Begin " + nameof(BackupAllBooksAsync)); var backupBook = getWiredUpBackupBook(completedAction); var automatedBackupsForm = new AutomatedBackupsForm(); (Action unsubscribeEvents, LogMe logMe) = attachToBackupsForm(backupBook, automatedBackupsForm); await new BackupLoop(logMe, backupBook, automatedBackupsForm).RunBackupAsync(); unsubscribeEvents(); } private static BackupBook getWiredUpBackupBook(EventHandler completedAction) { var backupBook = new BackupBook(); backupBook.DecryptBook.Begin += (_, l) => wireUpEvents(backupBook.DecryptBook, l); backupBook.DownloadPdf.Begin += (_, __) => wireUpEvents(backupBook.DownloadPdf); // must occur before completedAction. A common use case is: // - filter by -liberated // - liberate only that book // completedAction is to refresh grid // - want to see that book disappear from grid // also for this to work, updateIsLiberated can NOT be async backupBook.DecryptBook.Completed += updateIsLiberated; backupBook.DownloadPdf.Completed += updateIsLiberated; if (completedAction != null) { backupBook.DecryptBook.Completed += completedAction; backupBook.DownloadPdf.Completed += completedAction; } return backupBook; } private static void updateIsLiberated(object sender, LibraryBook e) => ApplicationServices.SearchEngineCommands.UpdateIsLiberated(e.Book); private static (Action unsubscribeEvents, LogMe) attachToBackupsForm(BackupBook backupBook, AutomatedBackupsForm automatedBackupsForm = null) { #region create logger var logMe = LogMe.RegisterForm(automatedBackupsForm); #endregion #region define how model actions will affect form behavior void statusUpdate(object _, string str) => logMe.Info("- " + str); void decryptBookBegin(object _, LibraryBook libraryBook) => logMe.Info($"Decrypt Step, Begin: {libraryBook.Book}"); // extra line after book is completely finished void decryptBookCompleted(object _, LibraryBook libraryBook) => logMe.Info($"Decrypt Step, Completed: {libraryBook.Book}{Environment.NewLine}"); void downloadPdfBegin(object _, LibraryBook libraryBook) => logMe.Info($"PDF Step, Begin: {libraryBook.Book}"); // extra line after book is completely finished void downloadPdfCompleted(object _, LibraryBook libraryBook) => logMe.Info($"PDF Step, Completed: {libraryBook.Book}{Environment.NewLine}"); #endregion #region subscribe new form to model's events backupBook.DecryptBook.Begin += decryptBookBegin; backupBook.DecryptBook.StatusUpdate += statusUpdate; backupBook.DecryptBook.Completed += decryptBookCompleted; backupBook.DownloadPdf.Begin += downloadPdfBegin; backupBook.DownloadPdf.StatusUpdate += statusUpdate; backupBook.DownloadPdf.Completed += downloadPdfCompleted; #endregion #region when form closes, unsubscribe from model's events // unsubscribe so disposed forms aren't still trying to receive notifications Action unsubscribe = () => { backupBook.DecryptBook.Begin -= decryptBookBegin; backupBook.DecryptBook.StatusUpdate -= statusUpdate; backupBook.DecryptBook.Completed -= decryptBookCompleted; backupBook.DownloadPdf.Begin -= downloadPdfBegin; backupBook.DownloadPdf.StatusUpdate -= statusUpdate; backupBook.DownloadPdf.Completed -= downloadPdfCompleted; }; #endregion return (unsubscribe, logMe); } public static async Task BackupAllPdfsAsync(EventHandler completedAction = null) { Serilog.Log.Logger.Information("Begin " + nameof(BackupAllPdfsAsync)); var downloadPdf = getWiredUpDownloadPdf(completedAction); (AutomatedBackupsForm automatedBackupsForm, LogMe logMe) = attachToBackupsForm(downloadPdf); await new BackupLoop(logMe, downloadPdf, automatedBackupsForm).RunBackupAsync(); } private static DownloadPdf getWiredUpDownloadPdf(EventHandler completedAction) { var downloadPdf = new DownloadPdf(); downloadPdf.Begin += (_, __) => wireUpEvents(downloadPdf); if (completedAction != null) downloadPdf.Completed += completedAction; return downloadPdf; } public static void DownloadFile(string url, string destination, bool showDownloadCompletedDialog = false) { var downloadDialog = new DownloadForm(); downloadDialog.UpdateFilename(destination); downloadDialog.Show(); new System.Threading.Thread(() => { var downloadFile = new DownloadFile(); downloadFile.DownloadProgressChanged += (_, progress) => downloadDialog.UIThread(() => downloadDialog.DownloadProgressChanged(progress.BytesReceived, progress.TotalBytesToReceive) ); downloadFile.DownloadCompleted += (_, __) => downloadDialog.UIThread(() => { downloadDialog.Close(); if (showDownloadCompletedDialog) MessageBox.Show("File downloaded"); }); downloadFile.PerformDownloadFileAsync(url, destination).GetAwaiter().GetResult(); }).Start(); } // subscribed to Begin event because a new form should be created+processed+closed on each iteration private static void wireUpEvents(IDownloadableProcessable downloadable) { #region create form var downloadDialog = new DownloadForm(); #endregion // extra complexity for wiring up download form: // case 1: download is needed // dialog created. subscribe to events // downloadable.DownloadBegin fires. shows dialog // downloadable.DownloadCompleted fires. closes dialog. which fires FormClosing, FormClosed, Disposed // Disposed unsubscribe from events // case 2: download is not needed // dialog created. subscribe to events // dialog is never shown nor closed // downloadable.Completed fires. disposes dialog and unsubscribes from events #region define how model actions will affect form behavior void downloadBegin(object _, string str) { downloadDialog.UpdateFilename(str); downloadDialog.Show(); } // close form on DOWNLOAD completed, not final Completed. Else for BackupBook this form won't close until DECRYPT is also complete void fileDownloadCompleted(object _, string __) => downloadDialog.Close(); void downloadProgressChanged(object _, Dinah.Core.Net.Http.DownloadProgress progress) => downloadDialog.DownloadProgressChanged(progress.BytesReceived, progress.TotalBytesToReceive); void unsubscribe(object _ = null, EventArgs __ = null) { downloadable.DownloadBegin -= downloadBegin; downloadable.DownloadCompleted -= fileDownloadCompleted; downloadable.DownloadProgressChanged -= downloadProgressChanged; downloadable.Completed -= dialogDispose; } // unless we dispose, if the form is created but un-used/never-shown then weird UI stuff can happen // also, since event unsubscribe occurs on FormClosing and an unused form is never closed, then the events will never be unsubscribed void dialogDispose(object _, object __) { if (!downloadDialog.IsDisposed) downloadDialog.Dispose(); } #endregion #region subscribe new form to model's events downloadable.DownloadBegin += downloadBegin; downloadable.DownloadCompleted += fileDownloadCompleted; downloadable.DownloadProgressChanged += downloadProgressChanged; downloadable.Completed += dialogDispose; #endregion #region when form closes, unsubscribe from model's events // unsubscribe so disposed forms aren't still trying to receive notifications // FormClosing is more UI safe but won't fire unless the form is shown and closed // if form was shown, Disposed will fire for FormClosing, FormClosed, and Disposed // if not shown, it will still fire for Disposed downloadDialog.Disposed += unsubscribe; #endregion } // subscribed to Begin event because a new form should be created+processed+closed on each iteration private static void wireUpEvents(IDecryptable decryptBook, LibraryBook libraryBook) { #region create form var decryptDialog = new DecryptForm(); #endregion #region Set initially displayed book properties from library info. decryptDialog.SetTitle(libraryBook.Book.Title); decryptDialog.SetAuthorNames(string.Join(", ", libraryBook.Book.Authors)); decryptDialog.SetNarratorNames(string.Join(", ", libraryBook.Book.NarratorNames)); decryptDialog.SetCoverImage( WindowsDesktopUtilities.WinAudibleImageServer.GetImage( libraryBook.Book.PictureId, FileManager.PictureSize._80x80 )); #endregion #region define how model actions will affect form behavior void decryptBegin(object _, string __) => decryptDialog.Show(); void titleDiscovered(object _, string title) => decryptDialog.SetTitle(title); void authorsDiscovered(object _, string authors) => decryptDialog.SetAuthorNames(authors); void narratorsDiscovered(object _, string narrators) => decryptDialog.SetNarratorNames(narrators); void coverImageFilepathDiscovered(object _, byte[] coverBytes) => decryptDialog.SetCoverImage(Dinah.Core.Drawing.ImageReader.ToImage(coverBytes)); void updateProgress(object _, int percentage) => decryptDialog.UpdateProgress(percentage); void updateRemainingTime(object _, TimeSpan remaining) => decryptDialog.UpdateRemainingTime(remaining); void decryptCompleted(object _, string __) => decryptDialog.Close(); void requestCoverArt(object _, Action setCoverArtDelegate) { var picDef = new FileManager.PictureDefinition(libraryBook.Book.PictureId, FileManager.PictureSize._500x500); (bool isDefault, byte[] picture) = FileManager.PictureStorage.GetPicture(picDef); if (isDefault) { void pictureCached(object _, string pictureId) { if (pictureId == libraryBook.Book.PictureId) { FileManager.PictureStorage.PictureCached -= pictureCached; var picDef = new FileManager.PictureDefinition(libraryBook.Book.PictureId, FileManager.PictureSize._500x500); (_, picture) = FileManager.PictureStorage.GetPicture(picDef); setCoverArtDelegate(picture); } }; FileManager.PictureStorage.PictureCached += pictureCached; } else setCoverArtDelegate(picture); } #endregion #region subscribe new form to model's events decryptBook.DecryptBegin += decryptBegin; decryptBook.TitleDiscovered += titleDiscovered; decryptBook.AuthorsDiscovered += authorsDiscovered; decryptBook.NarratorsDiscovered += narratorsDiscovered; decryptBook.CoverImageFilepathDiscovered += coverImageFilepathDiscovered; decryptBook.UpdateProgress += updateProgress; decryptBook.UpdateRemainingTime += updateRemainingTime; decryptBook.RequestCoverArt += requestCoverArt; decryptBook.DecryptCompleted += decryptCompleted; #endregion #region when form closes, unsubscribe from model's events // unsubscribe so disposed forms aren't still trying to receive notifications decryptDialog.FormClosing += (_, __) => { decryptBook.DecryptBegin -= decryptBegin; decryptBook.TitleDiscovered -= titleDiscovered; decryptBook.AuthorsDiscovered -= authorsDiscovered; decryptBook.NarratorsDiscovered -= narratorsDiscovered; decryptBook.CoverImageFilepathDiscovered -= coverImageFilepathDiscovered; decryptBook.UpdateProgress -= updateProgress; decryptBook.UpdateRemainingTime -= updateRemainingTime; decryptBook.RequestCoverArt -= requestCoverArt; decryptBook.DecryptCompleted -= decryptCompleted; decryptBook.Cancel(); }; #endregion } private static (AutomatedBackupsForm, LogMe) attachToBackupsForm(IDownloadableProcessable downloadable) { #region create form and logger var automatedBackupsForm = new AutomatedBackupsForm(); var logMe = LogMe.RegisterForm(automatedBackupsForm); #endregion #region define how model actions will affect form behavior void begin(object _, LibraryBook libraryBook) => logMe.Info($"Begin: {libraryBook.Book}"); void statusUpdate(object _, string str) => logMe.Info("- " + str); // extra line after book is completely finished void completed(object _, LibraryBook libraryBook) => logMe.Info($"Completed: {libraryBook.Book}{Environment.NewLine}"); #endregion #region subscribe new form to model's events downloadable.Begin += begin; downloadable.StatusUpdate += statusUpdate; downloadable.Completed += completed; #endregion #region when form closes, unsubscribe from model's events // unsubscribe so disposed forms aren't still trying to receive notifications automatedBackupsForm.FormClosing += (_, __) => { downloadable.Begin -= begin; downloadable.StatusUpdate -= statusUpdate; downloadable.Completed -= completed; }; #endregion return (automatedBackupsForm, logMe); } } abstract class BackupRunner { protected LogMe LogMe { get; } protected IProcessable Processable { get; } protected AutomatedBackupsForm AutomatedBackupsForm { get; } protected BackupRunner(LogMe logMe, IProcessable processable, AutomatedBackupsForm automatedBackupsForm = null) { LogMe = logMe; Processable = processable; AutomatedBackupsForm = automatedBackupsForm; } protected abstract Task RunAsync(); protected abstract string SkipDialogText { get; } protected abstract MessageBoxButtons SkipDialogButtons { get; } protected abstract DialogResult CreateSkipFileResult { get; } public async Task RunBackupAsync() { AutomatedBackupsForm?.Show(); try { await RunAsync(); } catch (Exception ex) { LogMe.Error(ex); } AutomatedBackupsForm?.FinalizeUI(); LogMe.Info("DONE"); } protected async Task ProcessOneAsync(Func> func, LibraryBook libraryBook) { string logMessage; try { var statusHandler = await func(libraryBook); if (statusHandler.IsSuccess) return true; foreach (var errorMessage in statusHandler.Errors) LogMe.Error(errorMessage); logMessage = statusHandler.Errors.Aggregate((a, b) => $"{a}\r\n{b}"); } catch (Exception ex) { LogMe.Error(ex); logMessage = ex.Message + "\r\n|\r\n" + ex.StackTrace; } LogMe.Error("ERROR. All books have not been processed. Most recent book: processing failed"); string details; try { static string trunc(string str) => string.IsNullOrWhiteSpace(str) ? "[empty]" : (str.Length > 50) ? $"{str.Truncate(47)}..." : str; details = $@" Title: {libraryBook.Book.Title} ID: {libraryBook.Book.AudibleProductId} Author: {trunc(libraryBook.Book.AuthorNames)} Narr: {trunc(libraryBook.Book.NarratorNames)}"; } catch { details = "[Error retrieving details]"; } var dialogResult = MessageBox.Show(string.Format(SkipDialogText, details), "Skip importing this book?", SkipDialogButtons, MessageBoxIcon.Question); if (dialogResult == DialogResult.Abort) return false; if (dialogResult == CreateSkipFileResult) { var path = FileManager.AudibleFileStorage.Audio.CreateSkipFile(libraryBook.Book.Title, libraryBook.Book.AudibleProductId, logMessage); LogMe.Info($@" Created new 'skip' file [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title} {path} ".Trim()); } return true; } } class BackupSingle : BackupRunner { private LibraryBook _libraryBook { get; } protected override string SkipDialogText => @" An error occurred while trying to process this book. Skip this book permanently? {0} - Click YES to skip this book permanently. - Click NO to skip the book this time only. We'll try again later. ".Trim(); protected override MessageBoxButtons SkipDialogButtons => MessageBoxButtons.YesNo; protected override DialogResult CreateSkipFileResult => DialogResult.Yes; public BackupSingle(LogMe logMe, IProcessable processable, LibraryBook libraryBook) : base(logMe, processable) { _libraryBook = libraryBook; } protected override async Task RunAsync() { if (_libraryBook is not null) await ProcessOneAsync(Processable.ProcessSingleAsync, _libraryBook); } } class BackupLoop : BackupRunner { protected override string SkipDialogText => @" An error occurred while trying to process this book. {0} - ABORT: stop processing books. - RETRY: retry this book later. Just skip it for now. Continue processing books. (Will try this book again later.) - IGNORE: Permanently ignore this book. Continue processing books. (Will not try this book again later.) ".Trim(); protected override MessageBoxButtons SkipDialogButtons => MessageBoxButtons.AbortRetryIgnore; protected override DialogResult CreateSkipFileResult => DialogResult.Ignore; public BackupLoop(LogMe logMe, IProcessable processable, AutomatedBackupsForm automatedBackupsForm) : base(logMe, processable, automatedBackupsForm) { } protected override async Task RunAsync() { // support for 'skip this time only' requires state. iterators provide this state for free. therefore: use foreach/iterator here foreach (var libraryBook in Processable.GetValidLibraryBooks()) { var keepGoing = await ProcessOneAsync(Processable.ProcessBookAsync_NoValidation, libraryBook); if (!keepGoing) return; if (AutomatedBackupsForm.IsDisposed) break; if (!AutomatedBackupsForm.KeepGoing) { if (!AutomatedBackupsForm.KeepGoingChecked) LogMe.Info("'Keep going' is unchecked"); return; } } LogMe.Info("Done. All books have been processed"); } } }