diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index c63d69d2..bfa51a1c 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -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> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions) + private static async Task> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin) { var tasks = new List>>(); @@ -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. diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 5c58cc2f..546b510e 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -26,8 +26,13 @@ public class ApiExtended private ApiExtended(Api api) => Api = api; + private static readonly SemaphoreSlim InteractiveLoginGate = new(1, 1); + /// 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. - public static async Task CreateAsync(Account account) + public static Task CreateAsync(Account account) + => CreateAsync(account, allowInteractiveLogin: true); + + public static async Task 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(); + } } } diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 6788ed92..3d72078c 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -3,6 +3,7 @@ net10.0 enable + enable diff --git a/Source/AudibleUtilities/AuthenticationExceptionHelper.cs b/Source/AudibleUtilities/AuthenticationExceptionHelper.cs new file mode 100644 index 00000000..1608b7d3 --- /dev/null +++ b/Source/AudibleUtilities/AuthenticationExceptionHelper.cs @@ -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; + } +} diff --git a/Source/AudibleUtilities/AuthenticationRequiredException.cs b/Source/AudibleUtilities/AuthenticationRequiredException.cs new file mode 100644 index 00000000..b9946b8b --- /dev/null +++ b/Source/AudibleUtilities/AuthenticationRequiredException.cs @@ -0,0 +1,14 @@ +namespace AudibleUtilities; + +/// +/// 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). +/// +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; +} diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Import.cs b/Source/LibationAvalonia/ViewModels/MainVM.Import.cs index 991bfe46..fabccf8d 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Import.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Import.cs @@ -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) diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs index 63255801..b548b902 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs @@ -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(); diff --git a/Source/LibationUiBase/AutoScanRunner.cs b/Source/LibationUiBase/AutoScanRunner.cs new file mode 100644 index 00000000..4e96885b --- /dev/null +++ b/Source/LibationUiBase/AutoScanRunner.cs @@ -0,0 +1,96 @@ +using ApplicationServices; +using AudibleUtilities; +using Serilog; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationUiBase; + +/// +/// Runs background library auto-scan without opening login UI. +/// Pauses the timer after an authentication failure until the user logs in manually. +/// +public sealed class AutoScanRunner +{ + private readonly Func isAutoScanEnabled; + private readonly Action pauseTimer; + private readonly Action resumeTimer; + private readonly Func? notifyAuthRequired; + + private bool pausedForAuthentication; + + public AutoScanRunner( + Func isAutoScanEnabled, + Action pauseTimer, + Action resumeTimer, + Func? 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(); + } +} diff --git a/Source/LibationWinForms/Form1.ScanAuto.cs b/Source/LibationWinForms/Form1.ScanAuto.cs index 5358e40e..cdb9a5cd 100644 --- a/Source/LibationWinForms/Form1.ScanAuto.cs +++ b/Source/LibationWinForms/Form1.ScanAuto.cs @@ -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 diff --git a/Source/LibationWinForms/Form1.ScanManual.cs b/Source/LibationWinForms/Form1.ScanManual.cs index 93e1e984..9784fb69 100644 --- a/Source/LibationWinForms/Form1.ScanManual.cs +++ b/Source/LibationWinForms/Form1.ScanManual.cs @@ -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)