using DataLayer; using Dinah.Core; using FileManager; using LibationFileManager; using Lucene.Net.Analysis.Standard; using Lucene.Net.Documents; using Lucene.Net.Index; using Lucene.Net.Search; using Lucene.Net.Store; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; namespace LibationSearchEngine; public class SearchEngine { public const Lucene.Net.Util.Version Version = Lucene.Net.Util.Version.LUCENE_30; public const string _ID_ = "_ID_"; public const string TAGS = "tags"; // special field for each book which includes all major parts of the book's metadata. enables non-targetting searching public const string ALL = "all"; #region index rules private static bool isAuthorNarrated(Book book) { var authors = book.Authors.Select(a => a.Name).ToArray(); var narrators = book.Narrators.Select(a => a.Name).ToArray(); return authors.Intersect(narrators).Any(); } /// /// True when <title short> cuts the title itself rather than merely dropping Audible's subtitle /// field. Audible ships plenty of titles with a colon in them, and those are the ones where shortening loses /// something the user may need: "Omnibus: Volume One" and "Omnibus: Volume Two" both shorten to "Omnibus". /// private static bool titleIsShortened(Book book) => LibationFileManager.Templates.Templates.GetTitleShort(book.Title) != book.Title; // use these common fields in the "all" default search field public static IndexRuleCollection FieldIndexRules { get; } = new IndexRuleCollection { { FieldType.ID, lb => lb.Book.AudibleProductId.ToLowerInvariant(), nameof(Book.AudibleProductId), "ProductId", "Id", "ASIN" }, { FieldType.Raw, lb => lb.Book.AudibleProductId, _ID_ }, { FieldType.String, lb => lb.Book.TitleWithSubtitle, "Title", "ProductId", "Id", "ASIN" }, { FieldType.String, lb => lb.Book.AuthorNames, "AuthorNames", "Author", "Authors" }, { FieldType.String, lb => lb.Book.NarratorNames, "NarratorNames", "Narrator", "Narrators" }, { FieldType.String, lb => lb.Book.Publisher, nameof(Book.Publisher) }, { FieldType.String, lb => lb.Book.SeriesNames(), "SeriesNames", "Narrator", "Series" }, { FieldType.String, lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)), "SeriesId" }, { FieldType.String, lb => lb.Book.AllCategoryIds() is not { } categories ? null : string.Join(", ", categories), "CategoriesId", "CategoryId" }, { FieldType.String, lb => lb.Book.AllCategoryNames() is null ? null : string.Join(", ", lb.Book.AllCategoryNames()), "Category", "Categories", "CategoriesNames" }, { FieldType.String, lb => lb.Book.UserDefinedItem.Tags, TAGS.FirstCharToUpper() }, { FieldType.String, lb => lb.Book.Locale, "Locale", "Region" }, { FieldType.String, lb => lb.Account, "Account", "Email" }, { FieldType.String, lb => lb.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, "Codec", "DownloadedCodec" }, { FieldType.Bool, lb => lb.Book.HasPdf.ToString(), "HasDownloads", "HasDownload", "Downloads" , "Download", "HasPDFs", "HasPDF" , "PDFs", "PDF" }, { FieldType.Bool, lb => (lb.Book.UserDefinedItem.Rating.OverallRating > 0f).ToString(), "IsRated", "Rated" }, { FieldType.Bool, lb => isAuthorNarrated(lb.Book).ToString(), "IsAuthorNarrated", "AuthorNarrated" }, { FieldType.Bool, lb => lb.Book.IsAbridged.ToString(), nameof(Book.IsAbridged), "Abridged" }, { FieldType.Bool, lb => lb.Book.IsSpatial.ToString(), nameof(Book.IsSpatial), "Spatial" }, { FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated).ToString(), "IsLiberated", "Liberated" }, { FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Error).ToString(), "LiberatedError" }, { FieldType.Bool, lb => lb.Book.IsEpisodeChild().ToString(), "Podcast", "Podcasts", "IsPodcast", "Episode", "Episodes", "IsEpisode" }, { FieldType.Bool, lb => lb.AbsentFromLastScan.ToString(), "AbsentFromLastScan", "Absent" }, { FieldType.Bool, lb => (!string.IsNullOrWhiteSpace(lb.Book.SeriesNames())).ToString(), "IsInSeries", "InSeries" }, { FieldType.Bool, lb => (!string.IsNullOrWhiteSpace(lb.Book.Subtitle)).ToString(), "HasSubtitle", "HasSubtitles" }, { FieldType.Bool, lb => titleIsShortened(lb.Book).ToString(), "TitleHasColon", "ColonInTitle" }, { FieldType.Bool, lb => lb.Book.UserDefinedItem.IsFinished.ToString(), nameof(UserDefinedItem.IsFinished), "Finished", "IsFinished" }, { FieldType.Bool, lb => lb.IsAudiblePlus.ToString(), nameof(LibraryBook.IsAudiblePlus), "AudiblePlus", "Plus" }, // all numbers are padded to 8 char.s // This will allow a single method to auto-pad numbers. The method will match these as well as date: yyyymmdd { FieldType.Number, lb => lb.Book.LengthInMinutes.ToLuceneString(), nameof(Book.LengthInMinutes), "Length", "Minutes" }, { FieldType.Number, lb => (lb.Book.LengthInMinutes / 60).ToLuceneString(), "Hours" }, { FieldType.Number, lb => lb.Book.Rating.OverallRating.ToLuceneString(), "ProductRating", "Rating" }, { FieldType.Number, lb => lb.Book.UserDefinedItem.Rating.OverallRating.ToLuceneString(), "UserRating", "MyRating" }, { FieldType.Number, lb => lb.Book.DatePublished?.ToLuceneString() ?? "", nameof(Book.DatePublished) }, { FieldType.Number, lb => lb.Book.UserDefinedItem.LastDownloaded.ToLuceneString(), nameof(UserDefinedItem.LastDownloaded), "LastDownload" }, { FieldType.Number, lb => lb.Book.UserDefinedItem.LastDownloadedFormat?.BitRate.ToLuceneString(), "Bitrate", "DownloadedBitrate" }, { FieldType.Number, lb => lb.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate.ToLuceneString(), "SampleRate", "DownloadedSampleRate" }, { FieldType.Number, lb => lb.DateAdded.ToLuceneString(), nameof(LibraryBook.DateAdded) } }; #endregion #region create and update index /// create new. ie: full re-index /// How many books could not be indexed, and so cannot be found by search or filter. /// /// One book is not allowed to cost the rest their place in the index. Disposing the writer commits /// whatever it already holds, so letting a single bad book escape the loop used to leave a silently /// truncated index behind: every book after the failure was missing from it, and a missing book is /// worse than an unsearchable one, because a negated filter drops it from the grid entirely. /// public int CreateNewIndex(IEnumerable library, bool overwrite = true) { var libraryList = library.ToList(); // 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); var failures = 0; foreach (var libraryBook in libraryList) { try { var doc = createBookIndexDocument(libraryBook); ixWriter.AddDocument(doc); } catch (Exception ex) { failures++; Serilog.Log.Logger.Error(ex, "Could not add {Book} to the search index. It will not be found by search or filter, and a negated filter will hide it.", describeForLog(libraryBook)); } } return failures; } /// Name a book for a log line without risking a second exception from the book that just threw. private static string describeForLog(LibraryBook libraryBook) { try { return libraryBook?.Book?.AudibleProductId ?? "a book with no product id"; } catch { return "an unreadable book"; } } /// /// How many books the index holds, or -1 when there is no index to read. Every write to the index is a /// full rebuild, so this should always equal the number of books a rebuild would write; when it is short, /// the missing books are invisible to search and are hidden from the grid by any negated filter. /// public int GetIndexedBookCount() { try { using var index = getIndex(); if (!IndexReader.IndexExists(index)) return -1; using var reader = IndexReader.Open(index, readOnly: true); return reader.NumDocs(); } catch (Exception ex) { Serilog.Log.Logger.Debug(ex, "Could not count the documents in the search index"); return -1; } } /// /// 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 { var createNewIndex = overwrite || !IndexReader.IndexExists(index); return new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED); } catch (Exception ex) when (isTransientLockConflict(ex) && attempt < maxAttempts) { 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 (Exception ex) when (!isTransientLockConflict(ex) && repairsRemaining-- > 0) { // 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; } } } /// /// 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)); /// /// 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 []; foreach (var file in System.IO.Directory.GetFiles(searchEngineDirectory, "*", SearchOption.AllDirectories)) FileUtility.TrySaferDelete(file); foreach (var dir in System.IO.Directory.GetDirectories(searchEngineDirectory, "*", SearchOption.AllDirectories).OrderByDescending(d => d.Length)) { try { System.IO.Directory.Delete(dir); } catch (Exception ex) { Serilog.Log.Logger.Warning(ex, "Could not remove search index subdirectory {Dir}", dir); } } return System.IO.Directory.Exists(searchEngineDirectory) ? [.. System.IO.Directory.GetFiles(searchEngineDirectory, "segments*", SearchOption.AllDirectories)] : []; } public SearchEngine(string? directory = null) { SearchEngineDirectory = directory ?? new DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName; } /// Long running. Use await Task.Run(() => UpdateBook(productId)) public void UpdateBook(LibationContext context, string productId) { if (context.GetLibraryBook_Flat_NoTracking(productId) is not { } libraryBook) return; var document = createBookIndexDocument(libraryBook); var createNewIndex = false; var term = new Term(_ID_, productId); using var index = getIndex(); using var analyzer = new StandardAnalyzer(Version); using var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED); ixWriter.DeleteDocuments(term); ixWriter.AddDocument(document); } private static Document createBookIndexDocument(LibraryBook libraryBook) { var doc = new Document(); // concat all common fields for the default 'all' field var allConcat = FieldIndexRules .Select(rule => rule.GetValue(libraryBook)) .OfType() .Aggregate((a, b) => $"{a} {b}"); doc.AddAnalyzed(ALL, allConcat); foreach (var rule in FieldIndexRules) doc.AddIndexRule(rule, libraryBook); return doc; } // update single document entry // all fields, including 'tags' are case-specific public void UpdateTags(string productId, string tags) => updateAnalyzedField(productId, TAGS, tags); // all fields are case-specific private void updateAnalyzedField(string productId, string fieldName, string newValue) => updateDocument( productId, d => { d.RemoveField(fieldName.ToLower()); d.AddAnalyzed(fieldName, newValue); }); // update single document entry public void UpdateLiberatedStatus(LibraryBook book) => updateDocument( book.Book.AudibleProductId, d => { if (FieldIndexRules.GetRuleByFieldName("IsLiberated") is { } lib) { d.RemoveRule(lib); d.AddIndexRule(lib, book); } if (FieldIndexRules.GetRuleByFieldName("LiberatedError") is { } libError) { d.RemoveRule(libError); d.AddIndexRule(libError, book); } if (FieldIndexRules.GetRuleByFieldName(nameof(UserDefinedItem.LastDownloaded)) is { } lastDl) { d.RemoveRule(lastDl); d.AddIndexRule(lastDl, book); } }); public void UpdateUserRatings(LibraryBook book) => updateDocument( book.Book.AudibleProductId, d => { if (FieldIndexRules.GetRuleByFieldName("UserRating") is { } rating) { d.RemoveRule(rating); d.AddIndexRule(rating, book); } }); private void updateDocument(string productId, Action action) { var productTerm = new Term(_ID_, productId); using var index = getIndex(); // get existing document using var searcher = new IndexSearcher(index); var query = new TermQuery(productTerm); var docs = searcher.Search(query, 1); var scoreDoc = docs.ScoreDocs.SingleOrDefault(); if (scoreDoc is null) return; var document = searcher.Doc(scoreDoc.Doc); // perform update action(document); // update index var createNewIndex = false; using var analyzer = new StandardAnalyzer(Version); using var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED); ixWriter.UpdateDocument(productTerm, document, analyzer); } #endregion // the workaround which allows displaying all books when query is empty public const string ALL_QUERY = "*:*"; #region search public SearchResultSet Search(string searchString) { using var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30); Serilog.Log.Logger.Debug("original search string: {@DebugInfo}", new { searchString }); searchString = QuerySanitizer.Sanitize(searchString, analyzer); Serilog.Log.Logger.Debug("formatted search string: {@DebugInfo}", new { searchString }); var results = generalSearch(searchString, analyzer); Serilog.Log.Logger.Debug("Hit(s): {@DebugInfo}", new { count = results.Docs.Count() }); displayResults(results); return results; } private SearchResultSet generalSearch(string searchString, StandardAnalyzer analyzer) { var defaultField = ALL; using var index = getIndex(); using var searcher = new IndexSearcher(index); var query = analyzer.GetQuery(defaultField, searchString); allowPureNegation(query); var docs = searcher .Search(query, searcher.MaxDoc + 1) .ScoreDocs .Select(ds => new ScoreDocExplicit(searcher.Doc(ds.Doc), ds.Score)) .ToList(); var queryString = query.ToString(); Serilog.Log.Logger.Debug("query: {@DebugInfo}", new { queryString }); return new SearchResultSet(queryString, docs); } /// /// Gives every all-negative in the tree something positive to subtract from. /// Lucene matches nothing for a query that only excludes, so -tags:hidden needs a /// added alongside it. /// /// Whether a query needs one is decided by its own clauses and nothing deeper. Judging it by every /// clause in the tree read -(tags:hidden OR tags:archived) as mixed, because of the two SHOULD /// clauses inside the negation, and returned nothing at all. Recursing also reaches a parenthesized /// negation used as a subquery, as in (-tags:hidden) AND (-tags:archived), where each group /// matches nothing on its own and so the whole query did too. /// /// /// Adding one anywhere else would make everything a match, so recurse first and test afterwards: the /// clause added to a subquery must not be counted when deciding about its parent. /// /// /// private static void allowPureNegation(Query query) { if (query is not BooleanQuery boolQuery) return; var clauses = boolQuery.ToList(); foreach (var clause in clauses) allowPureNegation(clause.Query); if (clauses.Count > 0 && clauses.TrueForAll(c => c.Occur == Occur.MUST_NOT)) boolQuery.Add(new MatchAllDocsQuery(), Occur.MUST); } private void displayResults(SearchResultSet docs) { //for (int i = 0; i < docs.Docs.Count(); i++) //{ // var sde = docs.Docs.First(); // Document doc = sde.Doc; // float score = sde.Score; // Serilog.Log.Logger.Debug($"{(i + 1)}) score={score}. Fields:"); // var allFields = doc.GetFields(); // foreach (var f in allFields) // Serilog.Log.Logger.Debug($" [{f.Name}]={f.StringValue}"); //} } #endregion private Lucene.Net.Store.Directory getIndex() => FSDirectory.Open(SearchEngineDirectory); //Defaults to "LibationFiles/SearchEngine, but can be overridden //in constructor for use in TrashBinDialog search private string SearchEngineDirectory { get; } }