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,
|
"isbn_10": None,
|
||||||
"release_date": "1957-03-01",
|
"release_date": "1957-03-01",
|
||||||
"cached_image": "https://example.invalid/hobbit-de.jpg",
|
"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")) == []
|
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 = {
|
SERIES_ROWS = {
|
||||||
"name": "Die drei Fragezeichen",
|
"name": "Die drei Fragezeichen",
|
||||||
"book_series": [
|
"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):
|
def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch):
|
||||||
calls = series_client(monkeypatch, [
|
calls = series_client(monkeypatch, [
|
||||||
{"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251",
|
{"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"))
|
books = asyncio.run(hardcover.series_books("10146", "german"))
|
||||||
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
|
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))
|
monkeypatch.setattr(openlibrary, "search", lambda q, lang="", fb=True: record("ol", q, lang))
|
||||||
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
|
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
|
||||||
assert called == ["gb"] # no token, so Google Books stands in for Hardcover
|
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 = """
|
_EDITIONS = """
|
||||||
query Editions($ids: [Int!]!, $lang: String!) {
|
query Editions($ids: [Int!]!, $lang: String!) {
|
||||||
editions(
|
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}
|
order_by: {users_count: desc_nulls_last}
|
||||||
) {
|
) {
|
||||||
book_id
|
book_id
|
||||||
@@ -47,6 +50,13 @@ query Editions($ids: [Int!]!, $lang: String!) {
|
|||||||
isbn_10
|
isbn_10
|
||||||
release_date
|
release_date
|
||||||
cached_image
|
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],
|
async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||||
language: str) -> dict[int, MetadataResult]:
|
language: str) -> dict[int, MetadataResult]:
|
||||||
"""The hits that have an edition in the wanted language, swapped in."""
|
"""The hits that have an edition in the wanted language, swapped in.
|
||||||
data = await _post(client, _EDITIONS,
|
|
||||||
{"ids": list(results), "lang": LANGUAGES[language]})
|
An edition without a language counts too, unless the book has one in another
|
||||||
localized: dict[int, MetadataResult] = {}
|
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 []:
|
for ed in data.get("editions") or []:
|
||||||
book_id = ed.get("book_id")
|
book_id = ed.get("book_id")
|
||||||
base = results.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
|
continue # the first edition is the most read one
|
||||||
release = str(ed.get("release_date") or "")
|
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,
|
"title": ed.get("title") or base.title,
|
||||||
"external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10"))
|
"external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10"))
|
||||||
or base.external_id),
|
or base.external_id),
|
||||||
"year": int(release[:4]) if release[:4].isdigit() else base.year,
|
"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,
|
"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],
|
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult],
|
||||||
language: str) -> list[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)
|
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 = "",
|
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)
|
author = _main_author(authors)
|
||||||
|
|
||||||
def rank(book_id: int) -> tuple:
|
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,
|
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:
|
def pick(candidates: list[int]) -> MetadataResult:
|
||||||
best = max(candidates, key=rank)
|
best = max(candidates, key=rank)
|
||||||
|
|||||||
Reference in new issue
Block a user