mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
Add HasSubtitle and TitleHasColon search fields
<title short> stops at the first colon, so it shortens Audible titles that contain one just as readily as it drops Audible's subtitle, and distinct books then collapse onto the same name. A colon cannot be searched for: the analyzer discards punctuation and Lucene reads a colon in a query as a field separator. Two bool index fields find the affected books instead. Document how the two title tags differ, since <audible title> already drops Audible's subtitle without ever cutting a title, and how to audit for names that actually collide in a spreadsheet export. Co-authored-by: rmcrackan <rmcrackan@gmail.com>
This commit is contained in:
7 files changed
+189
-5
No files matched your search
@@ -3,7 +3,8 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System.Text.Json;
|
||||
|
||||
// Seeds a Libation library with one book per Liberate-column state, for manual UI testing.
|
||||
// Seeds a Libation library with one book per Liberate-column state, plus a few titles that
|
||||
// subtitle removal changes, for manual UI testing.
|
||||
//
|
||||
// dotnet run Scripts/seed-demo-library.cs [--clean] [path-to-Libation-files-folder]
|
||||
//
|
||||
@@ -76,9 +77,9 @@ foreach (var book in books)
|
||||
insert into Books
|
||||
(AudibleProductId, ContentType, Description, IsAbridged, IsSpatial, LengthInMinutes,
|
||||
Locale, Rating_OverallRating, Rating_PerformanceRating, Rating_StoryRating, Subtitle, Title)
|
||||
values ($asin, $contentType, 'Seeded by seed-demo-library.cs', 0, 0, 600, 'us', 0, 0, 0, '', $title)
|
||||
values ($asin, $contentType, 'Seeded by seed-demo-library.cs', 0, 0, 600, 'us', 0, 0, 0, $subtitle, $title)
|
||||
""",
|
||||
("$asin", book.Asin), ("$contentType", book.ContentType), ("$title", book.Title));
|
||||
("$asin", book.Asin), ("$contentType", book.ContentType), ("$title", book.Title), ("$subtitle", book.Subtitle));
|
||||
|
||||
var bookId = Scalar("select BookId from Books where AudibleProductId = $asin", ("$asin", book.Asin));
|
||||
|
||||
@@ -263,6 +264,27 @@ static List<DemoBook> BuildDemoBooks()
|
||||
Expectation: expectation,
|
||||
IsAbsent: true));
|
||||
|
||||
// Titles that subtitle removal changes. <title short> stops at the first colon, so it cuts a colon in
|
||||
// Audible's own title just as readily as it drops Audible's subtitle field, and the grid's Title column
|
||||
// shows both the same way. The HasSubtitle and TitleHasColon filters are the only way to tell them apart.
|
||||
foreach (var (title, subtitle, match) in new[]
|
||||
{
|
||||
("A Book Series Omnibus", "Volume One", "HasSubtitle"),
|
||||
("A Book Series Omnibus", "Volume Two", "HasSubtitle"),
|
||||
("Star Trek: The Next Generation", "", "TitleHasColon"),
|
||||
("Dune: Book One", "The Graphic Novel", "both HasSubtitle and TitleHasColon"),
|
||||
})
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: title,
|
||||
ContentType: Product,
|
||||
BookStatus: NotLiberated,
|
||||
PdfStatus: null,
|
||||
IsPlus: false,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: $"red lamp; filter match: {match}",
|
||||
Subtitle: subtitle));
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
@@ -373,4 +395,5 @@ record DemoBook(
|
||||
string Expectation,
|
||||
string? SeriesAsin = null,
|
||||
string? SeriesOrder = null,
|
||||
bool IsAbsent = false);
|
||||
bool IsAbsent = false,
|
||||
string Subtitle = "");
|
||||
@@ -392,7 +392,8 @@ public abstract class Templates
|
||||
|
||||
#region Tag Formatters
|
||||
|
||||
private static string? GetTitleShort(string? title)
|
||||
/// <summary>Backs the <c><title short></c> tag. Public so the search index can flag the titles it shortens.</summary>
|
||||
public static string? GetTitleShort(string? title)
|
||||
=> title != null && title.IndexOf(':') is var i && i >= 0
|
||||
? title[..i]
|
||||
: title;
|
||||
|
||||
@@ -33,6 +33,14 @@ public class SearchEngine
|
||||
return authors.Intersect(narrators).Any();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <c><title short></c> 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".
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
@@ -60,6 +68,8 @@ public class SearchEngine
|
||||
{ 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
|
||||
|
||||
@@ -65,6 +65,11 @@ public class FormatSearchQuery
|
||||
[DataRow("1 to 10", "00000001.00 TO 00000010.00")]
|
||||
[DataRow("19990101 to 20001231", "19990101.00 TO 20001231.00")]
|
||||
|
||||
// subtitle keywords are bool fields, not text fields
|
||||
[DataRow("HasSubtitle", "hassubtitle:True")]
|
||||
[DataRow("-TitleHasColon", "-titlehascolon:True")]
|
||||
[DataRow("HasSubtitle OR TitleHasColon", "hassubtitle:True OR titlehascolon:True")]
|
||||
|
||||
// field to lowercase
|
||||
[DataRow("Author:Doyle", "author:Doyle")]
|
||||
// bool field to lowercase
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AssertionHelper;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Directory = System.IO.Directory;
|
||||
|
||||
namespace SearchEngineTests;
|
||||
|
||||
/// <summary>
|
||||
/// Subtitle removal is all-or-nothing: <c><title short></c> stops at the first colon, so "Omnibus: Volume One"
|
||||
/// and "Omnibus: Volume Two" both become "Omnibus". Finding the affected books used to be impossible -- the analyzer
|
||||
/// throws punctuation away and Lucene reads a colon in a query as a field separator -- so the index flags the two
|
||||
/// separate ways a name can lose part of its title.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SubtitleSearchFieldTests
|
||||
{
|
||||
private const string PLAIN = "B0PLAIN0001";
|
||||
private const string SUBTITLE = "B0SUBTITL01";
|
||||
private const string COLON = "B0COLON0001";
|
||||
private const string BOTH = "B0BOTH00001";
|
||||
|
||||
private string indexDirectory = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Initialize()
|
||||
{
|
||||
indexDirectory = Path.Combine(Path.GetTempPath(), "LibationSearchEngineTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(indexDirectory);
|
||||
new SearchEngine(indexDirectory).CreateNewIndex(library);
|
||||
}
|
||||
|
||||
[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, string? subtitle)
|
||||
{
|
||||
var contributor = Contributor.GetEmpty();
|
||||
var b = new Book(new AudibleProductId(asin), title, subtitle, null, 1, ContentType.Product, [contributor], [contributor], "us");
|
||||
return new LibraryBook(b, new DateTime(2026, 8, 15), "account");
|
||||
}
|
||||
|
||||
private static readonly List<LibraryBook> library =
|
||||
[
|
||||
book(PLAIN, "Sign of the Four", null),
|
||||
book(SUBTITLE, "A Book Series Omnibus", "Volume One"),
|
||||
book(COLON, "Star Trek: The Next Generation", null),
|
||||
book(BOTH, "Dune: Book One", "The Graphic Novel")
|
||||
];
|
||||
|
||||
private string[] search(string query)
|
||||
=> [.. new SearchEngine(indexDirectory).Search(query).Docs.Select(d => d.ProductId)];
|
||||
|
||||
/// <summary>Audible's own subtitle field, which every template except <c><title></c> leaves out.</summary>
|
||||
[TestMethod]
|
||||
[DataRow("HasSubtitle")]
|
||||
[DataRow("HasSubtitles")]
|
||||
[DataRow("hassubtitle")]
|
||||
public void books_with_an_audible_subtitle_are_found(string query)
|
||||
=> search(query).Should().BeEquivalentTo([SUBTITLE, BOTH]);
|
||||
|
||||
/// <summary>The riskier case: the colon is inside Audible's title, so shortening cuts the title itself.</summary>
|
||||
[TestMethod]
|
||||
[DataRow("TitleHasColon")]
|
||||
[DataRow("ColonInTitle")]
|
||||
[DataRow("titlehascolon")]
|
||||
public void books_whose_title_contains_a_colon_are_found(string query)
|
||||
=> search(query).Should().BeEquivalentTo([COLON, BOTH]);
|
||||
|
||||
[TestMethod]
|
||||
public void the_two_fields_are_independent()
|
||||
{
|
||||
search("HasSubtitle AND TitleHasColon").Should().BeEquivalentTo([BOTH]);
|
||||
search("HasSubtitle OR TitleHasColon").Should().BeEquivalentTo([SUBTITLE, COLON, BOTH]);
|
||||
}
|
||||
|
||||
/// <summary>The complement is what makes this useful: everything shortening cannot damage.</summary>
|
||||
[TestMethod]
|
||||
public void books_a_short_title_leaves_alone_are_found_by_negation()
|
||||
{
|
||||
search("-HasSubtitle").Should().BeEquivalentTo([PLAIN, COLON]);
|
||||
search("-TitleHasColon").Should().BeEquivalentTo([PLAIN, SUBTITLE]);
|
||||
search("-HasSubtitle AND -TitleHasColon").Should().BeEquivalentTo([PLAIN]);
|
||||
}
|
||||
|
||||
/// <summary>Bool fields combine with the rest of the syntax, which is the point: filter, then liberate.</summary>
|
||||
[TestMethod]
|
||||
public void the_fields_combine_with_other_search_terms()
|
||||
=> search("TitleHasColon AND title:dune").Should().BeEquivalentTo([BOTH]);
|
||||
|
||||
/// <summary>A subtitle is still part of the title text, so searching for its words keeps working.</summary>
|
||||
[TestMethod]
|
||||
public void subtitle_text_remains_searchable()
|
||||
=> search("title:\"volume one\"").Should().BeEquivalentTo([SUBTITLE]);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ These tags will be replaced in the template with the audiobook's values.
|
||||
|
||||
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
|
||||
|
||||
`<title short>` stops at the first colon wherever that colon came from, so it also cuts titles such as "Star Trek: The Next Generation" that Audible sent without a subtitle. `<audible title>` leaves out Audible's subtitle without cutting the title. To see which of your books are affected either way, filter on `HasSubtitle` and `TitleHasColon`; see [searching and filtering](searching-and-filtering.md#subtitles-and-short-titles).
|
||||
|
||||
### Conditional Tags
|
||||
|
||||
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|
||||
|
||||
@@ -54,6 +54,38 @@ I tagged autobiographies as auto_bio and biographies written by someone else as
|
||||
![Search example: [bio]](../images/SearchExampleBio.png)
|
||||
![Search example: [auto_bio]](../images/SearchExampleAutoBio.png)
|
||||
|
||||
## Subtitles and short titles
|
||||
|
||||
The `<title short>` tag keeps everything before the first colon, which is what keeps the default folder name short. That is usually what you want, but not always. "A Book Series Omnibus: Volume One" and "A Book Series Omnibus: Volume Two" both shorten to "A Book Series Omnibus", so the two books land in the same folder and can no longer be told apart by name.
|
||||
|
||||
A colon is not something you can search for: the search engine throws punctuation away when it indexes your library, and Lucene reads a colon in a query as the separator between a field and its value. Two boolean fields find these books instead.
|
||||
|
||||
| Field | Matches |
|
||||
|----------------------------------|---------------------------------------------------------------------------------------------------------------|
|
||||
| `HasSubtitle` (`HasSubtitles`) | Audible sent a separate subtitle, which every title tag except `<title>` leaves out |
|
||||
| `TitleHasColon` (`ColonInTitle`) | Audible's title itself contains a colon, so `<title short>` cuts into the title rather than dropping a subtitle |
|
||||
|
||||
Some searches worth keeping as quick filters:
|
||||
|
||||
- `TitleHasColon` - every book whose title `<title short>` cuts
|
||||
- `TitleHasColon AND -IsLiberated` - the same, limited to books you have not downloaded yet
|
||||
- `HasSubtitle OR TitleHasColon` - everything shortening changes in any way
|
||||
- `-HasSubtitle AND -TitleHasColon` - the books shortening cannot change
|
||||
|
||||
If these fields find nothing at all, your search index was built before they existed. Scanning your library rebuilds it, as does closing Libation and deleting the `SearchEngine` folder in your Libation files folder. The index is only a cache of your library, so deleting it is safe.
|
||||
|
||||
Once you can see the affected books, you can decide what to do about them. If the only problem is colons inside Audible's titles, switching `<title short>` to `<audible title>` in Settings > Download/Decrypt fixes every one of them at once: it still leaves out Audible's subtitle, but it never cuts the title. If instead two books share a title and differ only by subtitle, use `<title>` for those books, or keep `<id>` in the template so their names stay unique. Either way, filter to the books you want handled differently, liberate them with one template, then restore your usual template for the rest.
|
||||
|
||||
### Auditing titles in a spreadsheet
|
||||
|
||||
The filter tells you which books are shortened. It cannot tell you which ones actually collide, and a colon on its own is harmless - the damage is done when two books end up with the same name. Export (Export in the menu bar) writes `Title` and `Subtitle` as separate columns, where `Title` is Audible's title exactly as `<title short>` sees it, so a spreadsheet can answer the question the filter cannot.
|
||||
|
||||
Put this beside the Title column to see the name each book would be shortened to, then use `COUNTIF` on the result to find the ones that repeat:
|
||||
|
||||
`=LEFT(A2, IFERROR(FIND(":", A2) - 1, LEN(A2)))`
|
||||
|
||||
To carry the result back into Libation, paste the affected ids into the filter box joined by OR, eg. `id:B015D78L0U OR id:B01LYFDNZM`, which selects exactly those books in the grid.
|
||||
|
||||
## Filters
|
||||
|
||||
If you have a search you want to save, click Add To Quick Filters to save it in your Quick Filters list. To use it again, select it from the Quick Filters list.
|
||||
|
||||
Reference in new issue
Block a user