diff --git a/Source/AudibleUtilities/AudibleApiStorage.cs b/Source/AudibleUtilities/AudibleApiStorage.cs index ff96b812..2fb43e8b 100644 --- a/Source/AudibleUtilities/AudibleApiStorage.cs +++ b/Source/AudibleUtilities/AudibleApiStorage.cs @@ -5,19 +5,58 @@ using Newtonsoft.Json; namespace AudibleUtilities { + public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs + { + /// + /// Create a new, empty file if true, otherwise throw + /// + public bool Handled { get; set; } + /// + /// The file path of the AccountsSettings.json file + /// + public string SettingsFilePath { get; } + + public AccountSettingsLoadErrorEventArgs(string path, Exception exception) + : base(exception) + { + SettingsFilePath = path; + } + } + public static class AudibleApiStorage { public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json"); + public static event EventHandler LoadError; + public static void EnsureAccountsSettingsFileExists() { // saves. BEWARE: this will overwrite an existing file if (!File.Exists(AccountsSettingsFile)) - _ = new AccountsSettingsPersister(new AccountsSettings(), AccountsSettingsFile); + { + //Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved + //are not fired. There's no need to fire those events on an empty AccountsSettings file. + var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings(); + File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings)); + } } /// If you use this, be a good citizen and DISPOSE of it - public static AccountsSettingsPersister GetAccountsSettingsPersister() => new AccountsSettingsPersister(AccountsSettingsFile); + public static AccountsSettingsPersister GetAccountsSettingsPersister() + { + try + { + return new AccountsSettingsPersister(AccountsSettingsFile); + } + catch (Exception ex) + { + var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex); + LoadError?.Invoke(null, args); + if (args.Handled) + return GetAccountsSettingsPersister(); + throw; + } + } public static string GetIdentityTokensJsonPath(this Account account) => GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name); diff --git a/Source/FileManager/FileUtility.cs b/Source/FileManager/FileUtility.cs index e6d9074c..137b817f 100644 --- a/Source/FileManager/FileUtility.cs +++ b/Source/FileManager/FileUtility.cs @@ -157,7 +157,7 @@ namespace FileManager /// File extension override to use for /// If false and exists, append " (n)" to filename and try again. /// The actual destination filename - public static string SaferMoveToValidPath( + public static LongPath SaferMoveToValidPath( LongPath source, LongPath destination, ReplacementCharacters replacements, diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index e61e851a..e8f01956 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -97,10 +97,13 @@ - + @@ -134,7 +137,7 @@ - + diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 47173b02..e77f80f3 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -4,6 +4,7 @@ using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Threading; using DataLayer; +using Dinah.Core.Collections.Generic; using LibationAvalonia.Dialogs.Login; using LibationFileManager; using LibationUiBase.GridView; @@ -11,7 +12,6 @@ using ReactiveUI; using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -103,17 +103,18 @@ namespace LibationAvalonia.ViewModels internal async Task BindToGridAsync(List dbBooks) { + //Get the UI thread's synchronization context and set it on the current thread to ensure + //it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync var sc = await Dispatcher.UIThread.InvokeAsync(() => AvaloniaSynchronizationContext.Current); AvaloniaSynchronizationContext.SetSynchronizationContext(sc); var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks); var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks); - //Create the filtered-in list before adding entries to avoid a refresh - FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString); - //Adding entries to the Source list will invoke CollectionFilter - //Perform on UI thread for safety - await Dispatcher.UIThread.InvokeAsync(() => SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null)))); + //Add all IGridEntries to the SOURCE list. Note that SOURCE has not yet been linked to the UI via + //the GridEntries property, so adding items to SOURCE will not trigger any refreshes or UI action. + //This this can be done on any thread. + SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null))); //Add all children beneath their parent foreach (var series in seriesEntries) @@ -123,10 +124,15 @@ namespace LibationAvalonia.ViewModels SOURCE.Insert(++seriesIndex, child); } - // Adding SOURCE to the DataGridViewCollection after building the source + //Create the filtered-in list before adding entries to GridEntries to avoid a refresh or UI action + FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString); + + // Adding SOURCE to the DataGridViewCollection _after_ building the SOURCE list //Saves ~500 ms on a library of ~4500 books. - //Perform on UI thread for safety + //Perform on UI thread for safety, but at this time, merely setting the DataGridCollectionView + //does not trigger UI actions in the way that modifying the list after it's been linked does. await Dispatcher.UIThread.InvokeAsync(() => GridEntries = new(SOURCE) { Filter = CollectionFilter }); + GridEntries.CollectionChanged += GridEntries_CollectionChanged; GridEntries_CollectionChanged(); } @@ -150,7 +156,7 @@ namespace LibationAvalonia.ViewModels #region Add new or update existing grid entries //Add absent entries to grid, or update existing entry - var allEntries = SOURCE.BookEntries().ToList(); + var allEntries = SOURCE.BookEntries().ToDictionarySafe(b => b.AudibleProductId); var seriesEntries = SOURCE.SeriesEntries().ToList(); var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); @@ -158,7 +164,7 @@ namespace LibationAvalonia.ViewModels { foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) { - var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); + var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null; if (libraryBook.Book.IsProduct()) UpsertBook(libraryBook, existingEntry); diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index a70755dc..61eb14f2 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -1,6 +1,9 @@ +using AudibleUtilities; using Avalonia.Input; using Avalonia.ReactiveUI; +using Avalonia.Threading; using DataLayer; +using FileManager; using LibationAvalonia.ViewModels; using LibationFileManager; using LibationUiBase.GridView; @@ -18,6 +21,7 @@ namespace LibationAvalonia.Views { DataContext = new MainVM(this); + AudibleApiStorage.LoadError += AudibleApiStorage_LoadError; InitializeComponent(); Configure_Upgrade(); @@ -34,6 +38,62 @@ namespace LibationAvalonia.Views } } + private void AudibleApiStorage_LoadError(object sender, AccountSettingsLoadErrorEventArgs e) + { + try + { + //Backup AccountSettings.json and create a new, empty file. + var backupFile = + FileUtility.SaferMoveToValidPath( + e.SettingsFilePath, + e.SettingsFilePath, + ReplacementCharacters.Barebones, + "bak"); + AudibleApiStorage.EnsureAccountsSettingsFileExists(); + e.Handled = true; + + showAccountSettingsRecoveredMessage(backupFile); + } + catch + { + showAccountSettingsUnrecoveredMessage(); + } + + async void showAccountSettingsRecoveredMessage(LongPath backupFile) + => await MessageBox.Show(this, $""" + Libation could not load your account settings, so it had created a new, empty account settings file. + + You will need to re-add you Audible account(s) before scanning or downloading. + + The old account settings file has been archived at '{backupFile.PathWithoutPrefix}' + + {e.GetException().ToString()} + """, + "Error Loading Account Settings", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + + void showAccountSettingsUnrecoveredMessage() + { + var messageBoxWindow = MessageBox.Show(this, $""" + Libation could not load your account settings. The file may be corrupted, but Libation is unable to delete it. + + Please move or delete the account settings file '{e.SettingsFilePath}' + + {e.GetException().ToString()} + """, + "Error Loading Account Settings", + MessageBoxButtons.OK); + + //Force the message box to show synchronously because we're not handling the exception + //and libation will crash after the event handler returns + var frame = new DispatcherFrame(); + _ = messageBoxWindow.ContinueWith(static (_, s) => ((DispatcherFrame)s).Continue = false, frame); + Dispatcher.UIThread.PushFrame(frame); + messageBoxWindow.GetAwaiter().GetResult(); + } + } + private async void MainWindow_Opened(object sender, EventArgs e) { if (Configuration.Instance.FirstLaunch) diff --git a/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs b/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs index 85fbb9ea..2d118e36 100644 --- a/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs @@ -33,6 +33,10 @@ namespace LibationUiBase.GridView LoadCover(); } + /// + /// Creates for all non-episode books in an enumeration of . + /// + /// Can be called from any thread, but requires the calling thread's to be valid. public static async Task> GetAllProductsAsync(IEnumerable libraryBooks) { var products = libraryBooks.Where(lb => lb.Book.IsProduct()).ToArray(); diff --git a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs index ba3fef4f..17eeff93 100644 --- a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs @@ -56,6 +56,10 @@ namespace LibationUiBase.GridView LoadCover(); } + /// + /// Creates for all episodic series in an enumeration of . + /// + /// Can be called from any thread, but requires the calling thread's to be valid. public static async Task> GetAllSeriesEntriesAsync(IEnumerable libraryBooks) { var seriesBooks = libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).ToArray(); diff --git a/Source/LibationWinForms/Form1._NonUI.cs b/Source/LibationWinForms/Form1._NonUI.cs index 21ddc9f3..3c91142d 100644 --- a/Source/LibationWinForms/Form1._NonUI.cs +++ b/Source/LibationWinForms/Form1._NonUI.cs @@ -1,10 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Windows.Forms; using ApplicationServices; +using AudibleUtilities; using Dinah.Core.WindowsDesktop.Drawing; +using FileManager; using LibationFileManager; using LibationUiBase; @@ -13,9 +11,11 @@ namespace LibationWinForms public partial class Form1 { private void Configure_NonUI() - { - // init default/placeholder cover art - var format = System.Drawing.Imaging.ImageFormat.Jpeg; + { + AudibleApiStorage.LoadError += AudibleApiStorage_LoadError; + + // init default/placeholder cover art + var format = System.Drawing.Imaging.ImageFormat.Jpeg; PictureStorage.SetDefaultImage(PictureSize._80x80, Properties.Resources.default_cover_80x80.ToBytes(format)); PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format)); PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format)); @@ -35,6 +35,55 @@ namespace LibationWinForms if ((libraryStats.booksNoProgress + libraryStats.pdfsNotDownloaded) > 0) beginBookBackupsToolStripMenuItem_Click(); }; - } - } + } + + private void AudibleApiStorage_LoadError(object sender, AccountSettingsLoadErrorEventArgs e) + { + try + { + //Backup AccountSettings.json and create a new, empty file. + var backupFile = + FileUtility.SaferMoveToValidPath( + e.SettingsFilePath, + e.SettingsFilePath, + ReplacementCharacters.Barebones, + "bak"); + + AudibleApiStorage.EnsureAccountsSettingsFileExists(); + e.Handled = true; + + showAccountSettingsRecoveredMessage(backupFile); + } + catch + { + showAccountSettingsUnrecoveredMessage(); + } + + void showAccountSettingsRecoveredMessage(LongPath backupFile) + => MessageBox.Show(this, $""" + Libation could not load your account settings, so it had created a new, empty account settings file. + + You will need to re-add you Audible account(s) before scanning or downloading. + + The old account settings file has been archived at '{backupFile.PathWithoutPrefix}' + + {e.GetException().ToString()} + """, + "Error Loading Account Settings", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + + void showAccountSettingsUnrecoveredMessage() + => MessageBox.Show(this, $""" + Libation could not load your account settings. The file may be corrupted, but Libation is unable to delete it. + + Please move or delete the account settings file '{e.SettingsFilePath}' + + {e.GetException().ToString()} + """, + "Error Loading Account Settings", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 790f5ba0..9a7f31c7 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -1,9 +1,9 @@ using DataLayer; using Dinah.Core; +using Dinah.Core.Collections.Generic; using Dinah.Core.WindowsDesktop.Forms; using LibationFileManager; using LibationUiBase.GridView; -using NPOI.SS.Formula.Functions; using System; using System.Collections.Generic; using System.Data; @@ -216,8 +216,12 @@ namespace LibationWinForms.GridView internal async Task BindToGridAsync(List dbBooks) { - var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks); + //Get the UI thread's synchronization context and set it on the current thread to ensure + //it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync + var sc = Invoke(() => System.Threading.SynchronizationContext.Current); + System.Threading.SynchronizationContext.SetSynchronizationContext(sc); + var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks); var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks); geList.AddRange(seriesEntries); @@ -232,9 +236,12 @@ namespace LibationWinForms.GridView foreach (var child in series.Children) geList.Insert(++seriesIndex, child); } - + System.Threading.SynchronizationContext.SetSynchronizationContext(null); + bindingList = new GridEntryBindingList(geList); bindingList.CollapseAll(); + + //The syncBindingSource ensures that the IGridEntry list is added on the UI thread syncBindingSource.DataSource = bindingList; VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); } @@ -252,14 +259,19 @@ namespace LibationWinForms.GridView //Add absent entries to grid, or update existing entry - var allEntries = bindingList.AllItems().BookEntries(); + var allEntries = bindingList.AllItems().BookEntries().ToDictionarySafe(b => b.AudibleProductId); var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); + //Get the UI thread's synchronization context and set it on the current thread to ensure + //it's available for creation of new IGridEntry items during upsert + var sc = Invoke(() => System.Threading.SynchronizationContext.Current); + System.Threading.SynchronizationContext.SetSynchronizationContext(sc); + bindingList.RaiseListChangedEvents = false; foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) { - var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); + var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null; if (libraryBook.Book.IsProduct()) { @@ -289,6 +301,10 @@ namespace LibationWinForms.GridView .BookEntries() .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); + removedBooks = bindingList + .AllItems() + .BookEntries().Take(10).ToList(); + RemoveBooks(removedBooks); gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow;