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"]
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user