From 4e41b1a8ec621c6f838c18ee8f3286b3937d9b33 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:10:37 +0100 Subject: [PATCH] fix: hardcover author search (#929) - Re-adds author search suggestions for Hardcover - Correctly routes author queries to correct ID or best match author-filtered book query. --- shelfmark/metadata_providers/hardcover.py | 167 +++++++++++++++++- .../metadata/test_hardcover_field_options.py | 16 +- .../metadata/test_hardcover_search_author.py | 126 ++++++++++++- 3 files changed, 298 insertions(+), 11 deletions(-) diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index f8fd32f1..4071706f 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -395,6 +395,76 @@ query GetSeriesBooks($seriesId: Int!) { } """ +AUTHOR_BOOKS_BY_ID_QUERY = """ +query GetAuthorBooks($authorId: Int!, $limit: Int!, $offset: Int!) { + authors(where: {id: {_eq: $authorId}}, limit: 1) { + name + contributions( + where: { + contributable_type: {_eq: "Book"}, + book: { + canonical_id: {_is_null: true}, + state: {_in: ["normalized", "normalizing"]} + } + }, + order_by: [ + {book: {users_count: desc_nulls_last}}, + {book: {ratings_count: desc_nulls_last}}, + {book: {release_date: asc_nulls_last}}, + {book: {id: asc}} + ], + limit: $limit, + offset: $offset + ) { + contribution + book { + id + title + subtitle + slug + release_date + headline + description + pages + rating + ratings_count + users_count + compilation + editions_count + cached_image + cached_contributors + contributions(where: {contribution: {_eq: "Author"}}) { + author { + name + } + } + featured_book_series { + position + series { + id + name + primary_books_count + } + } + } + } + contributions_aggregate( + where: { + contributable_type: {_eq: "Book"}, + book: { + canonical_id: {_is_null: true}, + state: {_in: ["normalized", "normalizing"]} + } + } + ) { + aggregate { + count + } + } + } +} +""" + HARDCOVER_STATUS_PREFIX = "status:" HARDCOVER_STATUSES: list[dict] = [ {"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"}, @@ -869,6 +939,7 @@ class HardcoverProvider(MetadataProvider): label="Author", placeholder="Search author...", description="Search by author name", + suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=author", ), TextSearchField( key="title", @@ -917,7 +988,7 @@ class HardcoverProvider(MetadataProvider): Returns (query, fields, weights) tuple. Fields/weights are None for general search. """ if author and not title and not series: - return author, "author_names", "1" + return author, None, None if title and not author and not series: return title, "title,alternative_titles", "5,1" if author and title and not series: @@ -1210,13 +1281,14 @@ class HardcoverProvider(MetadataProvider): if item is None: continue + author_id = coerce_int(item.get("id"), 0) label = str(item.get("name") or "").strip() normalized_label = label.casefold() - if not label or normalized_label in seen_labels: + if author_id < 1 or not label or normalized_label in seen_labels: continue seen_labels.add(normalized_label) - options.append({"value": label, "label": label}) + options.append({"value": f"id:{author_id}", "label": label}) return options @@ -1527,6 +1599,72 @@ class HardcoverProvider(MetadataProvider): has_more = offset + len(page_rows) < total_found return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more) + def _fetch_author_books_by_id( + self, + author_id: int, + page: int, + limit: int, + *, + exclude_compilations: bool, + exclude_unreleased: bool, + ) -> SearchResult: + """Fetch books for a selected Hardcover author.""" + if not self.api_key: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + offset = (page - 1) * limit + result = self._execute_query( + AUTHOR_BOOKS_BY_ID_QUERY, + {"authorId": author_id, "limit": limit, "offset": offset}, + ) + if not result: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + author_items = result.get("authors", []) + if not isinstance(author_items, list) or not author_items: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + author_data = author_items[0] if isinstance(author_items[0], dict) else {} + contributions = ( + author_data.get("contributions", []) if isinstance(author_data, dict) else [] + ) + aggregate = ( + author_data.get("contributions_aggregate", {}) if isinstance(author_data, dict) else {} + ) + total_found = coerce_int( + aggregate.get("aggregate", {}).get("count") if isinstance(aggregate, dict) else 0, + 0, + ) + today = datetime.now(UTC).date() + + books: list[BookMetadata] = [] + for row in contributions: + if not isinstance(row, dict): + continue + contribution = str(row.get("contribution") or "").strip() + if contribution and "author" not in contribution.casefold(): + continue + book_data = row.get("book", {}) + if not isinstance(book_data, dict) or not book_data: + continue + if exclude_compilations and book_data.get("compilation"): + continue + release_date = _parse_release_date(book_data.get("release_date")) + if exclude_unreleased and (release_date is None or release_date.date() > today): + continue + try: + parsed_book = self._parse_book(book_data) + books.append(parsed_book) + except (AttributeError, IndexError, KeyError, TypeError, ValueError) as exc: + logger.debug( + "Failed to parse Hardcover author book for author_id=%s: %s", + author_id, + exc, + ) + + has_more = offset + len(contributions) < total_found + return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more) + @cacheable(ttl=120, key_prefix="hardcover:user_lists") def _get_user_lists_cached(self, _cache_user_id: str) -> list[dict[str, str]]: """Return cached user lists keyed by Hardcover user id.""" @@ -2160,6 +2298,29 @@ class HardcoverProvider(MetadataProvider): exclude_unreleased=exclude_unreleased, ) + author_value_from_field = str(options.fields.get("author", "")).strip() + if author_value_from_field.startswith(HARDCOVER_LIST_ID_PREFIX): + try: + author_id = self._parse_prefixed_int(author_value_from_field, "author id") + except ValueError: + logger.debug("Invalid Hardcover author id field value: %s", author_value_from_field) + return SearchResult(books=[], page=options.page, total_found=0, has_more=False) + exclude_compilations = coerce_bool( + app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False), + default=False, + ) + exclude_unreleased = coerce_bool( + app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False), + default=False, + ) + return self._fetch_author_books_by_id( + author_id, + options.page, + options.limit, + exclude_compilations=exclude_compilations, + exclude_unreleased=exclude_unreleased, + ) + # Handle ISBN search separately if options.search_type == SearchType.ISBN: result = self.search_by_isbn(options.query) diff --git a/tests/metadata/test_hardcover_field_options.py b/tests/metadata/test_hardcover_field_options.py index 59e40c3e..1f938219 100644 --- a/tests/metadata/test_hardcover_field_options.py +++ b/tests/metadata/test_hardcover_field_options.py @@ -2,11 +2,13 @@ from shelfmark.metadata_providers.hardcover import HardcoverProvider class TestHardcoverFieldOptions: - def test_search_fields_enable_typeahead_for_series_only(self): + def test_search_fields_enable_typeahead_for_author_and_series(self): provider = HardcoverProvider(api_key="test-token") fields_by_key = {field.key: field for field in provider.search_fields} - assert fields_by_key["author"].suggestions_endpoint is None + assert fields_by_key["author"].suggestions_endpoint == ( + "/api/metadata/field-options?provider=hardcover&field=author" + ) assert fields_by_key["title"].suggestions_endpoint is None assert fields_by_key["series"].suggestions_endpoint == ( "/api/metadata/field-options?provider=hardcover&field=series" @@ -25,9 +27,9 @@ class TestHardcoverFieldOptions: "search": { "results": { "hits": [ - {"document": {"name": "Brandon Sanderson"}}, - {"document": {"name": "Brandon Sanderson"}}, - {"document": {"name": "Brian Sanderson"}}, + {"document": {"id": 1, "name": "Brandon Sanderson"}}, + {"document": {"id": 1, "name": "Brandon Sanderson"}}, + {"document": {"id": 2, "name": "Brian Sanderson"}}, ], "found": 3, } @@ -39,8 +41,8 @@ class TestHardcoverFieldOptions: options = provider.get_search_field_options("author", query="sand") assert options == [ - {"value": "Brandon Sanderson", "label": "Brandon Sanderson"}, - {"value": "Brian Sanderson", "label": "Brian Sanderson"}, + {"value": "id:1", "label": "Brandon Sanderson"}, + {"value": "id:2", "label": "Brian Sanderson"}, ] assert captured["variables"] == { "query": "sand", diff --git a/tests/metadata/test_hardcover_search_author.py b/tests/metadata/test_hardcover_search_author.py index 36c724c9..3780a0b4 100644 --- a/tests/metadata/test_hardcover_search_author.py +++ b/tests/metadata/test_hardcover_search_author.py @@ -1,4 +1,5 @@ -from shelfmark.metadata_providers.hardcover import _simplify_author_for_search +from shelfmark.metadata_providers import MetadataSearchOptions, SearchResult +from shelfmark.metadata_providers.hardcover import HardcoverProvider, _simplify_author_for_search class TestHardcoverSimplifyAuthorForSearch: @@ -13,3 +14,126 @@ class TestHardcoverSimplifyAuthorForSearch: def test_returns_none_when_no_change(self): assert _simplify_author_for_search("Frank Herbert") is None + + +class TestHardcoverAuthorSearch: + def test_author_text_search_uses_default_book_search_fields(self): + provider = HardcoverProvider(api_key="test-token") + + assert provider._build_search_params("", "Stephen King", "", "") == ( + "Stephen King", + None, + None, + ) + + def test_search_paginated_uses_selected_author_id(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + expected = SearchResult(books=[], page=2, total_found=14, has_more=True) + captured: dict[str, int] = {} + + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.app_config.get", + lambda key, default=None: { + "HARDCOVER_EXCLUDE_COMPILATIONS": True, + "HARDCOVER_EXCLUDE_UNRELEASED": False, + }.get(key, default), + ) + + def fake_fetch( + author_id: int, + page: int, + limit: int, + *, + exclude_compilations: bool, + exclude_unreleased: bool, + ) -> SearchResult: + captured["author_id"] = author_id + captured["page"] = page + captured["limit"] = limit + captured["exclude_compilations"] = int(exclude_compilations) + captured["exclude_unreleased"] = int(exclude_unreleased) + return expected + + monkeypatch.setattr(provider, "_fetch_author_books_by_id", fake_fetch) + + result = provider.search_paginated( + MetadataSearchOptions( + query="", + page=2, + limit=20, + fields={"author": "id:42"}, + ) + ) + + assert result == expected + assert captured == { + "author_id": 42, + "page": 2, + "limit": 20, + "exclude_compilations": 1, + "exclude_unreleased": 0, + } + + def test_fetch_author_books_by_id_returns_books(self, monkeypatch): + provider = HardcoverProvider(api_key="test-token") + captured: dict[str, object] = {} + + monkeypatch.setattr( + provider, + "_execute_query", + lambda query, variables: ( + captured.update({"query": query, "variables": variables}) + or { + "authors": [ + { + "name": "Stephen King", + "contributions": [ + { + "contribution": "Author, Narrator", + "book": { + "id": 1, + "title": "The Shining", + "subtitle": None, + "slug": "the-shining", + "release_date": "1977-01-28", + "headline": None, + "description": None, + "pages": 447, + "rating": 4.3, + "ratings_count": 1000, + "users_count": 2000, + "compilation": False, + "editions_count": 20, + "cached_image": {}, + "cached_contributors": [{"name": "Stephen King"}], + "contributions": [], + "featured_book_series": None, + }, + } + ], + "contributions_aggregate": {"aggregate": {"count": 1}}, + } + ] + } + ), + ) + + result = provider._fetch_author_books_by_id( + 42, + page=1, + limit=20, + exclude_compilations=True, + exclude_unreleased=True, + ) + + assert "contributions(" in str(captured["query"]) + assert ( + "contribution:" + not in str(captured["query"]).split("contributions(", 1)[1].split(") {", 1)[0] + ) + assert "canonical_id: {_is_null: true}" in str(captured["query"]) + assert captured["variables"] == {"authorId": 42, "limit": 20, "offset": 0} + assert result.total_found == 1 + assert result.has_more is False + assert [book.title for book in result.books] == ["The Shining"] + assert result.books[0].authors == ["Stephen King"]