From b51d5754e03673cb91c05c57e8f6b057d1606ae5 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Fri, 25 Sep 2026 16:11:35 +0200 Subject: [PATCH] fix(metadata): keep hardcover books whose editions carry no language --- tests/test_ebooks.py | 55 ++++++++++++++++++++++++++++++++++- wordarr/metadata/hardcover.py | 53 +++++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/tests/test_ebooks.py b/tests/test_ebooks.py index 9f88c62..a38e4af 100644 --- a/tests/test_ebooks.py +++ b/tests/test_ebooks.py @@ -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"] diff --git a/wordarr/metadata/hardcover.py b/wordarr/metadata/hardcover.py index ced8dd6..ea32f37 100644 --- a/wordarr/metadata/hardcover.py +++ b/wordarr/metadata/hardcover.py @@ -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)