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