add(metadata): request a whole ebook series from hardcover
This commit is contained in:
@@ -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
@@ -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` : "")
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user