add(metadata): request a whole ebook series from hardcover

This commit is contained in:
Steppenstreuner
2026-09-01 17:20:34 +02:00
parent ffcfe24dfb
commit 169e84901c
6 changed files with 232 additions and 25 deletions
+2 -1
View File
@@ -34,7 +34,8 @@ the same mount, otherwise moving turns into copy + delete.
2. **Search** look up a title (ebooks: Hardcover, Google Books and Open 2. **Search** look up a title (ebooks: Hardcover, Google Books and Open
Library, audiobooks: Audible plus MusicBrainz, manga: AniList) and request Library, audiobooks: Audible plus MusicBrainz, manga: AniList) and request
it, or add it by hand. The language filter narrows ebook and audiobook hits. For a series, it, or add it by hand. The language filter narrows ebook and audiobook hits. For a series,
*Ganze Serie…* requests every episode at once. *Ganze Serie…* requests every part at once — audiobooks from Audible's keyword
crawl, ebooks from Hardcover's series listing (needs the token).
3. **Import** scan the download folder. wordarr suggests file→request matches, 3. **Import** scan the download folder. wordarr suggests file→request matches,
which you confirm or correct; entries can be merged, split, or turned into which you confirm or correct; entries can be merged, split, or turned into
requests from their folder names. Every row names its format and size, and requests from their folder names. Every row names its format and size, and
+20 -10
View File
@@ -239,7 +239,9 @@ $("#search-form").addEventListener("submit", async (e) => {
) )
.join(""); .join("");
box.querySelectorAll("[data-series]").forEach((btn) => box.querySelectorAll("[data-series]").forEach((btn) =>
btn.addEventListener("click", () => openSeriesDialog(results[btn.dataset.series])) btn.addEventListener("click", () =>
openSeriesDialog(results[btn.dataset.series], type, language)
)
); );
box.querySelectorAll("[data-req]").forEach((btn) => box.querySelectorAll("[data-req]").forEach((btn) =>
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
@@ -274,6 +276,7 @@ $("#search-form").addEventListener("submit", async (e) => {
// ---- request a whole series ---- // ---- request a whole series ----
let seriesEpisodes = []; let seriesEpisodes = [];
let seriesType = "audiobook";
function renderSeriesList() { function renderSeriesList() {
const from = parseInt($("#series-from").value); const from = parseInt($("#series-from").value);
@@ -288,7 +291,9 @@ function renderSeriesList() {
let gap = ""; let gap = "";
if (e.volume != null && previous != null && e.volume > previous + 1) { if (e.volume != null && previous != null && e.volume > previous + 1) {
const missing = e.volume - previous - 1; const missing = e.volume - previous - 1;
gap = `<div class="gap">… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}${e.volume - 1})</div>`; const ebook = seriesType === "ebook";
const unit = ebook ? (missing > 1 ? "Bände" : "Band") : `Folge${missing > 1 ? "n" : ""}`;
gap = `<div class="gap">… ${missing} ${unit} nicht ${ebook ? "bei Hardcover" : "bei Audible"} gefunden (${previous + 1}${e.volume - 1})</div>`;
} }
if (e.volume != null) previous = e.volume; if (e.volume != null) previous = e.volume;
return `${gap}<label> return `${gap}<label>
@@ -314,20 +319,23 @@ function updateSeriesCount() {
btn.disabled = n === 0; btn.disabled = n === 0;
} }
async function openSeriesDialog(result) { async function openSeriesDialog(result, type = "audiobook", language = "") {
const dlg = $("#series-dialog"); const dlg = $("#series-dialog");
seriesType = type;
const unit = type === "ebook" ? "Bände" : "Folgen";
$("#series-title").textContent = result.series || "Serie"; $("#series-title").textContent = result.series || "Serie";
$("#series-info").innerHTML = '<span class="spinner"></span> Folgen werden gesucht…'; $("#series-info").innerHTML = `<span class="spinner"></span> ${unit} werden gesucht…`;
$("#series-list").innerHTML = ""; $("#series-list").innerHTML = "";
$("#series-from").value = ""; $("#series-from").value = "";
$("#series-to").value = ""; $("#series-to").value = "";
$("#series-library").innerHTML = libOptions("audiobook") || "<option value=''>— keine Library —</option>"; $("#series-library").innerHTML = libOptions(type) || "<option value=''>— keine Library —</option>";
$("#series-submit").disabled = true; $("#series-submit").disabled = true;
dlg.showModal(); dlg.showModal();
try { try {
seriesEpisodes = await api( seriesEpisodes = await api(
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` + `/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
`&title=${encodeURIComponent(result.series || result.title)}` `&title=${encodeURIComponent(result.series || result.title)}` +
`&media_type=${type}&language=${encodeURIComponent(language)}`
); );
} catch (err) { } catch (err) {
$("#series-info").textContent = err.message; $("#series-info").textContent = err.message;
@@ -335,9 +343,11 @@ async function openSeriesDialog(result) {
} }
const numbered = seriesEpisodes.filter((e) => e.volume != null); const numbered = seriesEpisodes.filter((e) => e.volume != null);
$("#series-info").textContent = $("#series-info").textContent =
`${seriesEpisodes.length} Folgen gefunden` + `${seriesEpisodes.length} ${unit} gefunden` +
(numbered.length ? ` (Nr. ${numbered[0].volume}${numbered[numbered.length - 1].volume})` : "") + (numbered.length ? ` (Nr. ${numbered[0].volume}${numbered[numbered.length - 1].volume})` : "") +
". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen."; (type === "ebook"
? ". Bände ohne Ausgabe in der gewählten Sprache stehen mit ihrem Originaltitel da."
: ". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.");
renderSeriesList(); renderSeriesList();
} }
@@ -356,7 +366,7 @@ $("#series-cancel").addEventListener("click", () => $("#series-dialog").close())
$("#series-submit").addEventListener("click", async () => { $("#series-submit").addEventListener("click", async () => {
const libId = $("#series-library").value; const libId = $("#series-library").value;
if (!libId) { toast("Erst eine Audiobook-Library anlegen (Tab Libraries)", true); return; } if (!libId) { toast(`Erst eine ${seriesType === "ebook" ? "Ebook" : "Audiobook"}-Library anlegen (Tab Libraries)`, true); return; }
const items = seriesChecked().map((e) => ({ const items = seriesChecked().map((e) => ({
title: e.title, authors: e.authors, narrator: e.narrator || "", title: e.title, authors: e.authors, narrator: e.narrator || "",
external_id: e.external_id, year: e.year, series: e.series, external_id: e.external_id, year: e.year, series: e.series,
@@ -370,7 +380,7 @@ $("#series-submit").addEventListener("click", async () => {
method: "POST", method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }), body: JSON.stringify({ library_id: parseInt(libId), items }),
}); });
setLastLib("audiobook", libId); setLastLib(seriesType, libId);
toast( toast(
`${res.created.length} Anfragen angelegt` + `${res.created.length} Anfragen angelegt` +
(res.skipped ? `, ${res.skipped} bereits vorhanden` : "") (res.skipped ? `, ${res.skipped} bereits vorhanden` : "")
+74
View File
@@ -83,6 +83,80 @@ def test_hardcover_drops_books_without_an_edition_in_that_language(monkeypatch):
assert asyncio.run(hardcover.search("hobbit", "german")) == [] assert asyncio.run(hardcover.search("hobbit", "german")) == []
SERIES_ROWS = {
"name": "Die drei Fragezeichen",
"book_series": [
{"position": 1, "book": {
"id": 1, "title": "Super-Papagei", "release_year": 1968,
"contributions": [{"author": {"name": "Robert Arthur"}}],
"cached_image": {"url": "https://example.invalid/1.jpg"}}},
{"position": 2, "book": {"id": 2, "title": "Misteri Danau"}},
{"position": 2, "book": {"id": 3, "title": "Der Phantomsee"}},
{"position": 2, "book": {"id": 2, "title": "duplicate row"}},
{"position": 0, "book": {"id": 4, "title": "Sonderband"}},
],
}
def series_client(monkeypatch, editions):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content)
seen.append(body["variables"])
if "editions" in body["query"]:
return httpx.Response(200, json={"data": {"editions": editions}})
return httpx.Response(200, json={"data": {"series": [SERIES_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
return seen
def test_hardcover_series_sorts_by_volume_and_keeps_the_first_row(monkeypatch):
series_client(monkeypatch, [])
books = asyncio.run(hardcover.series_books("10146"))
assert [(b.volume, b.title) for b in books] == [
(1, "Super-Papagei"), (2, "Misteri Danau"), (None, "Sonderband"),
]
first = books[0]
assert (first.series, first.series_id, first.authors, first.media_type) == (
"Die drei Fragezeichen", "10146", "Robert Arthur", "ebook")
assert first.cover_url == "https://example.invalid/1.jpg"
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},
])
books = asyncio.run(hardcover.series_books("10146", "german"))
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
second = next(b for b in books if b.volume == 2)
assert (second.title, second.language, second.external_id, second.year) == (
"Der Phantomsee", "german", "9783440032251", 1969)
# a volume without a German edition keeps its original row instead of vanishing
assert [b.title for b in books] == ["Super-Papagei", "Der Phantomsee", "Sonderband"]
def test_hardcover_series_survives_a_broken_edition_lookup(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(500)
return httpx.Response(200, json={"data": {"series": [SERIES_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
assert len(asyncio.run(hardcover.series_books("10146", "german"))) == 3
def test_hardcover_series_needs_a_numeric_id(monkeypatch):
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
assert asyncio.run(hardcover.series_books("")) == []
def test_google_books_restricts_the_language(monkeypatch): def test_google_books_restricts_the_language(monkeypatch):
seen = [] seen = []
+9 -6
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from ..metadata import PROVIDERS, audible from ..metadata import PROVIDERS, SERIES_PROVIDERS
from ..metadata.base import MetadataResult from ..metadata.base import MetadataResult
router = APIRouter(prefix="/api/search", tags=["search"]) router = APIRouter(prefix="/api/search", tags=["search"])
@@ -18,13 +18,16 @@ async def search(media_type: str, q: str, language: str = "", fallback: bool = T
@router.get("/series", response_model=list[MetadataResult]) @router.get("/series", response_model=list[MetadataResult])
async def series(series_id: str, title: str): async def series(series_id: str, title: str = "", media_type: str = "audiobook",
"""All episodes of an Audible series, sorted by episode number. Audible has language: str = ""):
no series endpoint, so this is a paged keyword crawl and may miss a few """All parts of a series, by number. Audible has no series endpoint, so
episodes - those can still be added by hand.""" audiobooks are a keyword crawl that may miss episodes; Hardcover is exact."""
lookup = SERIES_PROVIDERS.get(media_type)
if not lookup:
raise HTTPException(400, f"no series lookup for media_type: {media_type}")
if not series_id: if not series_id:
raise HTTPException(400, "series_id required") raise HTTPException(400, "series_id required")
try: try:
return await audible.series_episodes(series_id, title) return await lookup(series_id, title, language)
except Exception as exc: except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}") raise HTTPException(502, f"metadata provider error: {exc}")
+14
View File
@@ -85,8 +85,22 @@ async def audiobook_search(query: str, language: str = "",
return hits + [r for r in extra if (r.title.strip().lower(), r.volume) not in seen] return hits + [r for r in extra if (r.title.strip().lower(), r.volume) not in seen]
async def _audiobook_series(series_id: str, title: str, language: str = ""):
return await audible.series_episodes(series_id, title)
async def _ebook_series(series_id: str, title: str = "", language: str = ""):
return await hardcover.series_books(series_id, language)
PROVIDERS = { PROVIDERS = {
"ebook": ebook_search, "ebook": ebook_search,
"audiobook": audiobook_search, "audiobook": audiobook_search,
"comic": anilist.search, "comic": anilist.search,
} }
# a media type without an entry gets no "whole series" button
SERIES_PROVIDERS = {
"audiobook": _audiobook_series,
"ebook": _ebook_series,
}
+113 -8
View File
@@ -8,6 +8,7 @@ The GraphQL search is Typesense backed and returns its hits as untyped JSON, so
every field is read defensively. A book row describes the work, not one edition, every field is read defensively. A book row describes the work, not one edition,
so when a language is requested the matching editions are fetched in a second so when a language is requested the matching editions are fetched in a second
query and their title/ISBN/cover replace the ones of the original edition. query and their title/ISBN/cover replace the ones of the original edition.
Series are one query - unlike Audible, Hardcover knows them.
""" """
import logging import logging
@@ -21,6 +22,7 @@ ENDPOINT = "https://api.hardcover.app/v1/graphql"
TOKEN = os.environ.get("WORDARR_HARDCOVER_TOKEN", "").strip() TOKEN = os.environ.get("WORDARR_HARDCOVER_TOKEN", "").strip()
LANGUAGES = {"german": "de", "english": "en"} LANGUAGES = {"german": "de", "english": "en"}
RESULT_LIMIT = 10 RESULT_LIMIT = 10
SERIES_LIMIT = 500 # books per series
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -49,6 +51,25 @@ query Editions($ids: [Int!]!, $lang: String!) {
""" """
_SERIES = """
query Series($id: Int!, $limit: Int!) {
series(where: {id: {_eq: $id}}) {
name
book_series(order_by: {position: asc_nulls_last}, limit: $limit) {
position
book {
id
title
release_year
cached_image
contributions(limit: 3) { author { name } }
}
}
}
}
"""
async def _post(client: httpx.AsyncClient, query: str, variables: dict) -> dict: async def _post(client: httpx.AsyncClient, query: str, variables: dict) -> dict:
resp = await client.post( resp = await client.post(
ENDPOINT, ENDPOINT,
@@ -88,8 +109,27 @@ def _isbn(*candidates) -> str:
return "" return ""
def _series_of(doc: dict) -> tuple[str, str]:
"""Name and id of the featured series; series_names[0] can be another one."""
featured = ((doc.get("featured_series") or {}).get("series")) or {}
if featured.get("name"):
return featured["name"], str(featured.get("id") or "")
names = doc.get("series_names") or []
ids = doc.get("series_ids") or []
return (names[0] if names else ""), (str(ids[0]) if ids else "")
def _volume(position) -> int | None:
# position 0 means "somewhere in this series", not "episode zero"
try:
number = int(float(position))
except (TypeError, ValueError):
return None
return number or None
def _to_result(doc: dict, language: str) -> MetadataResult: def _to_result(doc: dict, language: str) -> MetadataResult:
series = doc.get("series_names") or [] series, series_id = _series_of(doc)
year = doc.get("release_year") year = doc.get("release_year")
return MetadataResult( return MetadataResult(
media_type="ebook", media_type="ebook",
@@ -97,16 +137,18 @@ def _to_result(doc: dict, language: str) -> MetadataResult:
title=doc.get("title", ""), title=doc.get("title", ""),
authors=_authors(doc), authors=_authors(doc),
external_id=_isbn(doc.get("isbns")), external_id=_isbn(doc.get("isbns")),
year=int(year) if str(year).isdigit() else None, year=int(str(year)) if str(year).isdigit() else None,
language=language, language=language,
series=series[0] if series else "", series=series,
series_id=series_id,
volume=_volume(doc.get("featured_series_position")),
cover_url=_image_url(doc.get("image")), cover_url=_image_url(doc.get("image")),
) )
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult], async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult],
language: str) -> list[MetadataResult]: language: str) -> dict[int, MetadataResult]:
"""Replace each hit with its edition in the wanted language, drop the rest.""" """The hits that have an edition in the wanted language, swapped in."""
data = await _post(client, _EDITIONS, data = await _post(client, _EDITIONS,
{"ids": list(results), "lang": LANGUAGES[language]}) {"ids": list(results), "lang": LANGUAGES[language]})
localized: dict[int, MetadataResult] = {} localized: dict[int, MetadataResult] = {}
@@ -118,11 +160,19 @@ async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult
release = str(ed.get("release_date") or "") release = str(ed.get("release_date") or "")
localized[book_id] = base.model_copy(update={ localized[book_id] = base.model_copy(update={
"title": ed.get("title") or base.title, "title": ed.get("title") or base.title,
"external_id": _isbn(ed.get("isbn_13"), ed.get("isbn_10")) or base.external_id, "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, "year": int(release[:4]) if release[:4].isdigit() else base.year,
"language": language, "language": language,
"cover_url": _image_url(ed.get("cached_image")) or base.cover_url, "cover_url": _image_url(ed.get("cached_image")) or base.cover_url,
}) })
return localized
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."""
localized = await _localized(client, results, language)
return [localized[book_id] for book_id in results if book_id in localized] return [localized[book_id] for book_id in results if book_id in localized]
@@ -139,7 +189,7 @@ async def search(query: str, language: str = "",
doc = hit.get("document") or {} doc = hit.get("document") or {}
book_id = doc.get("id") book_id = doc.get("id")
try: try:
book_id = int(book_id) book_id = int(str(book_id))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if doc.get("title"): if doc.get("title"):
@@ -152,3 +202,58 @@ async def search(query: str, language: str = "",
# without the edition lookup the hits are still worth showing # without the edition lookup the hits are still worth showing
log.warning("hardcover edition lookup failed for %r: %s", query, exc) log.warning("hardcover edition lookup failed for %r: %s", query, exc)
return list(results.values()) return list(results.values())
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."""
if not TOKEN:
return []
try:
numeric_id = int(str(series_id))
except (TypeError, ValueError):
return []
async with httpx.AsyncClient(timeout=30) as client:
data = await _post(client, _SERIES, {"id": numeric_id, "limit": SERIES_LIMIT})
row = (data.get("series") or [{}])[0]
name = row.get("name", "")
slots: dict[int, list[int]] = {} # volume -> book rows filed under it
loose: list[int] = []
books: dict[int, MetadataResult] = {}
for entry in row.get("book_series") or []:
book = entry.get("book") or {}
try:
book_id = int(str(book.get("id")))
except (TypeError, ValueError):
continue
if not book.get("title") or book_id in books:
continue
volume = _volume(entry.get("position"))
books[book_id] = _to_result(
{**book, "image": book.get("cached_image"),
"series_names": [name], "series_ids": [numeric_id],
"featured_series_position": volume},
"",
)
if volume is None:
loose.append(book_id)
else:
slots.setdefault(volume, []).append(book_id)
localized: dict[int, MetadataResult] = {}
if language in LANGUAGES and books:
try:
localized = await _localized(client, books, language)
except (httpx.HTTPError, RuntimeError) as exc:
log.warning("hardcover series editions failed for %s: %s",
series_id, exc)
def pick(candidates: list[int]) -> MetadataResult:
best = next((i for i in candidates if i in localized), candidates[0])
return localized.get(best) or books[best]
return ([pick(slots[volume]) for volume in sorted(slots)]
+ [localized.get(i) or books[i] for i in loose])