From 9bd6c51388f6efc1ce258f6b22022a94bcc7f230 Mon Sep 17 00:00:00 2001 From: splitsec2 <35583321+splitsec2@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:35:37 -0600 Subject: [PATCH] fix(hardcover): read the featured series from a search result (#1589) Closes #1585. @joeRob2468 traced this one to the parser, and the answer turned out to be sitting in the search response already. A Hardcover search hit carries the book's featured series as `featured_series`, with the same shape as the `featured_book_series` of a full record: the position and a nested series with its id, name and book count. `_parse_search_result` never read it, so a book requested from a search card was stored with `series_name: null` and the `{Series}` folder and the custom script payload came out empty. It now reads it, through a small helper that `_parse_book` uses too, so there is no extra Hardcover call per request (the issue's other option was fetching the full record, which costs one against the rate limit). I checked it against a live search for "Mistborn The Final Empire". The top hit parses to series "The Mistborn Saga: The Original Trilogy", position 1, id 10001, and its search title is still "Mistborn". That last part is on purpose. I left `_compute_search_title` alone, since passing it the series would change what gets searched for everyone and the series name here doesn't equal the "Mistborn" prefix anyway. Two things that behave differently. A search result now has `series_name` set, so anything in the UI that shows it for a full record (the series link, the bigger cover in the release modal) will show it on search results too. I ran the branch and looked at the release modal for the top Mistborn result: it shows "#1 of 3 in The Mistborn Saga: The Original Trilogy" with the View series link. The modal fetches the full record when it opens, so I can't say how much of that is new on this path. Requests already stored keep the empty series they were saved with. Tests: four in a new file (a hit with a series, hits with none or an incomplete one, a hit nested in a `document`, and the search title staying the same), the first and third fail on current main. The metadata and core suites, ruff and basedpyright are clean. --- shelfmark/metadata_providers/hardcover.py | 46 +++++++++---- .../metadata/test_hardcover_search_series.py | 67 +++++++++++++++++++ 2 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 tests/metadata/test_hardcover_search_series.py diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index f77abeb5..a4d73e11 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -1046,6 +1046,29 @@ def _compute_search_title( return None +def _featured_series_fields( + featured_series: Any, +) -> tuple[str | None, str | None, Any, int | None]: + """The ``(id, name, position, book count)`` of a book's featured series. + + A book record carries it as ``featured_book_series`` and a search hit as + ``featured_series``; both hold the position and a nested ``series``. + """ + if not isinstance(featured_series, dict): + return None, None, None, None + + series_id = None + series_name = None + series_count = None + series_data = featured_series.get("series") + if isinstance(series_data, dict): + if series_data.get("id") is not None: + series_id = str(series_data.get("id")) + series_name = series_data.get("name") + series_count = series_data.get("primary_books_count") + return series_id, series_name, featured_series.get("position"), series_count + + @register_provider_kwargs("hardcover") def _hardcover_kwargs() -> dict[str, Any]: """Provide Hardcover-specific constructor kwargs.""" @@ -2959,6 +2982,9 @@ class HardcoverProvider(MetadataProvider): # Extract subtitle if available in search results subtitle = item.get("subtitle") search_title = _compute_search_title(title, subtitle) + series_id, series_name, series_position, series_count = _featured_series_fields( + item.get("featured_series") or (item.get("document") or {}).get("featured_series") + ) if matched_title and matched_title != title: # The subtitle belongs to the original title, not to the translation. @@ -2977,6 +3003,10 @@ class HardcoverProvider(MetadataProvider): description=full_description, publish_year=publish_year, source_url=source_url, + series_id=series_id, + series_name=series_name, + series_position=series_position, + series_count=series_count, display_fields=display_fields, ) @@ -3058,19 +3088,9 @@ class HardcoverProvider(MetadataProvider): full_description = _combine_headline_description(headline, description) # Extract series info from featured_book_series - series_id = None - series_name = None - series_position = None - series_count = None - featured_series = book.get("featured_book_series") - if featured_series: - series_position = featured_series.get("position") - series_data = featured_series.get("series") - if series_data: - if series_data.get("id") is not None: - series_id = str(series_data.get("id")) - series_name = series_data.get("name") - series_count = series_data.get("primary_books_count") + series_id, series_name, series_position, series_count = _featured_series_fields( + book.get("featured_book_series") + ) # Extract titles by language from editions # This allows searching with localized titles when language filter is active diff --git a/tests/metadata/test_hardcover_search_series.py b/tests/metadata/test_hardcover_search_series.py new file mode 100644 index 00000000..73619c38 --- /dev/null +++ b/tests/metadata/test_hardcover_search_series.py @@ -0,0 +1,67 @@ +from shelfmark.metadata_providers.hardcover import HardcoverProvider + + +def _provider() -> HardcoverProvider: + return HardcoverProvider(api_key="test-token") + + +def _hit(**overrides): + # The shape of a search hit: the featured series is `featured_series`, not the + # `featured_book_series` of a full book record. + hit = { + "id": "369692", + "title": "Mistborn: The Final Empire", + "subtitle": "The Final Empire", + "author_names": ["Brandon Sanderson"], + "featured_series": { + "position": 1.0, + "series": { + "id": 10001, + "name": "The Mistborn Saga: The Original Trilogy", + "primary_books_count": 3, + }, + }, + "featured_series_position": 1.0, + } + hit.update(overrides) + return hit + + +def test_a_search_result_carries_the_featured_series(): + book = _provider()._parse_search_result(_hit()) + + assert book is not None + assert book.series_id == "10001" + assert book.series_name == "The Mistborn Saga: The Original Trilogy" + assert book.series_position == 1.0 + assert book.series_count == 3 + + +def test_a_search_result_without_a_series_has_none(): + for featured in (None, {}, {"position": 2.0}): + book = _provider()._parse_search_result(_hit(featured_series=featured)) + + assert book is not None + assert book.series_name is None + assert book.series_id is None + + +def test_a_search_result_nested_in_a_document_carries_the_series_too(): + book = _provider()._parse_search_result({"document": _hit()}) + + assert book is not None + assert book.series_name == "The Mistborn Saga: The Original Trilogy" + + +def test_the_search_title_is_unchanged_by_the_series(): + book = _provider()._parse_search_result(_hit()) + + assert book is not None + assert book.search_title == "Mistborn" + + +def test_a_hit_with_a_null_document_and_no_series_is_still_parsed(): + book = _provider()._parse_search_result(_hit(featured_series=None, document=None)) + + assert book is not None + assert book.series_name is None