Pause auto-scan on auth failure instead of spamming login dialogs

This commit is contained in:
Robert McRackan
2026-07-07 09:40:14 -04:00
parent ecc7e49a21
commit 3cdaa09aff
10 changed files with 239 additions and 75 deletions

View File

@@ -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.

View File

@@ -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();
}
}
}

View File

@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>

View 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;
}
}

View 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;
}

View File

@@ -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)

View File

@@ -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();

View 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();
}
}

View File

@@ -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

View File

@@ -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)