Merge pull request #1172 from Mbucari/master

Thread safety and AccountSettings.json error handling
This commit is contained in:
rmcrackan 2025-03-03 19:27:45 -05:00 committed by GitHub
commit 5f99e594d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 211 additions and 30 deletions

View File

@ -5,19 +5,58 @@ using Newtonsoft.Json;
namespace AudibleUtilities namespace AudibleUtilities
{ {
public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs
{
/// <summary>
/// Create a new, empty <see cref="AccountsSettings"/> file if true, otherwise throw
/// </summary>
public bool Handled { get; set; }
/// <summary>
/// The file path of the AccountsSettings.json file
/// </summary>
public string SettingsFilePath { get; }
public AccountSettingsLoadErrorEventArgs(string path, Exception exception)
: base(exception)
{
SettingsFilePath = path;
}
}
public static class AudibleApiStorage public static class AudibleApiStorage
{ {
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json"); public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
public static void EnsureAccountsSettingsFileExists() public static void EnsureAccountsSettingsFileExists()
{ {
// saves. BEWARE: this will overwrite an existing file // saves. BEWARE: this will overwrite an existing file
if (!File.Exists(AccountsSettingsFile)) 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));
}
} }
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary> /// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
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) public static string GetIdentityTokensJsonPath(this Account account)
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name); => GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);

View File

@ -157,7 +157,7 @@ namespace FileManager
/// <param name="extension">File extension override to use for <paramref name="destination"/></param> /// <param name="extension">File extension override to use for <paramref name="destination"/></param>
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param> /// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
/// <returns>The actual destination filename</returns> /// <returns>The actual destination filename</returns>
public static string SaferMoveToValidPath( public static LongPath SaferMoveToValidPath(
LongPath source, LongPath source,
LongPath destination, LongPath destination,
ReplacementCharacters replacements, ReplacementCharacters replacements,

View File

@ -97,10 +97,13 @@
</Setter> </Setter>
</Style> </Style>
<Style Selector="^[UseCustomTitleBar=true]"> <Style Selector="^[UseCustomTitleBar=true]">
<Style Selector="^[CanResize=false] Border#DialogWindowFormBorder">
<Setter Property="BorderThickness" Value="2" />
</Style>
<Setter Property="SystemDecorations" Value="BorderOnly"/> <Setter Property="SystemDecorations" Value="BorderOnly"/>
<Setter Property="Template"> <Setter Property="Template">
<ControlTemplate> <ControlTemplate>
<Panel Background="{DynamicResource SystemControlBackgroundAltHighBrush}"> <Border Name="DialogWindowFormBorder" BorderBrush="{DynamicResource SystemBaseMediumLowColor}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}">
<Grid RowDefinitions="30,*"> <Grid RowDefinitions="30,*">
<Border Name="DialogWindowTitleBorder" Margin="5,0" Background="{DynamicResource SystemAltMediumColor}"> <Border Name="DialogWindowTitleBorder" Margin="5,0" Background="{DynamicResource SystemAltMediumColor}">
<Border.Styles> <Border.Styles>
@ -134,7 +137,7 @@
<Path Stroke="{DynamicResource SystemBaseMediumLowColor}" StrokeThickness="1" VerticalAlignment="Bottom" Stretch="Fill" Data="M0,0 L1,0" /> <Path Stroke="{DynamicResource SystemBaseMediumLowColor}" StrokeThickness="1" VerticalAlignment="Bottom" Stretch="Fill" Data="M0,0 L1,0" />
<ContentPresenter Grid.Row="1" Content="{TemplateBinding Content}" /> <ContentPresenter Grid.Row="1" Content="{TemplateBinding Content}" />
</Grid> </Grid>
</Panel> </Border>
</ControlTemplate> </ControlTemplate>
</Setter> </Setter>
</Style> </Style>

View File

@ -4,6 +4,7 @@ using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using DataLayer; using DataLayer;
using Dinah.Core.Collections.Generic;
using LibationAvalonia.Dialogs.Login; using LibationAvalonia.Dialogs.Login;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.GridView; using LibationUiBase.GridView;
@ -11,7 +12,6 @@ using ReactiveUI;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -103,17 +103,18 @@ namespace LibationAvalonia.ViewModels
internal async Task BindToGridAsync(List<LibraryBook> dbBooks) internal async Task BindToGridAsync(List<LibraryBook> 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); var sc = await Dispatcher.UIThread.InvokeAsync(() => AvaloniaSynchronizationContext.Current);
AvaloniaSynchronizationContext.SetSynchronizationContext(sc); AvaloniaSynchronizationContext.SetSynchronizationContext(sc);
var geList = await LibraryBookEntry<AvaloniaEntryStatus>.GetAllProductsAsync(dbBooks); var geList = await LibraryBookEntry<AvaloniaEntryStatus>.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks); var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
//Create the filtered-in list before adding entries to avoid a refresh //Add all IGridEntries to the SOURCE list. Note that SOURCE has not yet been linked to the UI via
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString); //the GridEntries property, so adding items to SOURCE will not trigger any refreshes or UI action.
//Adding entries to the Source list will invoke CollectionFilter //This this can be done on any thread.
//Perform on UI thread for safety SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null)));
await Dispatcher.UIThread.InvokeAsync(() => SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null))));
//Add all children beneath their parent //Add all children beneath their parent
foreach (var series in seriesEntries) foreach (var series in seriesEntries)
@ -123,10 +124,15 @@ namespace LibationAvalonia.ViewModels
SOURCE.Insert(++seriesIndex, child); 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. //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 }); await Dispatcher.UIThread.InvokeAsync(() => GridEntries = new(SOURCE) { Filter = CollectionFilter });
GridEntries.CollectionChanged += GridEntries_CollectionChanged; GridEntries.CollectionChanged += GridEntries_CollectionChanged;
GridEntries_CollectionChanged(); GridEntries_CollectionChanged();
} }
@ -150,7 +156,7 @@ namespace LibationAvalonia.ViewModels
#region Add new or update existing grid entries #region Add new or update existing grid entries
//Add absent entries to grid, or update existing entry //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 seriesEntries = SOURCE.SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
@ -158,7 +164,7 @@ namespace LibationAvalonia.ViewModels
{ {
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) 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()) if (libraryBook.Book.IsProduct())
UpsertBook(libraryBook, existingEntry); UpsertBook(libraryBook, existingEntry);

View File

@ -1,6 +1,9 @@
using AudibleUtilities;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.ReactiveUI; using Avalonia.ReactiveUI;
using Avalonia.Threading;
using DataLayer; using DataLayer;
using FileManager;
using LibationAvalonia.ViewModels; using LibationAvalonia.ViewModels;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.GridView; using LibationUiBase.GridView;
@ -18,6 +21,7 @@ namespace LibationAvalonia.Views
{ {
DataContext = new MainVM(this); DataContext = new MainVM(this);
AudibleApiStorage.LoadError += AudibleApiStorage_LoadError;
InitializeComponent(); InitializeComponent();
Configure_Upgrade(); 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) private async void MainWindow_Opened(object sender, EventArgs e)
{ {
if (Configuration.Instance.FirstLaunch) if (Configuration.Instance.FirstLaunch)

View File

@ -33,6 +33,10 @@ namespace LibationUiBase.GridView
LoadCover(); LoadCover();
} }
/// <summary>
/// Creates <see cref="LibraryBookEntry{TStatus}"/> for all non-episode books in an enumeration of <see cref="LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<IGridEntry>> GetAllProductsAsync(IEnumerable<LibraryBook> libraryBooks) public static async Task<List<IGridEntry>> GetAllProductsAsync(IEnumerable<LibraryBook> libraryBooks)
{ {
var products = libraryBooks.Where(lb => lb.Book.IsProduct()).ToArray(); var products = libraryBooks.Where(lb => lb.Book.IsProduct()).ToArray();

View File

@ -56,6 +56,10 @@ namespace LibationUiBase.GridView
LoadCover(); LoadCover();
} }
/// <summary>
/// Creates <see cref="SeriesEntry{TStatus}"/> for all episodic series in an enumeration of <see cref="LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<ISeriesEntry>> GetAllSeriesEntriesAsync(IEnumerable<LibraryBook> libraryBooks) public static async Task<List<ISeriesEntry>> GetAllSeriesEntriesAsync(IEnumerable<LibraryBook> libraryBooks)
{ {
var seriesBooks = libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).ToArray(); var seriesBooks = libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).ToArray();

View File

@ -1,10 +1,8 @@
using System; using System.Windows.Forms;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ApplicationServices; using ApplicationServices;
using AudibleUtilities;
using Dinah.Core.WindowsDesktop.Drawing; using Dinah.Core.WindowsDesktop.Drawing;
using FileManager;
using LibationFileManager; using LibationFileManager;
using LibationUiBase; using LibationUiBase;
@ -13,9 +11,11 @@ namespace LibationWinForms
public partial class Form1 public partial class Form1
{ {
private void Configure_NonUI() private void Configure_NonUI()
{ {
// init default/placeholder cover art AudibleApiStorage.LoadError += AudibleApiStorage_LoadError;
var format = System.Drawing.Imaging.ImageFormat.Jpeg;
// 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._80x80, Properties.Resources.default_cover_80x80.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format)); PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.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) if ((libraryStats.booksNoProgress + libraryStats.pdfsNotDownloaded) > 0)
beginBookBackupsToolStripMenuItem_Click(); 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);
}
}
} }

View File

@ -1,9 +1,9 @@
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using Dinah.Core.Collections.Generic;
using Dinah.Core.WindowsDesktop.Forms; using Dinah.Core.WindowsDesktop.Forms;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.GridView; using LibationUiBase.GridView;
using NPOI.SS.Formula.Functions;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data; using System.Data;
@ -216,8 +216,12 @@ namespace LibationWinForms.GridView
internal async Task BindToGridAsync(List<LibraryBook> dbBooks) internal async Task BindToGridAsync(List<LibraryBook> dbBooks)
{ {
var geList = await LibraryBookEntry<WinFormsEntryStatus>.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<WinFormsEntryStatus>.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry<WinFormsEntryStatus>.GetAllSeriesEntriesAsync(dbBooks); var seriesEntries = await SeriesEntry<WinFormsEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
geList.AddRange(seriesEntries); geList.AddRange(seriesEntries);
@ -232,9 +236,12 @@ namespace LibationWinForms.GridView
foreach (var child in series.Children) foreach (var child in series.Children)
geList.Insert(++seriesIndex, child); geList.Insert(++seriesIndex, child);
} }
System.Threading.SynchronizationContext.SetSynchronizationContext(null);
bindingList = new GridEntryBindingList(geList); bindingList = new GridEntryBindingList(geList);
bindingList.CollapseAll(); bindingList.CollapseAll();
//The syncBindingSource ensures that the IGridEntry list is added on the UI thread
syncBindingSource.DataSource = bindingList; syncBindingSource.DataSource = bindingList;
VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count());
} }
@ -252,14 +259,19 @@ namespace LibationWinForms.GridView
//Add absent entries to grid, or update existing entry //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 seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); 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; bindingList.RaiseListChangedEvents = false;
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) 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()) if (libraryBook.Book.IsProduct())
{ {
@ -289,6 +301,10 @@ namespace LibationWinForms.GridView
.BookEntries() .BookEntries()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
removedBooks = bindingList
.AllItems()
.BookEntries().Take(10).ToList();
RemoveBooks(removedBooks); RemoveBooks(removedBooks);
gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow;