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;