mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 22:05:45 +01:00
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:
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user