fix(metadata): pick the right book row per hardcover series position
Build image / image (push) Successful in 1m49s
Build image / image (push) Successful in 1m49s
This commit is contained in:
1 parent
dc83071a0a
commit
13e2a0693e
2 files changed
+83
-3
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in new issue
Block a user