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.
This commit is contained in:
Alex
2026-04-29 19:10:37 +01:00
committed by GitHub
parent c7619488cd
commit 4e41b1a8ec
3 changed files with 298 additions and 11 deletions
+164 -3
View File
@@ -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)
@@ -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",
+125 -1
View File
@@ -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"]