fix(metadata): pick the right book row per hardcover series position
Build image / image (push) Successful in 1m49s

This commit is contained in:
Steppenstreuner committed 2026-09-15 09:10:43 +02:00
1 parent dc83071a0a
commit 13e2a0693e
2 files changed
+83 -3

No files matched your search

+40
View File
@@ -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"] 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 test_hardcover_series_survives_a_broken_edition_lookup(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
import json import json
+43 -3
View File
@@ -13,6 +13,7 @@ Series are one query - unlike Audible, Hardcover knows them.
import logging import logging
import os import os
from collections import Counter
import httpx import httpx
@@ -61,6 +62,7 @@ query Series($id: Int!, $limit: Int!) {
id id
title title
release_year release_year
users_count
cached_image cached_image
contributions(limit: 3) { author { name } } contributions(limit: 3) { author { name } }
} }
@@ -204,10 +206,38 @@ async def search(query: str, language: str = "",
return list(results.values()) 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]: async def series_books(series_id: str, language: str = "") -> list[MetadataResult]:
"""Every volume of a series, by position. A position often holds several book """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 rows - other editions of the story, and now and then a foreign book someone
language wins the slot, else the first. Unnumbered books go last.""" 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: if not TOKEN:
return [] return []
try: 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 slots: dict[int, list[int]] = {} # volume -> book rows filed under it
loose: list[int] = [] loose: list[int] = []
books: dict[int, MetadataResult] = {} books: dict[int, MetadataResult] = {}
authors: dict[int, set[str]] = {}
readers: dict[int, int] = {}
for entry in row.get("book_series") or []: for entry in row.get("book_series") or []:
book = entry.get("book") or {} book = entry.get("book") or {}
try: try:
@@ -238,6 +270,8 @@ async def series_books(series_id: str, language: str = "") -> list[MetadataResul
"featured_series_position": volume}, "featured_series_position": volume},
"", "",
) )
authors[book_id] = _author_set(book)
readers[book_id] = _count(book.get("users_count"))
if volume is None: if volume is None:
loose.append(book_id) loose.append(book_id)
else: 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", log.warning("hardcover series editions failed for %s: %s",
series_id, exc) 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: 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 localized.get(best) or books[best]
return ([pick(slots[volume]) for volume in sorted(slots)] return ([pick(slots[volume]) for volume in sorted(slots)]