From 13e2a0693ed1333b8a4432c39850a853d5a86e99 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Tue, 15 Sep 2026 09:10:43 +0200 Subject: [PATCH] fix(metadata): pick the right book row per hardcover series position --- tests/test_ebooks.py | 40 ++++++++++++++++++++++++++++++ wordarr/metadata/hardcover.py | 46 ++++++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/tests/test_ebooks.py b/tests/test_ebooks.py index cc5825a..9f88c62 100644 --- a/tests/test_ebooks.py +++ b/tests/test_ebooks.py @@ -140,6 +140,46 @@ def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkey assert [b.title for b in books] == ["Super-Papagei", "Der Phantomsee", "Sonderband"] +INTRUDER_ROWS = { + "name": "Villains", + "book_series": [ + {"position": 1, "book": { + "id": 1, "title": "Vicious", "users_count": 1833, + "contributions": [{"author": {"name": "V. E. Schwab"}}]}}, + {"position": 1, "book": { + "id": 2, "title": "Die Schoenste im ganzen Land", "users_count": 1, + "contributions": [{"author": {"name": "Serena Valentino"}}]}}, + {"position": 2, "book": { + "id": 3, "title": "Coeur de pierre", "users_count": 1, + "contributions": [{"author": {"name": "Serena Valentino"}}]}}, + {"position": 2, "book": { + "id": 4, "title": "Cold Hearted", "users_count": 76, + "contributions": [{"author": {"name": "Serena Valentino"}}]}}, + ], +} + + +def test_hardcover_series_skips_a_foreign_book_filed_under_a_volume(monkeypatch): + """Hardcover lets anyone file a book into a series - "Vicious" sits on volume + 1 of the Disney Villains and has a German edition and 1833 readers, so both + tie breakers would hand it the slot.""" + 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": 1, "title": "Vicious - Das Boese in uns"}, + {"book_id": 2, "title": "Die Schoenste im ganzen Land"}, + ]}}) + return httpx.Response(200, json={"data": {"series": [INTRUDER_ROWS]}}) + + monkeypatch.setattr(hardcover, "TOKEN", "test-token") + mock_client(monkeypatch, hardcover, handler) + books = asyncio.run(hardcover.series_books("976", "german")) + # volume 2 has no German edition: the most read row beats the French one + assert [b.title for b in books] == [ + "Die Schoenste im ganzen Land", "Cold Hearted"] + + def test_hardcover_series_survives_a_broken_edition_lookup(monkeypatch): def handler(request: httpx.Request) -> httpx.Response: import json diff --git a/wordarr/metadata/hardcover.py b/wordarr/metadata/hardcover.py index d1d5857..ced8dd6 100644 --- a/wordarr/metadata/hardcover.py +++ b/wordarr/metadata/hardcover.py @@ -13,6 +13,7 @@ Series are one query - unlike Audible, Hardcover knows them. import logging import os +from collections import Counter import httpx @@ -61,6 +62,7 @@ query Series($id: Int!, $limit: Int!) { id title release_year + users_count cached_image contributions(limit: 3) { author { name } } } @@ -204,10 +206,38 @@ async def search(query: str, language: str = "", return list(results.values()) +def _count(value) -> int: + try: + return int(str(value)) + except (TypeError, ValueError): + return 0 + + +def _author_set(book: dict) -> set[str]: + return {name.strip().lower() + for name in _authors(book).split(", ") if name.strip()} + + +def _main_author(authors: dict[int, set[str]]) -> str: + """The name behind the series, or "" when the rows do not agree on one. + + Translators and publishers ride along in the contributions, so the author of + the series is simply the name most rows share - and a row without it is the + foreign book that someone filed under this position by mistake. + """ + tally = Counter(name for names in authors.values() for name in names) + if not tally: + return "" + name, shared = tally.most_common(1)[0] + return name if shared * 2 > len(authors) else "" + + async def series_books(series_id: str, language: str = "") -> list[MetadataResult]: """Every volume of a series, by position. A position often holds several book - rows (the same story as another work); the one with an edition in the wanted - language wins the slot, else the first. Unnumbered books go last.""" + rows - other editions of the story, and now and then a foreign book someone + filed here by mistake. The slot goes to the row that is by the author of the + series, has an edition in the wanted language and is the most read of those. + Unnumbered books go last.""" if not TOKEN: return [] try: @@ -223,6 +253,8 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul slots: dict[int, list[int]] = {} # volume -> book rows filed under it loose: list[int] = [] books: dict[int, MetadataResult] = {} + authors: dict[int, set[str]] = {} + readers: dict[int, int] = {} for entry in row.get("book_series") or []: book = entry.get("book") or {} try: @@ -238,6 +270,8 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul "featured_series_position": volume}, "", ) + authors[book_id] = _author_set(book) + readers[book_id] = _count(book.get("users_count")) if volume is None: loose.append(book_id) else: @@ -251,8 +285,14 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul log.warning("hardcover series editions failed for %s: %s", series_id, exc) + author = _main_author(authors) + + def rank(book_id: int) -> tuple: + return (author in authors[book_id] if author else True, + book_id in localized, readers[book_id]) + def pick(candidates: list[int]) -> MetadataResult: - best = next((i for i in candidates if i in localized), candidates[0]) + best = max(candidates, key=rank) return localized.get(best) or books[best] return ([pick(slots[volume]) for volume in sorted(slots)]