mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-07-29 16:36:00 -04:00
Pause auto-scan on auth failure instead of spamming login dialogs
This commit is contained in:
@@ -62,7 +62,7 @@ public static class LibraryCommands
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
@@ -108,7 +108,10 @@ public static class LibraryCommands
|
||||
}
|
||||
|
||||
#region FULL LIBRARY scan and import
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
=> ImportAccountAsync(accounts, allowInteractiveLogin: true);
|
||||
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Account[]? accounts, bool allowInteractiveLogin)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
@@ -136,7 +139,7 @@ public static class LibraryCommands
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
@@ -272,7 +275,7 @@ public static class LibraryCommands
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
|
||||
@@ -288,11 +291,15 @@ public static class LibraryCommands
|
||||
try
|
||||
{
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||
}
|
||||
catch (Exception ex) when (!allowInteractiveLogin && AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Catch to allow other accounts to continue scanning.
|
||||
|
||||
@@ -26,8 +26,13 @@ public class ApiExtended
|
||||
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
private static readonly SemaphoreSlim InteractiveLoginGate = new(1, 1);
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||
public static Task<ApiExtended> CreateAsync(Account account)
|
||||
=> CreateAsync(account, allowInteractiveLogin: true);
|
||||
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
|
||||
@@ -46,25 +51,36 @@ public class ApiExtended
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!allowInteractiveLogin)
|
||||
throw new AuthenticationRequiredException(account, innerException: ex);
|
||||
|
||||
if (LoginChoiceFactory is null)
|
||||
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
|
||||
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
await InteractiveLoginGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
|
||||
return new ApiExtended(api);
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InteractiveLoginGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
27
Source/AudibleUtilities/AuthenticationExceptionHelper.cs
Normal file
27
Source/AudibleUtilities/AuthenticationExceptionHelper.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using AudibleApi.Authentication;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public static class AuthenticationExceptionHelper
|
||||
{
|
||||
public static bool IsAuthenticationFailure(Exception ex)
|
||||
{
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
return aggregate.InnerExceptions.Any(IsAuthenticationFailure)
|
||||
|| (aggregate.InnerException is not null && IsAuthenticationFailure(aggregate.InnerException));
|
||||
}
|
||||
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is AuthenticationRequiredException or LoginFailedException)
|
||||
return true;
|
||||
|
||||
if (current is InvalidOperationException { Message: var message }
|
||||
&& message.Contains("ADP token is null", StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
14
Source/AudibleUtilities/AuthenticationRequiredException.cs
Normal file
14
Source/AudibleUtilities/AuthenticationRequiredException.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Stored Audible credentials are missing or invalid and interactive login is required.
|
||||
/// Thrown instead of opening login UI when the caller disallows interactive login (e.g. auto-scan).
|
||||
/// </summary>
|
||||
public sealed class AuthenticationRequiredException : Exception
|
||||
{
|
||||
public Account? Account { get; }
|
||||
|
||||
public AuthenticationRequiredException(Account? account, string? message = null, Exception? innerException = null)
|
||||
: base(message ?? "Audible authentication is required.", innerException)
|
||||
=> Account = account;
|
||||
}
|
||||
@@ -212,6 +212,7 @@ public partial class MainVM
|
||||
try
|
||||
{
|
||||
var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
|
||||
autoScanRunner?.OnManualScanSucceeded();
|
||||
|
||||
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
|
||||
if (Configuration.Instance.ShowImportedStats && newAdded > 0)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -11,45 +13,37 @@ namespace LibationAvalonia.ViewModels;
|
||||
|
||||
partial class MainVM
|
||||
{
|
||||
private readonly InterruptableTimer autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5));
|
||||
private readonly InterruptableTimer autoScanTimer = new(TimeSpan.FromMinutes(5));
|
||||
private AutoScanRunner? autoScanRunner;
|
||||
|
||||
private void Configure_ScanAuto()
|
||||
{
|
||||
// subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI
|
||||
autoScanTimer.Elapsed += async (_, __) =>
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var accounts = persister.AccountsSettings
|
||||
.GetAll()
|
||||
.Where(a => a.LibraryScan)
|
||||
.ToArray();
|
||||
autoScanRunner = new AutoScanRunner(
|
||||
isAutoScanEnabled: () => Configuration.Instance.AutoScan,
|
||||
pauseTimer: () => autoScanTimer.Stop(),
|
||||
resumeTimer: () => autoScanTimer.PerformNow(),
|
||||
notifyAuthRequired: notifyAutoScanAuthRequiredAsync);
|
||||
|
||||
// in autoScan, new books SHALL NOT show dialog
|
||||
try
|
||||
{
|
||||
await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Serilog.Log.Information("Audible login attempt cancelled by user");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error invoking auto-scan");
|
||||
}
|
||||
};
|
||||
autoScanTimer.Elapsed += async (_, __) => await autoScanRunner.RunAsync();
|
||||
|
||||
// if enabled: begin on load
|
||||
MainWindow.Loaded += startAutoScan;
|
||||
|
||||
// if new 'default' account is added, run autoscan
|
||||
AccountsSettingsPersister.Saving += accountsPreSave;
|
||||
AccountsSettingsPersister.Saved += accountsPostSave;
|
||||
|
||||
// when autoscan setting is changed, update menu checkbox and run autoscan
|
||||
Configuration.Instance.PropertyChanged += startAutoScan;
|
||||
}
|
||||
|
||||
private async Task notifyAutoScanAuthRequiredAsync()
|
||||
{
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
"Libation could not refresh your Audible library because your login session expired.\n\n"
|
||||
+ "Background auto-scan has been paused. Use Import > Scan Library to log in again to resume periodic scans.",
|
||||
"Auto-scan paused - login required",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
|
||||
private List<(string AccountId, string LocaleName)>? preSaveDefaultAccounts;
|
||||
private List<(string AccountId, string LocaleName)> getDefaultAccounts()
|
||||
@@ -75,6 +69,7 @@ partial class MainVM
|
||||
[PropertyChangeFilter(nameof(Configuration.AutoScan))]
|
||||
private void startAutoScan(object? sender = null, EventArgs? e = null)
|
||||
{
|
||||
autoScanRunner?.OnAutoScanSettingChanged();
|
||||
AutoScanChecked = Configuration.Instance.AutoScan;
|
||||
if (AutoScanChecked)
|
||||
autoScanTimer.PerformNow();
|
||||
|
||||
96
Source/LibationUiBase/AutoScanRunner.cs
Normal file
96
Source/LibationUiBase/AutoScanRunner.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationUiBase;
|
||||
|
||||
/// <summary>
|
||||
/// Runs background library auto-scan without opening login UI.
|
||||
/// Pauses the timer after an authentication failure until the user logs in manually.
|
||||
/// </summary>
|
||||
public sealed class AutoScanRunner
|
||||
{
|
||||
private readonly Func<bool> isAutoScanEnabled;
|
||||
private readonly Action pauseTimer;
|
||||
private readonly Action resumeTimer;
|
||||
private readonly Func<Task>? notifyAuthRequired;
|
||||
|
||||
private bool pausedForAuthentication;
|
||||
|
||||
public AutoScanRunner(
|
||||
Func<bool> isAutoScanEnabled,
|
||||
Action pauseTimer,
|
||||
Action resumeTimer,
|
||||
Func<Task>? notifyAuthRequired = null)
|
||||
{
|
||||
this.isAutoScanEnabled = isAutoScanEnabled;
|
||||
this.pauseTimer = pauseTimer;
|
||||
this.resumeTimer = resumeTimer;
|
||||
this.notifyAuthRequired = notifyAuthRequired;
|
||||
}
|
||||
|
||||
public void OnManualScanSucceeded()
|
||||
{
|
||||
if (!pausedForAuthentication)
|
||||
return;
|
||||
|
||||
pausedForAuthentication = false;
|
||||
if (isAutoScanEnabled())
|
||||
resumeTimer();
|
||||
}
|
||||
|
||||
public void OnAutoScanSettingChanged()
|
||||
{
|
||||
if (!isAutoScanEnabled())
|
||||
pausedForAuthentication = false;
|
||||
}
|
||||
|
||||
public async Task RunAsync()
|
||||
{
|
||||
if (!isAutoScanEnabled() || pausedForAuthentication)
|
||||
return;
|
||||
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var accounts = persister.AccountsSettings
|
||||
.GetAll()
|
||||
.Where(a => a.LibraryScan)
|
||||
.ToArray();
|
||||
|
||||
if (accounts.Length == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts, allowInteractiveLogin: false));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log.Information("Audible login attempt cancelled by user");
|
||||
}
|
||||
catch (Exception ex) when (AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
|
||||
{
|
||||
await pauseForAuthenticationAsync(ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error invoking auto-scan");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task pauseForAuthenticationAsync(Exception ex)
|
||||
{
|
||||
if (pausedForAuthentication)
|
||||
return;
|
||||
|
||||
pausedForAuthentication = true;
|
||||
pauseTimer();
|
||||
|
||||
Log.Warning(ex, "Auto-scan paused: Audible login is required. Log in with Import > Scan Library to resume background scans.");
|
||||
|
||||
if (notifyAuthRequired is not null)
|
||||
await notifyAuthRequired();
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace LibationWinForms;
|
||||
|
||||
@@ -13,55 +15,58 @@ namespace LibationWinForms;
|
||||
public partial class Form1
|
||||
{
|
||||
private InterruptableTimer? autoScanTimer;
|
||||
private AutoScanRunner? autoScanRunner;
|
||||
|
||||
private void Configure_ScanAuto()
|
||||
{
|
||||
// creating InterruptableTimer inside 'Configure_' is a break from the pattern. As long as no one else needs to access or subscribe to it, this is ok
|
||||
// creating InterruptableTimer inside 'Configure_' is a break from the pattern. As long as no one else needs to access or subscribe to it, this is ok
|
||||
|
||||
autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5));
|
||||
autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5));
|
||||
autoScanRunner = new AutoScanRunner(
|
||||
isAutoScanEnabled: () => Configuration.Instance.AutoScan,
|
||||
pauseTimer: () => autoScanTimer?.Stop(),
|
||||
resumeTimer: () => autoScanTimer?.PerformNow(),
|
||||
notifyAuthRequired: notifyAutoScanAuthRequiredAsync);
|
||||
|
||||
// subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI
|
||||
autoScanTimer.Elapsed += async (_, __) =>
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var accounts = persister.AccountsSettings
|
||||
.GetAll()
|
||||
.Where(a => a.LibraryScan)
|
||||
.ToArray();
|
||||
autoScanTimer.Elapsed += async (_, __) => await autoScanRunner.RunAsync();
|
||||
|
||||
// in autoScan, new books SHALL NOT show dialog
|
||||
try
|
||||
{
|
||||
await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Serilog.Log.Information("Audible login attempt cancelled by user");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error invoking auto-scan");
|
||||
}
|
||||
};
|
||||
|
||||
// load init state to menu checkbox
|
||||
Load += updateAutoScanLibraryToolStripMenuItem;
|
||||
// if enabled: begin on load
|
||||
Shown += startAutoScan;
|
||||
|
||||
// if new 'default' account is added, run autoscan
|
||||
AccountsSettingsPersister.Saving += accountsPreSave;
|
||||
AccountsSettingsPersister.Saved += accountsPostSave;
|
||||
|
||||
Configuration.Instance.PropertyChanged += Configuration_PropertyChanged;
|
||||
}
|
||||
|
||||
private void notifyAutoScanAuthRequired()
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Libation could not refresh your Audible library because your login session expired.\n\n"
|
||||
+ "Background auto-scan has been paused. Use Import > Scan Library to log in again to resume periodic scans.",
|
||||
"Auto-scan paused - login required",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
|
||||
private Task notifyAutoScanAuthRequiredAsync()
|
||||
{
|
||||
if (InvokeRequired)
|
||||
{
|
||||
Invoke(notifyAutoScanAuthRequired);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
notifyAutoScanAuthRequired();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[PropertyChangeFilter(nameof(Configuration.AutoScan))]
|
||||
private void Configuration_PropertyChanged(object? sender, PropertyChangedEventArgsEx e)
|
||||
{
|
||||
// when autoscan setting is changed, update menu checkbox and run autoscan
|
||||
updateAutoScanLibraryToolStripMenuItem(sender, e);
|
||||
{
|
||||
// when autoscan setting is changed, update menu checkbox and run autoscan
|
||||
updateAutoScanLibraryToolStripMenuItem(sender, e);
|
||||
startAutoScan(sender, e);
|
||||
}
|
||||
|
||||
@@ -89,6 +94,7 @@ public partial class Form1
|
||||
|
||||
private void startAutoScan(object? sender = null, EventArgs? e = null)
|
||||
{
|
||||
autoScanRunner?.OnAutoScanSettingChanged();
|
||||
if (Configuration.Instance.AutoScan)
|
||||
autoScanTimer?.PerformNow();
|
||||
else
|
||||
|
||||
@@ -77,6 +77,7 @@ public partial class Form1
|
||||
try
|
||||
{
|
||||
var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
|
||||
autoScanRunner?.OnManualScanSucceeded();
|
||||
|
||||
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
|
||||
if (Configuration.Instance.ShowImportedStats && newAdded > 0)
|
||||
|
||||
Reference in New Issue
Block a user