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.
This commit is contained in:
splitsec2 authored and GitHub committed 2026-10-31 01:35:37 -06:00
1 parent d95f663a92
commit 9bd6c51388
2 files changed
+100 -13

No files matched your search

+33 -13
View File
@@ -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
@@ -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