fix(metadata): keep hardcover books whose editions carry no language
This commit is contained in:
1 parent
91ad7ff98b
commit
b51d5754e0
2 files changed
+95
-13
No files matched your search
+54
-1
@@ -25,6 +25,7 @@ GERMAN_EDITION = {
|
||||
"isbn_10": None,
|
||||
"release_date": "1957-03-01",
|
||||
"cached_image": "https://example.invalid/hobbit-de.jpg",
|
||||
"language": {"code2": "de"},
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +84,38 @@ def test_hardcover_drops_books_without_an_edition_in_that_language(monkeypatch):
|
||||
assert asyncio.run(hardcover.search("hobbit", "german")) == []
|
||||
|
||||
|
||||
def test_hardcover_keeps_a_book_whose_editions_carry_no_language(monkeypatch):
|
||||
"""German translations are often their own book row with untagged editions."""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
if "editions" in json.loads(request.content)["query"]:
|
||||
return httpx.Response(200, json={"data": {
|
||||
"editions": [{"book_id": 42, "title": "Der Hobbit", "language": None}],
|
||||
"other": [],
|
||||
}})
|
||||
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
|
||||
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
mock_client(monkeypatch, hardcover, handler)
|
||||
hit = asyncio.run(hardcover.search("hobbit", "german"))[0]
|
||||
assert (hit.title, hit.language) == ("Der Hobbit", "")
|
||||
|
||||
|
||||
def test_hardcover_drops_an_untagged_edition_of_a_foreign_book(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
if "editions" in json.loads(request.content)["query"]:
|
||||
return httpx.Response(200, json={"data": {
|
||||
"editions": [{"book_id": 42, "title": "The Hobbit", "language": None}],
|
||||
"other": [{"book_id": 42}],
|
||||
}})
|
||||
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
|
||||
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
mock_client(monkeypatch, hardcover, handler)
|
||||
assert asyncio.run(hardcover.search("hobbit", "german")) == []
|
||||
|
||||
|
||||
SERIES_ROWS = {
|
||||
"name": "Die drei Fragezeichen",
|
||||
"book_series": [
|
||||
@@ -129,7 +162,8 @@ def test_hardcover_series_sorts_by_volume_and_keeps_the_first_row(monkeypatch):
|
||||
def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch):
|
||||
calls = series_client(monkeypatch, [
|
||||
{"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251",
|
||||
"release_date": "1969-01-01", "cached_image": None},
|
||||
"release_date": "1969-01-01", "cached_image": None,
|
||||
"language": {"code2": "de"}},
|
||||
])
|
||||
books = asyncio.run(hardcover.series_books("10146", "german"))
|
||||
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
|
||||
@@ -280,3 +314,22 @@ def test_automatic_search_asks_only_the_best_provider(monkeypatch):
|
||||
monkeypatch.setattr(openlibrary, "search", lambda q, lang="", fb=True: record("ol", q, lang))
|
||||
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
|
||||
assert called == ["gb"] # no token, so Google Books stands in for Hardcover
|
||||
|
||||
|
||||
def test_hardcover_puts_hits_of_unknown_language_last(monkeypatch):
|
||||
untagged = {"document": {**HARDCOVER_HIT["document"], "id": "7", "title": "Bundle"}}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
if "editions" in json.loads(request.content)["query"]:
|
||||
return httpx.Response(200, json={"data": {"editions": [
|
||||
{"book_id": 7, "title": "Bundle", "language": None},
|
||||
GERMAN_EDITION,
|
||||
]}})
|
||||
return httpx.Response(200, json={"data": {"search": {"results": {
|
||||
"hits": [untagged, HARDCOVER_HIT]}}}})
|
||||
|
||||
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
|
||||
mock_client(monkeypatch, hardcover, handler)
|
||||
hits = asyncio.run(hardcover.search("hobbit", "german"))
|
||||
assert [h.title for h in hits] == ["Der Hobbit", "Bundle"]
|
||||
@@ -38,7 +38,10 @@ query Search($q: String!, $per: Int!) {
|
||||
_EDITIONS = """
|
||||
query Editions($ids: [Int!]!, $lang: String!) {
|
||||
editions(
|
||||
where: {book_id: {_in: $ids}, language: {code2: {_eq: $lang}}}
|
||||
where: {
|
||||
book_id: {_in: $ids}
|
||||
_or: [{language: {code2: {_eq: $lang}}}, {language_id: {_is_null: true}}]
|
||||
}
|
||||
order_by: {users_count: desc_nulls_last}
|
||||
) {
|
||||
book_id
|
||||
@@ -47,6 +50,13 @@ query Editions($ids: [Int!]!, $lang: String!) {
|
||||
isbn_10
|
||||
release_date
|
||||
cached_image
|
||||
language { code2 }
|
||||
}
|
||||
other: editions(
|
||||
where: {book_id: {_in: $ids}, language: {code2: {_neq: $lang}}}
|
||||
distinct_on: book_id
|
||||
) {
|
||||
book_id
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -150,32 +160,49 @@ def _to_result(doc: dict, language: str) -> MetadataResult:
|
||||
|
||||
async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||
language: str) -> dict[int, MetadataResult]:
|
||||
"""The hits that have an edition in the wanted language, swapped in."""
|
||||
data = await _post(client, _EDITIONS,
|
||||
{"ids": list(results), "lang": LANGUAGES[language]})
|
||||
localized: dict[int, MetadataResult] = {}
|
||||
"""The hits that have an edition in the wanted language, swapped in.
|
||||
|
||||
An edition without a language counts too, unless the book has one in another
|
||||
language: translations are often a book row of their own whose editions nobody
|
||||
tagged ("Tote Augen"), while an English row always has tagged editions.
|
||||
"""
|
||||
code = LANGUAGES[language]
|
||||
data = await _post(client, _EDITIONS, {"ids": list(results), "lang": code})
|
||||
foreign = {ed.get("book_id") for ed in data.get("other") or []}
|
||||
known: dict[int, MetadataResult] = {}
|
||||
unknown: dict[int, MetadataResult] = {}
|
||||
for ed in data.get("editions") or []:
|
||||
book_id = ed.get("book_id")
|
||||
base = results.get(book_id)
|
||||
if base is None or book_id in localized:
|
||||
if base is None:
|
||||
continue
|
||||
if (ed.get("language") or {}).get("code2") == code:
|
||||
target, tag = known, language
|
||||
elif book_id not in foreign:
|
||||
target, tag = unknown, ""
|
||||
else:
|
||||
continue
|
||||
if book_id in target:
|
||||
continue # the first edition is the most read one
|
||||
release = str(ed.get("release_date") or "")
|
||||
localized[book_id] = base.model_copy(update={
|
||||
target[book_id] = base.model_copy(update={
|
||||
"title": ed.get("title") or base.title,
|
||||
"external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10"))
|
||||
or base.external_id),
|
||||
"year": int(release[:4]) if release[:4].isdigit() else base.year,
|
||||
"language": language,
|
||||
"language": tag,
|
||||
"cover_url": _image_url(ed.get("cached_image")) or base.cover_url,
|
||||
})
|
||||
return localized
|
||||
return {**unknown, **known}
|
||||
|
||||
|
||||
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||
language: str) -> list[MetadataResult]:
|
||||
"""Replace each hit with its edition in the wanted language, drop the rest."""
|
||||
"""Replace each hit with its edition in the wanted language, drop the rest.
|
||||
Hits whose language is only unknown go last."""
|
||||
localized = await _localized(client, results, language)
|
||||
return [localized[book_id] for book_id in results if book_id in localized]
|
||||
hits = [localized[book_id] for book_id in results if book_id in localized]
|
||||
return sorted(hits, key=lambda r: not r.language)
|
||||
|
||||
|
||||
async def search(query: str, language: str = "",
|
||||
@@ -288,8 +315,10 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul
|
||||
author = _main_author(authors)
|
||||
|
||||
def rank(book_id: int) -> tuple:
|
||||
# an untagged edition is no evidence: bundles and Spanish rows have them too
|
||||
edition = localized.get(book_id)
|
||||
return (author in authors[book_id] if author else True,
|
||||
book_id in localized, readers[book_id])
|
||||
bool(edition and edition.language), readers[book_id])
|
||||
|
||||
def pick(candidates: list[int]) -> MetadataResult:
|
||||
best = max(candidates, key=rank)
|
||||
|
||||
Reference in new issue
Block a user