From 46b9e7d8a3d9ff8526c5fc4c7204c87d0dc0c324 Mon Sep 17 00:00:00 2001 From: splitsec2 <35583321+splitsec2@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:30:33 -0600 Subject: [PATCH] fix(library): match a shelf title against the full title when the search title is only the series (#1587) Fixes #1584. Thanks to @joeRob2468 for the write-up, which pinned this down to the line. Hardcover cuts a title like "Mistborn: The Final Empire" down to "Mistborn" for searching when it can't tell the series prefix from a title prefix, and search results don't carry the series. The library check then matched the shelf title "Mistborn: The Final Empire" against "Mistborn", saw "final empire" as extra words and took it for a longer, different work (the "Dune" vs "Dune Messiah" rule), so the book got no In library badge. `match_entries` now tries the search title first as before and, if that finds nothing and the full title is different, tries the full title too. The full title is the more specific of the two so it should rarely add false positives. Another book in the same series still doesn't match, there is a test for that, and the Dune and Dune Messiah test passes unchanged. The matching loop moved into a small helper to run it twice, nothing in it changed. This only touches the library check. The same short search title also feeds release searches and auto-download matching, which I left alone here because loosening or tightening those changes what gets searched and downloaded for everyone. Tests fail on current main without the change. The library tests, ruff and basedpyright are clean. --- shelfmark/core/library_index.py | 21 ++++++++++++++++++++- tests/core/test_library_index.py | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/shelfmark/core/library_index.py b/shelfmark/core/library_index.py index 95dcb007..ced2646f 100644 --- a/shelfmark/core/library_index.py +++ b/shelfmark/core/library_index.py @@ -162,8 +162,27 @@ def match_entries(book: BookMetadata, entries: list[LibraryEntry]) -> str | None if external_id in entry.external_ids or entry.isbns & book_isbns: return "owned" - title = book.search_title or book.title surname = author_surname(book.search_author or (book.authors[0] if book.authors else "")) + # The search title is the looser of the two, so it goes first. Hardcover cuts "Series: + # Book Title" down to the series name when it cannot tell that prefix from a title, and a + # shelf title with the book's own words then reads as a longer, different work. The full + # title is the more specific of the two and names the book as the shelf does. + titles = [book.search_title or book.title] + if book.title and book.title not in titles: + titles.append(book.title) + + collection: str | None = None + for title in titles: + held = _held_as(title, surname, entries) + if held == "owned": + return "owned" + collection = collection or held + + return collection + + +def _held_as(title: str, surname: str | None, entries: list[LibraryEntry]) -> str | None: + """How the shelf holds a book of this ``title``: ``"owned"``, ``"collection"`` or None.""" collection: str | None = None title_toks = significant_tokens(title) for entry in entries: diff --git a/tests/core/test_library_index.py b/tests/core/test_library_index.py index e58169dd..f4006c0e 100644 --- a/tests/core/test_library_index.py +++ b/tests/core/test_library_index.py @@ -444,6 +444,30 @@ def test_the_series_and_volume_a_shelf_title_repeats_do_not_block_a_match() -> N assert library_index.match_entries(book, shelf) == "owned" +def test_the_full_title_matches_when_the_search_title_is_only_the_series() -> None: + # Hardcover searches "Mistborn: The Final Empire" as "Mistborn" when it cannot tell the + # series prefix from a title prefix, and the shelf title then reads as a longer work. + shelf = [_shelf_entry({"mistborn", "the", "final", "empire"}, {"brandon", "sanderson"})] + book = _book( + title="Mistborn: The Final Empire", + search_title="Mistborn", + authors=["Brandon Sanderson"], + ) + + assert library_index.match_entries(book, shelf) == "owned" + + +def test_the_full_title_does_not_match_another_book_in_the_series() -> None: + shelf = [_shelf_entry({"mistborn", "the", "well", "of", "ascension"}, {"brandon", "sanderson"})] + book = _book( + title="Mistborn: The Final Empire", + search_title="Mistborn", + authors=["Brandon Sanderson"], + ) + + assert library_index.match_entries(book, shelf) is None + + def test_an_edition_word_does_not_block_a_match() -> None: shelf = [_shelf_entry({"dune", "illustrated", "edition"}, {"frank", "herbert"})]