diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs
index c52ca0a1..f7fc6a09 100644
--- a/Source/AppScaffolding/LibationScaffolding.cs
+++ b/Source/AppScaffolding/LibationScaffolding.cs
@@ -341,10 +341,10 @@ public static class LibationScaffolding
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
- => SearchEngineCommands.FullReIndex(libraryBooks);
+ => SearchEngineCommands.OnLibrarySizeChanged(libraryBooks);
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
- => SearchEngineCommands.UpdateBooks(books);
+ => SearchEngineCommands.OnBookUserDefinedItemCommitted(books);
}
public static VersionCheckResult GetLatestRelease()
diff --git a/Source/ApplicationServices/SearchEngineCommands.cs b/Source/ApplicationServices/SearchEngineCommands.cs
index ebf46368..00d1089b 100644
--- a/Source/ApplicationServices/SearchEngineCommands.cs
+++ b/Source/ApplicationServices/SearchEngineCommands.cs
@@ -44,9 +44,41 @@ public static class SearchEngineCommands
public static event EventHandler? SearchEngineUpdated;
+ ///
+ /// Occurs when the index could not be updated even after automatic repair, so it needs the user's help.
+ ///
+ public static event EventHandler? UpdateFailed;
+
#region Update
private static bool isUpdating;
+ /// Updates the index after books were added to or removed from the library.
+ public static void OnLibrarySizeChanged(List libraryBooks)
+ => tryUpdate(() => FullReIndex(libraryBooks));
+
+ /// Updates the index after book details, tags or statuses were committed.
+ public static void OnBookUserDefinedItemCommitted(IEnumerable books)
+ => tryUpdate(() => UpdateBooks(books));
+
+ ///
+ /// The database change that triggers an update is committed before the update runs, and this index is derived
+ /// from that database, so a failure here is reported instead of propagated. Letting it escape reported a
+ /// successful scan as "Error importing library", and, since this is the first subscriber to those events,
+ /// stopped the handlers that refresh the grid and the backup counts from running at all.
+ ///
+ private static void tryUpdate(Action update)
+ {
+ try
+ {
+ update();
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to update the search index. Library changes are saved; search and filter results may be stale until the next update succeeds.");
+ UpdateFailed?.Invoke(null, ex);
+ }
+ }
+
public static void UpdateBooks(IEnumerable books)
{
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
diff --git a/Source/AudibleUtilities/AccountCredentialStatus.cs b/Source/AudibleUtilities/AccountCredentialStatus.cs
new file mode 100644
index 00000000..eb304353
--- /dev/null
+++ b/Source/AudibleUtilities/AccountCredentialStatus.cs
@@ -0,0 +1,37 @@
+using Dinah.Core;
+
+namespace AudibleUtilities;
+
+/// Describes whether an account has usable stored Audible tokens.
+public static class AccountCredentialStatus
+{
+ ///
+ /// True when identity tokens are absent or carry no refresh token, meaning the account was never fully logged
+ /// in or its credentials were cleared, rather than merely holding an expired access token.
+ ///
+ public static bool LooksLikeMissingCredentials(Account? account)
+ {
+ if (account?.IdentityTokens is not { } tokens)
+ return true;
+
+ if (tokens.IsValid)
+ return false;
+
+ // without a refresh token there is nothing to renew from, so this needs a fresh login rather than a retry
+ return string.IsNullOrWhiteSpace(tokens.RefreshToken?.Value);
+ }
+
+ ///
+ /// Account label for dialogs shown to the person who owns the account. Not for logs: those get attached to
+ /// public issue reports, which is what is for.
+ ///
+ public static string FormatAccountLabel(Account? account)
+ {
+ if (account is null)
+ return "an Audible account";
+
+ return string.IsNullOrWhiteSpace(account.AccountName) || account.AccountName.EqualsInsensitive(account.AccountId)
+ ? $"'{account.AccountId}'"
+ : $"'{account.AccountName}' ({account.AccountId})";
+ }
+}
diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs
index 8155b445..2832add0 100644
--- a/Source/AudibleUtilities/ApiExtended.cs
+++ b/Source/AudibleUtilities/ApiExtended.cs
@@ -59,19 +59,27 @@ public class ApiExtended
{
if (!allowInteractiveLogin || LoginChoiceFactory is null)
{
+ // an account that was never logged in needs a first login, not a renewal, and saying so saves the
+ // user hunting for an expired session that never existed
+ var missingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account);
+
Serilog.Log.Logger.Error(ex,
- "Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
+ missingCredentials
+ ? "Stored credentials are missing or incomplete and interactive login is not available. {@DebugInfo}"
+ : "Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
new
{
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name,
AllowInteractiveLogin = allowInteractiveLogin,
- LoginChoiceFactorySet = LoginChoiceFactory is not null
+ LoginChoiceFactorySet = LoginChoiceFactory is not null,
+ LooksLikeMissingCredentials = missingCredentials
});
throw new AuthenticationRequiredException(
account,
- message: $"Stored credentials for '{account.AccountId}' could not be used"
+ message: $"Stored credentials for '{account.AccountId}' "
+ + (missingCredentials ? "are missing or incomplete" : "could not be used")
+ (LoginChoiceFactory is null
? " and interactive login is not available in this context (CLI/Docker)."
: " and interactive login was not allowed.")
diff --git a/Source/AudibleUtilities/AuthenticationExceptionHelper.cs b/Source/AudibleUtilities/AuthenticationExceptionHelper.cs
index 1608b7d3..a86dcdfe 100644
--- a/Source/AudibleUtilities/AuthenticationExceptionHelper.cs
+++ b/Source/AudibleUtilities/AuthenticationExceptionHelper.cs
@@ -24,4 +24,28 @@ public static class AuthenticationExceptionHelper
return false;
}
+
+ ///
+ /// Finds the in or its inner chain, which is
+ /// the one that knows which account needs a login.
+ ///
+ public static AuthenticationRequiredException? FindAuthenticationRequired(Exception ex)
+ {
+ for (var current = ex; current is not null; current = current.InnerException)
+ {
+ if (current is AuthenticationRequiredException auth)
+ return auth;
+ }
+
+ if (ex is AggregateException aggregate)
+ {
+ foreach (var inner in aggregate.InnerExceptions)
+ {
+ if (FindAuthenticationRequired(inner) is { } found)
+ return found;
+ }
+ }
+
+ return null;
+ }
}
diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs
index 825e7cc4..d1dac4f1 100644
--- a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs
+++ b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs
@@ -4,6 +4,7 @@ using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using LibationFileManager;
+using LibationUiBase;
using LibationUiBase.Forms;
using ReactiveUI;
using System;
@@ -56,22 +57,42 @@ partial class MainVM
public async Task EditQuickFiltersAsync() => await new LibationAvalonia.Dialogs.EditQuickFilters().ShowDialog(MainWindow);
public async Task PerformFilter(QuickFilters.NamedFilter? namedFilter)
{
- SelectedNamedFilter = namedFilter;
var tryFilter = namedFilter?.Filter;
+ var failure = await applyFilterAsync(namedFilter);
+ if (failure is null)
+ return;
+
+ Serilog.Log.Logger.Error(failure, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
+
+ if (SearchIndexRecovery.IsIndexUnavailable(failure))
+ await MessageBox.Show(SearchIndexRecovery.ManualRecoveryInstructions, SearchIndexRecovery.Caption, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ else
+ await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{failure.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
+
+ // Restore the last filter that worked, then give up on filtering entirely. Recursing into PerformFilter
+ // here never terminated when the search index rather than the query was at fault, because that fails for
+ // every filter including the one being restored. An empty filter never reaches the search engine.
+ if (lastGoodSearch.Length > 0 && await applyFilterAsync(lastGoodFilter) is null)
+ return;
+
+ await applyFilterAsync(new(string.Empty, null));
+ }
+
+ /// Applies a filter, returning the exception that stopped it, or null when it worked.
+ private async Task applyFilterAsync(QuickFilters.NamedFilter? namedFilter)
+ {
+ SelectedNamedFilter = namedFilter;
+
try
{
- await ProductsDisplay.Filter(tryFilter);
+ await ProductsDisplay.Filter(namedFilter?.Filter);
lastGoodSearch = namedFilter?.Filter ?? "";
+ return null;
}
catch (Exception ex)
{
- Serilog.Log.Logger.Error(ex, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
- await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
-
- // re-apply last good filter
- namedFilter = (namedFilter ?? new(string.Empty, null)) with { Filter = lastGoodSearch };
- await PerformFilter(namedFilter);
+ return ex;
}
}
diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs
index b548b902..ce7147af 100644
--- a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs
+++ b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs
@@ -34,13 +34,12 @@ partial class MainVM
Configuration.Instance.PropertyChanged += startAutoScan;
}
- private async Task notifyAutoScanAuthRequiredAsync()
+ private async Task notifyAutoScanAuthRequiredAsync(AuthenticationRequiredException ex)
{
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",
+ AutoScanAuthPrompt.FormatBody(ex),
+ AutoScanAuthPrompt.Caption,
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
diff --git a/Source/LibationAvalonia/ViewModels/MainVM.SearchIndex.cs b/Source/LibationAvalonia/ViewModels/MainVM.SearchIndex.cs
new file mode 100644
index 00000000..1e2a5a5c
--- /dev/null
+++ b/Source/LibationAvalonia/ViewModels/MainVM.SearchIndex.cs
@@ -0,0 +1,33 @@
+using ApplicationServices;
+using LibationUiBase;
+using LibationUiBase.Forms;
+using System;
+
+namespace LibationAvalonia.ViewModels;
+
+partial class MainVM
+{
+ private void Configure_SearchIndex()
+ => SearchEngineCommands.UpdateFailed += searchIndexUpdateFailed;
+
+ private async void searchIndexUpdateFailed(object? sender, Exception ex)
+ {
+ try
+ {
+ if (!SearchIndexRecovery.ShouldNotify())
+ return;
+
+ await MessageBox.Show(
+ MainWindow,
+ SearchIndexRecovery.ManualRecoveryInstructions,
+ SearchIndexRecovery.Caption,
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Warning);
+ }
+ catch (Exception dialogEx)
+ {
+ // nothing above this is allowed to fail: the library change that got us here already succeeded
+ Serilog.Log.Logger.Error(dialogEx, "Could not show the search index recovery instructions");
+ }
+ }
+}
diff --git a/Source/LibationAvalonia/ViewModels/MainVM.cs b/Source/LibationAvalonia/ViewModels/MainVM.cs
index 27e5d5bb..02972a7e 100644
--- a/Source/LibationAvalonia/ViewModels/MainVM.cs
+++ b/Source/LibationAvalonia/ViewModels/MainVM.cs
@@ -34,6 +34,7 @@ public partial class MainVM : ViewModelBase
Configure_Liberate();
Configure_ProcessQueue();
Configure_ScanAuto();
+ Configure_SearchIndex();
Configure_Settings();
Configure_VisibleBooks();
}
diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs
index b05ab9be..5ab1d4a0 100644
--- a/Source/LibationSearchEngine/SearchEngine.cs
+++ b/Source/LibationSearchEngine/SearchEngine.cs
@@ -80,58 +80,105 @@ public class SearchEngine
/// create new. ie: full re-index
public void CreateNewIndex(IEnumerable library, bool overwrite = true)
{
- const int maxRetries = 5;
- const int baseDelayMs = 400;
var libraryList = library.ToList();
- // Corruption (e.g. checksum mismatch in segments) is not fixed by waiting; clear and rebuild immediately.
- var corruptRebuildAttemptsRemaining = 2;
- // Exponential backoff retry: 400 ms, 800 ms, 1600 ms, etc
- // Total wait time before giving up: 12.4 sec
- for (var attempt = 0; attempt < maxRetries; attempt++)
+ // location of index/create the index
+ using var index = getIndex();
+
+ // analyzer for tokenizing text. same analyzer should be used for indexing and searching
+ using var analyzer = new StandardAnalyzer(Version);
+ using var ixWriter = openWriterForRebuild(index, analyzer, overwrite);
+
+ foreach (var libraryBook in libraryList)
+ {
+ var doc = createBookIndexDocument(libraryBook);
+ ixWriter.AddDocument(doc);
+ }
+ }
+
+ ///
+ /// Opens the writer for a full re-index, waiting out lock conflicts and repairing an index Lucene cannot open.
+ ///
+ private IndexWriter openWriterForRebuild(Lucene.Net.Store.Directory index, StandardAnalyzer analyzer, bool overwrite)
+ {
+ // Exponential backoff for lock conflicts: 400 ms, 800 ms, 1600 ms, 3200 ms. 6.4 sec total before giving up.
+ const int maxAttempts = 5;
+ const int baseDelayMs = 400;
+ var repairsRemaining = 1;
+
+ for (var attempt = 1; ; attempt++)
{
try
{
- createNewIndexCore(libraryList, overwrite);
- return;
+ var createNewIndex = overwrite || !IndexReader.IndexExists(index);
+ return new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED);
}
- catch (CorruptIndexException ex) when (corruptRebuildAttemptsRemaining-- > 0)
+ catch (Exception ex) when (isTransientLockConflict(ex) && attempt < maxAttempts)
{
- Serilog.Log.Logger.Warning(ex, "Lucene search index corrupt at {Path}. Clearing for rebuild.", SearchEngineDirectory);
- deleteAllSearchIndexFiles(SearchEngineDirectory);
- attempt--;
- }
- catch (IOException ex) when (attempt < maxRetries - 1 && ex is not CorruptIndexException)
- {
- var delayMs = baseDelayMs * (1 << attempt);
- // write.lock can be held by another process (e.g. second Libation instance, antivirus) or a prior writer that did not release. Retry after delay.
- Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt + 1, maxRetries, delayMs);
+ var delayMs = baseDelayMs * (1 << (attempt - 1));
+ Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt, maxAttempts, delayMs);
Thread.Sleep(delayMs);
}
- catch (UnauthorizedAccessException ex) when (attempt < maxRetries - 1)
+ catch (Exception ex) when (!isTransientLockConflict(ex) && repairsRemaining-- > 0)
{
- var delayMs = baseDelayMs * (1 << attempt);
- // Windows may report "file in use" as UnauthorizedAccessException
- Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt + 1, maxRetries, delayMs);
- Thread.Sleep(delayMs);
+ // Passing overwrite/create to IndexWriter does not repair an unreadable index: its IndexFileDeleter
+ // reads every segments_* file in the directory and only tolerates missing ones, so a single unreadable
+ // segments file -- even a stale one from an older commit -- keeps the whole directory unusable until it
+ // is deleted. This is a full re-index, so nothing on disk is worth preserving.
+ // warning, not error: the index is a cache of the database, so rebuilding it resolves this
+ Serilog.Log.Logger.Warning(ex, "Search index at {Path} could not be opened. Deleting it and rebuilding from the library.", SearchEngineDirectory);
+
+ if (deleteAllSearchIndexFiles(SearchEngineDirectory) is { Count: > 0 } undeletable)
+ throw new IOException($"The search index at '{SearchEngineDirectory}' is damaged and could not be deleted automatically. Close Libation, delete that folder, then restart. Undeletable file(s): {string.Join(", ", undeletable)}", ex);
+
+ // the repair, not the failed open, gets a fresh allowance of lock retries
+ attempt = 0;
}
}
}
///
- /// Lucene 3 parses segments_* filenames in the index directory. Cloud sync (e.g. OneDrive) can leave debris
- /// or conflict copies whose names break that parser, throwing with this message shape.
- /// Actual error is likely to be something like: Invalid or unsupported character in number, hence this string check.
- /// (e.g. checksum mismatch in segments) is also recoverable by deleting the index and rebuilding.
+ /// True when the index could not be opened because something else is holding it -- a second Libation instance,
+ /// antivirus, a backup agent -- which is worth waiting out rather than repairing.
+ /// derives from , so a bare
+ /// IOException check cannot tell a lock conflict apart from a damaged index. Nor can the type alone:
+ /// Windows raises a sharing violation on the lock file before Lucene turns it into a
+ /// , so a plain naming the lock file counts
+ /// too. Matching the file name rather than the wording keeps that working on non-English Windows.
+ ///
+ private static bool isTransientLockConflict(Exception ex)
+ => ex is LockObtainFailedException
+ || (ex is IOException && ex.Message.Contains(IndexWriter.WRITE_LOCK_NAME, StringComparison.OrdinalIgnoreCase))
+ // Windows may report "file in use" as UnauthorizedAccessException
+ || ex is UnauthorizedAccessException;
+
+ ///
+ /// True when Lucene cannot read the index and the only cure is deleting it and rebuilding from the database.
+ ///
+ /// - : eg. checksum mismatch in the segments file.
+ /// - A truncated or zero-length segments_* file, which Lucene 3 reports as a plain
+ /// from BufferedIndexInput.Refill rather than as a
+ /// . Matched by message because the type is shared with
+ /// , which must keep retrying instead.
+ /// - Lucene 3 parses segments_* filenames in the index directory. Cloud sync (eg. OneDrive) can leave
+ /// debris or conflict copies whose names break that parser, throwing with a
+ /// message like "Invalid or unsupported character in number", hence this string check.
+ ///
///
public static bool IsRecoverableCorruptIndexException(Exception ex)
=> ex is CorruptIndexException
+ || (ex is IOException && !isTransientLockConflict(ex) && ex.Message.Contains("read past EOF", StringComparison.OrdinalIgnoreCase))
|| (ex is ArgumentException aex && aex.Message.Contains("character in number", StringComparison.OrdinalIgnoreCase));
- private static void deleteAllSearchIndexFiles(string searchEngineDirectory)
+ ///
+ /// Best-effort delete of everything under the index directory. Returns the segments_* files that survived:
+ /// Lucene rebuilds happily in an empty directory, and ignores leftover segment data, but any segments file it
+ /// cannot read keeps the directory permanently unusable.
+ ///
+ private static List deleteAllSearchIndexFiles(string searchEngineDirectory)
{
if (!System.IO.Directory.Exists(searchEngineDirectory))
- return;
+ return [];
foreach (var file in System.IO.Directory.GetFiles(searchEngineDirectory, "*", SearchOption.AllDirectories))
FileUtility.TrySaferDelete(file);
@@ -147,39 +194,10 @@ public class SearchEngine
Serilog.Log.Logger.Warning(ex, "Could not remove search index subdirectory {Dir}", dir);
}
}
- }
- private void createNewIndexCore(List library, bool overwrite)
- {
- bool indexExists;
- using (var indexProbe = getIndex())
- {
- try
- {
- indexExists = IndexReader.IndexExists(indexProbe);
- }
- catch (Exception ex) when (IsRecoverableCorruptIndexException(ex))
- {
- Serilog.Log.Logger.Warning(ex, "Lucene search index at {Path} is unreadable or corrupt (often cloud-sync debris or a partial write). Clearing it for rebuild.", SearchEngineDirectory);
- indexExists = false;
- }
- }
-
- if (!indexExists)
- deleteAllSearchIndexFiles(SearchEngineDirectory);
-
- // location of index/create the index
- using var index = getIndex();
- var createNewIndex = overwrite || !indexExists;
-
- // analyzer for tokenizing text. same analyzer should be used for indexing and searching
- using var analyzer = new StandardAnalyzer(Version);
- using var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED);
- foreach (var libraryBook in library)
- {
- var doc = createBookIndexDocument(libraryBook);
- ixWriter.AddDocument(doc);
- }
+ return System.IO.Directory.Exists(searchEngineDirectory)
+ ? [.. System.IO.Directory.GetFiles(searchEngineDirectory, "segments*", SearchOption.AllDirectories)]
+ : [];
}
public SearchEngine(string? directory = null)
diff --git a/Source/LibationUiBase/AutoScanAuthPrompt.cs b/Source/LibationUiBase/AutoScanAuthPrompt.cs
new file mode 100644
index 00000000..7603edf5
--- /dev/null
+++ b/Source/LibationUiBase/AutoScanAuthPrompt.cs
@@ -0,0 +1,23 @@
+using AudibleUtilities;
+using System;
+
+namespace LibationUiBase;
+
+/// Shared copy for the dialog shown when auto-scan pauses itself waiting for a login.
+public static class AutoScanAuthPrompt
+{
+ public const string Caption = "Auto-scan paused - login required";
+
+ public static string FormatBody(AuthenticationRequiredException ex)
+ {
+ ArgumentNullException.ThrowIfNull(ex);
+
+ var account = AccountCredentialStatus.FormatAccountLabel(ex.Account);
+ var cause = AccountCredentialStatus.LooksLikeMissingCredentials(ex.Account)
+ ? "that account has never been logged in, or its stored credentials are missing"
+ : "the stored login for that account expired";
+
+ return $"Libation could not refresh the Audible library for {account} because {cause}.\n\n"
+ + "Background auto-scan has been paused. Use Import > Scan Library to log in for that account and resume periodic scans.";
+ }
+}
diff --git a/Source/LibationUiBase/AutoScanRunner.cs b/Source/LibationUiBase/AutoScanRunner.cs
index 4e96885b..20b4c52e 100644
--- a/Source/LibationUiBase/AutoScanRunner.cs
+++ b/Source/LibationUiBase/AutoScanRunner.cs
@@ -16,7 +16,7 @@ public sealed class AutoScanRunner
private readonly Func isAutoScanEnabled;
private readonly Action pauseTimer;
private readonly Action resumeTimer;
- private readonly Func? notifyAuthRequired;
+ private readonly Func? notifyAuthRequired;
private bool pausedForAuthentication;
@@ -24,7 +24,7 @@ public sealed class AutoScanRunner
Func isAutoScanEnabled,
Action pauseTimer,
Action resumeTimer,
- Func? notifyAuthRequired = null)
+ Func? notifyAuthRequired = null)
{
this.isAutoScanEnabled = isAutoScanEnabled;
this.pauseTimer = pauseTimer;
@@ -72,7 +72,11 @@ public sealed class AutoScanRunner
}
catch (Exception ex) when (AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
{
- await pauseForAuthenticationAsync(ex);
+ // LoginFailedException and the "ADP token is null" case do not name an account, so fall back to one
+ // that at least carries the original failure
+ await pauseForAuthenticationAsync(
+ AuthenticationExceptionHelper.FindAuthenticationRequired(ex)
+ ?? new AuthenticationRequiredException(account: null, message: ex.Message, innerException: ex));
}
catch (Exception ex)
{
@@ -80,7 +84,7 @@ public sealed class AutoScanRunner
}
}
- private async Task pauseForAuthenticationAsync(Exception ex)
+ private async Task pauseForAuthenticationAsync(AuthenticationRequiredException ex)
{
if (pausedForAuthentication)
return;
@@ -88,9 +92,12 @@ public sealed class AutoScanRunner
pausedForAuthentication = true;
pauseTimer();
- Log.Warning(ex, "Auto-scan paused: Audible login is required. Log in with Import > Scan Library to resume background scans.");
+ // masked, not the label the dialog uses: log files get attached to public issue reports
+ Log.Warning(ex,
+ "Auto-scan paused: Audible login is required for {Account}. Log in with Import > Scan Library to resume background scans.",
+ ex.Account?.MaskedLogEntry ?? "[unknown account]");
if (notifyAuthRequired is not null)
- await notifyAuthRequired();
+ await notifyAuthRequired(ex);
}
}
diff --git a/Source/LibationUiBase/SearchIndexRecovery.cs b/Source/LibationUiBase/SearchIndexRecovery.cs
new file mode 100644
index 00000000..79b3951e
--- /dev/null
+++ b/Source/LibationUiBase/SearchIndexRecovery.cs
@@ -0,0 +1,43 @@
+using LibationSearchEngine;
+using System;
+using System.IO;
+using System.Threading;
+
+namespace LibationUiBase;
+
+///
+/// Shared copy for telling the user that Libation's search index needs to be deleted by hand.
+/// Reached only after the automatic delete-and-rebuild has already failed.
+///
+public static class SearchIndexRecovery
+{
+ public const string Caption = "Search index needs attention";
+
+ public const string ManualRecoveryInstructions
+ = "Libation could not use its search index, and could not repair it automatically. "
+ + "Your library itself is fine; only searching and filtering are affected.\n\n"
+ + "To fix it by hand:\n"
+ + "1. In Settings, click 'Open log folder'\n"
+ + "2. Close Libation\n"
+ + "3. Delete the SearchEngine folder you find there\n"
+ + "4. Start Libation again";
+
+ ///
+ /// True when a search failed because the index could not be reached, rather than because the query was
+ /// malformed. A damaged index, a held write.lock and a permission problem all arrive as IO-family exceptions,
+ /// plus the cloud-sync debris that Lucene reports as an ; a query Lucene cannot
+ /// parse arrives as none of those. Getting this backwards would send the user hunting for a typo in a query
+ /// that was fine.
+ ///
+ public static bool IsIndexUnavailable(Exception ex)
+ => ex is IOException or UnauthorizedAccessException
+ || SearchEngine.IsRecoverableCorruptIndexException(ex);
+
+ private static int notified;
+
+ ///
+ /// True the first time the index fails in this session, false afterwards. A damaged index fails on every
+ /// library change, and these steps only need following once.
+ ///
+ public static bool ShouldNotify() => Interlocked.Exchange(ref notified, 1) == 0;
+}
diff --git a/Source/LibationWinForms/Form1.Filter.cs b/Source/LibationWinForms/Form1.Filter.cs
index 4b0350f0..3c3da6ca 100644
--- a/Source/LibationWinForms/Form1.Filter.cs
+++ b/Source/LibationWinForms/Form1.Filter.cs
@@ -1,4 +1,5 @@
-using LibationWinForms.Dialogs;
+using LibationUiBase;
+using LibationWinForms.Dialogs;
using System;
using System.Windows.Forms;
@@ -29,6 +30,28 @@ public partial class Form1
private string? lastGoodFilter = null;
private void performFilter(string? filterString)
+ {
+ if (applyFilter(filterString) is not Exception failure)
+ return;
+
+ Serilog.Log.Logger.Error(failure, "Error performing filtering. {@DebugInfo}", new { filterString, lastGoodFilter });
+
+ if (SearchIndexRecovery.IsIndexUnavailable(failure))
+ MessageBox.Show(this, SearchIndexRecovery.ManualRecoveryInstructions, SearchIndexRecovery.Caption, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ else
+ MessageBox.Show(this, $"Bad filter string:\r\n\r\n{failure.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
+
+ // Restore the last filter that worked, then give up on filtering entirely. Recursing into performFilter
+ // here never terminated when the search index rather than the query was at fault, because that fails for
+ // every filter including the one being restored. An empty filter never reaches the search engine.
+ if (!string.IsNullOrEmpty(lastGoodFilter) && applyFilter(lastGoodFilter) is null)
+ return;
+
+ applyFilter(string.Empty);
+ }
+
+ /// Applies a filter, returning the exception that stopped it, or null when it worked.
+ private Exception? applyFilter(string? filterString)
{
this.filterSearchTb.Text = filterString;
@@ -36,13 +59,11 @@ public partial class Form1
{
productsDisplay.Filter(filterString);
lastGoodFilter = filterString;
+ return null;
}
catch (Exception ex)
{
- MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
-
- // re-apply last good filter
- performFilter(lastGoodFilter);
+ return ex;
}
}
diff --git a/Source/LibationWinForms/Form1.ScanAuto.cs b/Source/LibationWinForms/Form1.ScanAuto.cs
index cdb9a5cd..55b4d8e5 100644
--- a/Source/LibationWinForms/Form1.ScanAuto.cs
+++ b/Source/LibationWinForms/Form1.ScanAuto.cs
@@ -39,26 +39,25 @@ public partial class Form1
Configuration.Instance.PropertyChanged += Configuration_PropertyChanged;
}
- private void notifyAutoScanAuthRequired()
+ private void notifyAutoScanAuthRequired(AuthenticationRequiredException ex)
{
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",
+ AutoScanAuthPrompt.FormatBody(ex),
+ AutoScanAuthPrompt.Caption,
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
- private Task notifyAutoScanAuthRequiredAsync()
+ private Task notifyAutoScanAuthRequiredAsync(AuthenticationRequiredException ex)
{
if (InvokeRequired)
{
- Invoke(notifyAutoScanAuthRequired);
+ Invoke(() => notifyAutoScanAuthRequired(ex));
return Task.CompletedTask;
}
- notifyAutoScanAuthRequired();
+ notifyAutoScanAuthRequired(ex);
return Task.CompletedTask;
}
diff --git a/Source/LibationWinForms/Form1.SearchIndex.cs b/Source/LibationWinForms/Form1.SearchIndex.cs
new file mode 100644
index 00000000..91460e0c
--- /dev/null
+++ b/Source/LibationWinForms/Form1.SearchIndex.cs
@@ -0,0 +1,42 @@
+using ApplicationServices;
+using LibationUiBase;
+using System;
+using System.Windows.Forms;
+
+namespace LibationWinForms;
+
+public partial class Form1
+{
+ private void Configure_SearchIndex()
+ => SearchEngineCommands.UpdateFailed += searchIndexUpdateFailed;
+
+ private void searchIndexUpdateFailed(object? sender, Exception ex)
+ {
+ try
+ {
+ if (!SearchIndexRecovery.ShouldNotify())
+ return;
+
+ if (InvokeRequired)
+ {
+ BeginInvoke(showSearchIndexRecoveryInstructions);
+ return;
+ }
+
+ showSearchIndexRecoveryInstructions();
+ }
+ catch (Exception dialogEx)
+ {
+ // nothing above this is allowed to fail: the library change that got us here already succeeded
+ Serilog.Log.Logger.Error(dialogEx, "Could not show the search index recovery instructions");
+ }
+ }
+
+ private void showSearchIndexRecoveryInstructions()
+ => MessageBox.Show(
+ this,
+ SearchIndexRecovery.ManualRecoveryInstructions,
+ SearchIndexRecovery.Caption,
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Warning);
+}
diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs
index 92a56e9d..5f9c7c06 100644
--- a/Source/LibationWinForms/Form1.cs
+++ b/Source/LibationWinForms/Form1.cs
@@ -37,6 +37,7 @@ public partial class Form1 : Form
// eg: if one of these init'd productsGrid, then another can't reliably subscribe to it
Configure_BackupCounts();
Configure_ScanAuto();
+ Configure_SearchIndex();
Configure_ScanNotification();
Configure_VisibleBooks();
Configure_QuickFilters();
diff --git a/Source/_Tests/ApplicationServices.Tests/SearchIndexUpdateGuardTests.cs b/Source/_Tests/ApplicationServices.Tests/SearchIndexUpdateGuardTests.cs
new file mode 100644
index 00000000..838b2840
--- /dev/null
+++ b/Source/_Tests/ApplicationServices.Tests/SearchIndexUpdateGuardTests.cs
@@ -0,0 +1,95 @@
+using ApplicationServices;
+using AssertionHelper;
+using DataLayer;
+using LibationFileManager;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace SearchIndexUpdateGuardTests;
+
+///
+/// A library change is committed to the database before the search index is updated, so a failure to update that
+/// index must be reported rather than propagated. Letting it escape reported a successful scan as
+/// "Error importing library" and starved the event's remaining subscribers.
+///
+[TestClass]
+[DoNotParallelize]
+public class SearchIndexUpdateGuardTests
+{
+ private string tempLibationFiles = string.Empty;
+ private readonly List reportedFailures = [];
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ tempLibationFiles = Path.Combine(Path.GetTempPath(), $"libation-search-index-guard-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempLibationFiles);
+
+ // A fresh Configuration resolves LibationFiles from this variable, so the index lands in the temp dir.
+ Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, tempLibationFiles);
+ Configuration.CreateMockInstance();
+
+ SearchEngineCommands.UpdateFailed += recordFailure;
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ SearchEngineCommands.UpdateFailed -= recordFailure;
+ Configuration.RestoreSingletonInstance();
+ Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, null);
+
+ try
+ {
+ Directory.Delete(tempLibationFiles, recursive: true);
+ }
+ catch (IOException)
+ {
+ // a leftover temp directory is not worth failing a test over
+ }
+ }
+
+ private void recordFailure(object? sender, Exception ex) => reportedFailures.Add(ex);
+
+ /// Occupying the SearchEngine path with a file leaves the engine nowhere to build its index.
+ private void blockTheIndexDirectory()
+ => File.WriteAllText(Path.Combine(tempLibationFiles, "SearchEngine"), "not a directory");
+
+ private static List library()
+ {
+ var contributor = Contributor.GetEmpty();
+ var book = new Book(new AudibleProductId("B0TEST0001"), "Hound of the Baskervilles", null, null, 1, ContentType.Product, [contributor], [contributor], "us");
+ return [new LibraryBook(book, new DateTime(2026, 8, 15), "account")];
+ }
+
+ [TestMethod]
+ public void a_library_size_change_survives_an_unusable_search_index()
+ {
+ blockTheIndexDirectory();
+
+ SearchEngineCommands.OnLibrarySizeChanged(library());
+
+ reportedFailures.Should().HaveCount(1);
+ }
+
+ [TestMethod]
+ public void a_book_detail_change_survives_an_unusable_search_index()
+ {
+ blockTheIndexDirectory();
+
+ SearchEngineCommands.OnBookUserDefinedItemCommitted(library());
+
+ reportedFailures.Should().HaveCount(1);
+ }
+
+ [TestMethod]
+ public void a_successful_update_reports_no_failure()
+ {
+ SearchEngineCommands.OnLibrarySizeChanged(library());
+
+ reportedFailures.Should().HaveCount(0);
+ Directory.Exists(Path.Combine(tempLibationFiles, "SearchEngine")).Should().BeTrue();
+ }
+}
diff --git a/Source/_Tests/AudibleUtilities.Tests/AccountCredentialStatusTests.cs b/Source/_Tests/AudibleUtilities.Tests/AccountCredentialStatusTests.cs
new file mode 100644
index 00000000..1adb115e
--- /dev/null
+++ b/Source/_Tests/AudibleUtilities.Tests/AccountCredentialStatusTests.cs
@@ -0,0 +1,74 @@
+using AssertionHelper;
+using AudibleApi;
+using AudibleApi.Authorization;
+using AudibleApi.Cryptography;
+using AudibleUtilities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+
+namespace AudibleUtilities.Tests;
+
+[TestClass]
+public class AccountCredentialStatusTests
+{
+ private static Account registeredAccount()
+ {
+ var identity = new Identity(Localization.Get("us"));
+ identity.Update(
+ new PrivateKey(RSA.Create(2048).ExportRSAPrivateKeyPem()),
+ new AdpToken("{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}"),
+ new AccessToken("Atna|_CHAR_ACCESS_", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
+ new RefreshToken("Atnr|_CHAR_REFRESH_"),
+ new List> { new("session-id", "cookie-value") },
+ deviceSerialNumber: "device-serial",
+ deviceType: "device-type",
+ amazonAccountId: "amzn-account",
+ deviceName: "device-name",
+ storeAuthenticationCookie: "store-auth-cookie");
+
+ return new Account("user@example.com") { AccountName = "Jade", IdentityTokens = identity };
+ }
+
+ [TestMethod]
+ public void no_account_looks_like_missing_credentials()
+ => AccountCredentialStatus.LooksLikeMissingCredentials(null).Should().BeTrue();
+
+ [TestMethod]
+ public void an_account_that_was_never_logged_in_looks_like_missing_credentials()
+ => AccountCredentialStatus.LooksLikeMissingCredentials(new Account("user@example.com")).Should().BeTrue();
+
+ /// A bare Identity has a locale but no tokens to renew from, so it needs a first login.
+ [TestMethod]
+ public void tokens_without_a_refresh_token_look_like_missing_credentials()
+ {
+ var account = new Account("user@example.com") { IdentityTokens = new Identity(Localization.Get("us")) };
+
+ AccountCredentialStatus.LooksLikeMissingCredentials(account).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void a_registered_account_does_not_look_like_missing_credentials()
+ => AccountCredentialStatus.LooksLikeMissingCredentials(registeredAccount()).Should().BeFalse();
+
+ [TestMethod]
+ public void the_label_pairs_a_friendly_name_with_the_id()
+ => AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com") { AccountName = "Jade" })
+ .Should().Be("'Jade' (user@example.com)");
+
+ [TestMethod]
+ public void the_label_falls_back_to_the_id_alone()
+ => AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com"))
+ .Should().Be("'user@example.com'");
+
+ /// Accounts default their name to their id, and "'x' (x)" reads like a mistake.
+ [TestMethod]
+ public void the_label_does_not_repeat_an_id_used_as_the_name()
+ => AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com") { AccountName = "USER@example.com" })
+ .Should().Be("'user@example.com'");
+
+ [TestMethod]
+ public void the_label_stays_readable_without_an_account()
+ => AccountCredentialStatus.FormatAccountLabel(null).Should().Be("an Audible account");
+}
diff --git a/Source/_Tests/AudibleUtilities.Tests/FindAuthenticationRequiredTests.cs b/Source/_Tests/AudibleUtilities.Tests/FindAuthenticationRequiredTests.cs
new file mode 100644
index 00000000..e72f3a18
--- /dev/null
+++ b/Source/_Tests/AudibleUtilities.Tests/FindAuthenticationRequiredTests.cs
@@ -0,0 +1,46 @@
+using AssertionHelper;
+using AudibleUtilities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+
+namespace AudibleUtilities.Tests;
+
+///
+/// The scan wraps failures on the way up, so the exception that knows which account needs a login has to be dug
+/// back out before the auto-scan dialog can name it.
+///
+[TestClass]
+public class FindAuthenticationRequiredTests
+{
+ [TestMethod]
+ public void the_exception_itself_is_returned()
+ {
+ var auth = new AuthenticationRequiredException(new Account("user@example.com"));
+
+ AuthenticationExceptionHelper.FindAuthenticationRequired(auth).Should().BeSameAs(auth);
+ }
+
+ [TestMethod]
+ public void a_wrapped_exception_is_found()
+ {
+ var auth = new AuthenticationRequiredException(new Account("user@example.com"), "need login");
+ var wrapped = new Exception("Error importing library", new Exception("inner", auth));
+
+ AuthenticationExceptionHelper.FindAuthenticationRequired(wrapped).Should().BeSameAs(auth);
+ }
+
+ /// Scanning several accounts at once surfaces failures as an AggregateException.
+ [TestMethod]
+ public void an_aggregated_exception_is_found()
+ {
+ var auth = new AuthenticationRequiredException(new Account("user@example.com"), "need login");
+ var aggregate = new AggregateException(new InvalidOperationException("unrelated"), auth);
+
+ AuthenticationExceptionHelper.FindAuthenticationRequired(aggregate).Should().BeSameAs(auth);
+ }
+
+ [TestMethod]
+ public void an_unrelated_exception_yields_nothing()
+ => AuthenticationExceptionHelper.FindAuthenticationRequired(new InvalidOperationException("unrelated"))
+ .Should().BeNull();
+}
diff --git a/Source/_Tests/LibationSearchEngine.Tests/CorruptIndexRecoveryTests.cs b/Source/_Tests/LibationSearchEngine.Tests/CorruptIndexRecoveryTests.cs
new file mode 100644
index 00000000..82146211
--- /dev/null
+++ b/Source/_Tests/LibationSearchEngine.Tests/CorruptIndexRecoveryTests.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using AssertionHelper;
+using DataLayer;
+using LibationSearchEngine;
+using Lucene.Net.Index;
+using Lucene.Net.Store;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Directory = System.IO.Directory;
+
+namespace SearchEngineTests;
+
+///
+/// A damaged search index used to block library imports forever: Lucene 3 reports an unreadable segments file as a
+/// plain , which Libation mistook for a write.lock conflict and retried instead of repairing,
+/// and passing create/overwrite to does not repair it either.
+///
+[TestClass]
+public class CorruptIndexRecoveryTests
+{
+ private string indexDirectory = null!;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ indexDirectory = Path.Combine(Path.GetTempPath(), "LibationSearchEngineTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(indexDirectory);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ try
+ {
+ if (Directory.Exists(indexDirectory))
+ Directory.Delete(indexDirectory, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Windows refuses to delete a file Lucene still holds open, and a leftover temp directory is not
+ // worth failing a test over
+ }
+ }
+
+ private static LibraryBook book(string asin, string title)
+ {
+ var contributor = Contributor.GetEmpty();
+ var b = new Book(new AudibleProductId(asin), title, null, null, 1, ContentType.Product, [contributor], [contributor], "us");
+ return new LibraryBook(b, new DateTime(2026, 8, 15), "account");
+ }
+
+ private static readonly List library
+ = [book("B0TEST0001", "Hound of the Baskervilles"), book("B0TEST0002", "Sign of the Four")];
+
+ private SearchEngine reIndex()
+ {
+ var engine = new SearchEngine(indexDirectory);
+ engine.CreateNewIndex(library);
+ return engine;
+ }
+
+ private void assertIndexIsUsable()
+ {
+ var engine = new SearchEngine(indexDirectory);
+ engine.Search("Baskervilles").Docs.Select(d => d.ProductId).Should().BeEquivalentTo(["B0TEST0001"]);
+ engine.Search(SearchEngine.ALL_QUERY).Docs.Should().HaveCount(2);
+ }
+
+ private string currentSegmentsFile()
+ => Directory.GetFiles(indexDirectory, "segments_*").OrderBy(f => f.Length).ThenBy(f => f).Last();
+
+ /// Reproduces the state Lucene leaves behind when a commit is interrupted, eg. by a crash or power loss.
+ private void truncateCurrentSegmentsFile(int keepBytes)
+ {
+ var file = currentSegmentsFile();
+ File.WriteAllBytes(file, File.ReadAllBytes(file).Take(keepBytes).ToArray());
+ }
+
+ private static Exception exceptionFromOpeningWriter(string directory)
+ {
+ using var index = FSDirectory.Open(directory);
+ using var analyzer = new Lucene.Net.Analysis.Standard.StandardAnalyzer(SearchEngine.Version);
+ return Assert.ThrowsExactly(() => new IndexWriter(index, analyzer, true, IndexWriter.MaxFieldLength.UNLIMITED).Dispose());
+ }
+
+ [TestMethod]
+ public void full_reindex_builds_a_searchable_index()
+ {
+ reIndex();
+ assertIndexIsUsable();
+ }
+
+ [TestMethod]
+ public void full_reindex_repairs_a_zero_length_segments_file()
+ {
+ reIndex();
+ truncateCurrentSegmentsFile(0);
+
+ reIndex();
+ assertIndexIsUsable();
+ }
+
+ [TestMethod]
+ public void full_reindex_repairs_a_partially_written_segments_file()
+ {
+ reIndex();
+ truncateCurrentSegmentsFile(File.ReadAllBytes(currentSegmentsFile()).Length / 2);
+
+ reIndex();
+ assertIndexIsUsable();
+ }
+
+ ///
+ /// The nastiest variant: the current commit is intact, so the index looks fine, but IndexWriter's IndexFileDeleter
+ /// reads every segments_* file it finds and one unreadable leftover poisons the whole directory.
+ ///
+ [TestMethod]
+ public void full_reindex_repairs_a_stale_unreadable_segments_file_beside_a_valid_commit()
+ {
+ reIndex();
+ File.WriteAllBytes(Path.Combine(indexDirectory, "segments_9"), []);
+
+ reIndex();
+ assertIndexIsUsable();
+ }
+
+ [TestMethod]
+ public void full_reindex_repairs_a_checksum_mismatch()
+ {
+ reIndex();
+ var file = currentSegmentsFile();
+ var bytes = File.ReadAllBytes(file);
+ bytes[^1] ^= 0xFF;
+ File.WriteAllBytes(file, bytes);
+
+ reIndex();
+ assertIndexIsUsable();
+ }
+
+ /// Cloud sync (eg. OneDrive) leaves conflict copies whose names Lucene 3's segments parser rejects.
+ [TestMethod]
+ public void full_reindex_repairs_cloud_sync_debris()
+ {
+ reIndex();
+ File.WriteAllText(Path.Combine(indexDirectory, "segments_2 (1)"), "conflict copy");
+
+ reIndex();
+ assertIndexIsUsable();
+ }
+
+ ///
+ /// A garbled segments.gen sends Lucene looking for an absurd generation, and Lucene 3's base-36 filename
+ /// formatter overruns its buffer on the way. Damage does not always announce itself as an IOException.
+ ///
+ [TestMethod]
+ public void full_reindex_repairs_a_segments_gen_pointing_at_a_bogus_generation()
+ {
+ reIndex();
+ var gen = Path.Combine(indexDirectory, "segments.gen");
+ // format(int) then the generation as a long, written twice; corrupt the high bytes of both copies
+ var bytes = File.ReadAllBytes(gen);
+ bytes[7] = 0x63;
+ bytes[15] = 0x63;
+ File.WriteAllBytes(gen, bytes);
+
+ reIndex();
+ assertIndexIsUsable();
+ }
+
+ [TestMethod]
+ public void search_repairs_a_zero_length_segments_file()
+ {
+ reIndex();
+ truncateCurrentSegmentsFile(0);
+
+ // the query path recovers via SearchEngineCommands, which keys off IsRecoverableCorruptIndexException
+ var ex = Assert.ThrowsExactly(() => new SearchEngine(indexDirectory).Search("Baskervilles"));
+ SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void an_unreadable_segments_file_is_classified_as_recoverable_corruption()
+ {
+ reIndex();
+ truncateCurrentSegmentsFile(0);
+
+ var ex = exceptionFromOpeningWriter(indexDirectory);
+
+ (ex is CorruptIndexException).Should().BeFalse();
+ ex.Message.Should().Be("read past EOF");
+ SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeTrue();
+ }
+
+ ///
+ /// A held write.lock is transient and must keep being retried. It derives from , so
+ /// widening the corruption check to cover IOException must not swallow it and delete a healthy index.
+ ///
+ [TestMethod]
+ public void a_write_lock_conflict_is_not_classified_as_corruption()
+ {
+ SearchEngine.IsRecoverableCorruptIndexException(new LockObtainFailedException("Lock obtain timed out")).Should().BeFalse();
+ SearchEngine.IsRecoverableCorruptIndexException(new UnauthorizedAccessException()).Should().BeFalse();
+
+ // Windows raises the sharing violation on the lock file before Lucene can turn it into a
+ // LockObtainFailedException, so this arrives as a plain IOException. Mistaking it for corruption would
+ // delete the index the other holder is using.
+ SearchEngine.IsRecoverableCorruptIndexException(
+ new IOException(@"The process cannot access the file 'C:\Users\me\Libation\SearchEngine\write.lock' because it is being used by another process."))
+ .Should().BeFalse();
+ }
+
+ ///
+ /// The risk in repairing anything that is not a lock conflict is over-reach, so a held write.lock has to keep
+ /// being retried and must leave the index alone. The lock is held for the whole retry budget rather than
+ /// released part way through, which would race with Lucene 3's own lock bookkeeping.
+ ///
+ [TestMethod]
+ public void a_write_lock_conflict_is_retried_and_leaves_the_index_intact()
+ {
+ reIndex();
+ var indexedBefore = indexFiles();
+
+ using var index = FSDirectory.Open(indexDirectory);
+ var writeLock = index.MakeLock(IndexWriter.WRITE_LOCK_NAME);
+ writeLock.Obtain().Should().BeTrue();
+
+ try
+ {
+ // Linux surfaces this as Lucene's LockObtainFailedException, Windows as a sharing violation on the
+ // lock file. Either way it must not be read as corruption, and the index must survive.
+ var ex = Assert.Throws(() => reIndex());
+
+ SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeFalse();
+ indexFiles().Should().BeEquivalentTo(indexedBefore);
+ }
+ finally
+ {
+ // a competing NativeFSLock.Obtain can delete the lock file it failed to take, leaving Release nothing
+ // to delete. Lucene 3's lock bookkeeping is not what this test is about.
+ try { writeLock.Release(); }
+ catch (Exception ex) when (ex is LockReleaseFailedException or IOException) { }
+ }
+ }
+
+ private List indexFiles()
+ => [.. Directory.GetFiles(indexDirectory)
+ .Select(Path.GetFileName)
+ .OfType()
+ .Where(f => f != IndexWriter.WRITE_LOCK_NAME)
+ .OrderBy(f => f)];
+}
diff --git a/Source/_Tests/LibationSearchEngine.Tests/QueryFailureShapeTests.cs b/Source/_Tests/LibationSearchEngine.Tests/QueryFailureShapeTests.cs
new file mode 100644
index 00000000..d739b375
--- /dev/null
+++ b/Source/_Tests/LibationSearchEngine.Tests/QueryFailureShapeTests.cs
@@ -0,0 +1,71 @@
+using AssertionHelper;
+using LibationSearchEngine;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using DataLayer;
+using Directory = System.IO.Directory;
+
+namespace SearchEngineTests;
+
+///
+/// The filter box has to tell a query the user mistyped apart from an index Libation cannot read: one deserves
+/// "bad filter string", the other deserves the recovery steps, and only the latter is worth rebuilding over.
+///
+[TestClass]
+public class QueryFailureShapeTests
+{
+ private string indexDirectory = null!;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ indexDirectory = Path.Combine(Path.GetTempPath(), "LibationSearchEngineTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(indexDirectory);
+
+ var contributor = Contributor.GetEmpty();
+ var book = new Book(new AudibleProductId("B0TEST0001"), "Hound of the Baskervilles", null, null, 1, ContentType.Product, [contributor], [contributor], "us");
+ new SearchEngine(indexDirectory).CreateNewIndex(new List { new(book, new DateTime(2026, 8, 15), "account") });
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ try
+ {
+ if (Directory.Exists(indexDirectory))
+ Directory.Delete(indexDirectory, recursive: true);
+ }
+ catch (IOException) { }
+ }
+
+ [TestMethod]
+ [DataRow("title:[unclosed")]
+ [DataRow("*")]
+ [DataRow("AND OR")]
+ [DataRow("(((")]
+ [DataRow(@"title:""unbalanced")]
+ public void a_malformed_query_does_not_look_like_an_unreachable_index(string searchString)
+ {
+ var engine = new SearchEngine(indexDirectory);
+
+ Exception? thrown = null;
+ try
+ {
+ engine.Search(searchString);
+ }
+ catch (Exception ex)
+ {
+ thrown = ex;
+ }
+
+ // some of these parse fine and simply match nothing, which is also acceptable
+ if (thrown is null)
+ return;
+
+ // what must never happen is a parse failure being read as index trouble and triggering a rebuild
+ (thrown is IOException).Should().BeFalse();
+ SearchEngine.IsRecoverableCorruptIndexException(thrown).Should().BeFalse();
+ }
+}
diff --git a/Source/_Tests/LibationUiBase.Tests/AutoScanAuthPromptTests.cs b/Source/_Tests/LibationUiBase.Tests/AutoScanAuthPromptTests.cs
new file mode 100644
index 00000000..e2286649
--- /dev/null
+++ b/Source/_Tests/LibationUiBase.Tests/AutoScanAuthPromptTests.cs
@@ -0,0 +1,71 @@
+using AudibleApi;
+using AudibleApi.Authorization;
+using AudibleApi.Cryptography;
+using AudibleUtilities;
+using LibationUiBase;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+
+namespace LibationUiBase.Tests;
+
+[TestClass]
+public class AutoScanAuthPromptTests
+{
+ private static Account registeredAccount()
+ {
+ var identity = new Identity(Localization.Get("us"));
+ identity.Update(
+ new PrivateKey(RSA.Create(2048).ExportRSAPrivateKeyPem()),
+ new AdpToken("{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}"),
+ new AccessToken("Atna|_CHAR_ACCESS_", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
+ new RefreshToken("Atnr|_CHAR_REFRESH_"),
+ new List> { new("session-id", "cookie-value") },
+ deviceSerialNumber: "device-serial",
+ deviceType: "device-type",
+ amazonAccountId: "amzn-account",
+ deviceName: "device-name",
+ storeAuthenticationCookie: "store-auth-cookie");
+
+ return new Account("jade@example.com") { AccountName = "Jade", IdentityTokens = identity };
+ }
+
+ ///
+ /// The reporter's log paused auto-scan on a second account that had never been logged in, while the dialog
+ /// blamed an expired session and named no account at all.
+ ///
+ [TestMethod]
+ public void an_account_that_was_never_logged_in_is_named_and_explained()
+ {
+ var account = new Account("jade@example.com") { AccountName = "Jade" };
+
+ var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(account, "missing"));
+
+ StringAssert.Contains(body, "'Jade' (jade@example.com)");
+ StringAssert.Contains(body, "never been logged in");
+ StringAssert.Contains(body, "Import > Scan Library");
+ }
+
+ [TestMethod]
+ public void an_account_with_stored_tokens_is_told_its_login_expired()
+ {
+ var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(registeredAccount(), "expired"));
+
+ StringAssert.Contains(body, "'Jade' (jade@example.com)");
+ StringAssert.Contains(body, "expired");
+ }
+
+ [TestMethod]
+ public void an_unattributed_failure_still_reads_sensibly()
+ {
+ var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(account: null, "auth failed"));
+
+ StringAssert.Contains(body, "an Audible account");
+ StringAssert.Contains(body, "Import > Scan Library");
+ }
+
+ [TestMethod]
+ public void a_missing_exception_is_rejected()
+ => Assert.ThrowsExactly(() => AutoScanAuthPrompt.FormatBody(null!));
+}
diff --git a/Source/_Tests/LibationUiBase.Tests/SearchIndexRecoveryTests.cs b/Source/_Tests/LibationUiBase.Tests/SearchIndexRecoveryTests.cs
new file mode 100644
index 00000000..24ab3204
--- /dev/null
+++ b/Source/_Tests/LibationUiBase.Tests/SearchIndexRecoveryTests.cs
@@ -0,0 +1,49 @@
+using LibationUiBase;
+using Lucene.Net.Index;
+using Lucene.Net.QueryParsers;
+using Lucene.Net.Store;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+
+namespace LibationUiBase.Tests;
+
+[TestClass]
+public class SearchIndexRecoveryTests
+{
+ [TestMethod]
+ public void instructions_name_the_folder_and_how_to_find_it()
+ {
+ StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "SearchEngine");
+ StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "Open log folder");
+ // the whole point of the guard is that the library survived
+ StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "library itself is fine");
+ }
+
+ ///
+ /// Every way of failing to reach the index. Reporting one of these as a bad filter string sends the user
+ /// looking for a typo in a query that was fine.
+ ///
+ [TestMethod]
+ public void failures_to_reach_the_index_are_told_apart_from_a_bad_query()
+ {
+ Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new IOException("read past EOF")));
+ Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new CorruptIndexException("checksum mismatch in segments file")));
+ Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new LockObtainFailedException("Lock obtain timed out")));
+ Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new UnauthorizedAccessException()));
+ // cloud-sync debris, which Lucene reports while parsing segments file names
+ Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new ArgumentException("Invalid or unsupported character in number: )")));
+
+ Assert.IsFalse(SearchIndexRecovery.IsIndexUnavailable(new ParseException("Cannot parse '[unclosed'")));
+ Assert.IsFalse(SearchIndexRecovery.IsIndexUnavailable(new ArgumentException("some other argument problem")));
+ }
+
+ /// A damaged index fails on every library change, and these steps only need following once.
+ [TestMethod]
+ public void the_user_is_told_once_per_session()
+ {
+ Assert.IsTrue(SearchIndexRecovery.ShouldNotify());
+ Assert.IsFalse(SearchIndexRecovery.ShouldNotify());
+ Assert.IsFalse(SearchIndexRecovery.ShouldNotify());
+ }
+}