From 97e4b10ac8e4306155f022502b93b29dac76f618 Mon Sep 17 00:00:00 2001 From: JohannesBOT Date: Sat, 30 May 2026 09:23:58 +0200 Subject: [PATCH] missing cover fix --- src/ComicInfoBuilder.py | 57 +------- src/MangaBakaWorksResolver.py | 239 +++++++++++++++++++++++++--------- src/SuwayomiMover.py | 26 ++-- 3 files changed, 193 insertions(+), 129 deletions(-) diff --git a/src/ComicInfoBuilder.py b/src/ComicInfoBuilder.py index 9336db7..70f6bd4 100644 --- a/src/ComicInfoBuilder.py +++ b/src/ComicInfoBuilder.py @@ -45,7 +45,7 @@ from pathlib import Path import requests from MangadexVolumeResolver import MangaDexVolumeResolver -from MangaBakaWorksResolver import MangaBakaWorksResolver +from MangaBakaWorksResolver import MangaBakaWorksResolver, _pick_image_url from MALResolver import MALResolver from AniListResolver import AniListResolver from MatchesCache import MatchesCache @@ -998,59 +998,12 @@ class ComicInfoBuilder: # -------------------------------------------------------------------------- -# Module-level helpers (shared with MangaBakaWorksResolver logic) +# Module-level helpers # -------------------------------------------------------------------------- -def _pick_cover_url(cover) -> "str | None": - """ - Selects the best cover URL from a MangaBaka cover object. - Real API shape (from `GET /v1/series/{id}` and `/works`): - { - "raw": {"url": "...", "size": ..., "height": ..., "width": ...}, - "x150": {"x1": "...", "x2": "...", "x3": "..."}, - "x250": {"x1": "...", "x2": "...", "x3": "..."}, - "x350": {"x1": "...", "x2": "...", "x3": "..."} - } - - Order of preference: raw original > x350@x3 > x250@x3 > x150@x3 - (falling through to lower densities and sizes as needed). - """ - if not cover: - return None - if isinstance(cover, str): - return cover - if not isinstance(cover, dict): - return None - - # 1) Preferred: the unscaled "raw" image - raw = cover.get("raw") - if isinstance(raw, dict): - url = raw.get("url") - if isinstance(url, str) and url: - return url - elif isinstance(raw, str) and raw: - return raw - - # 2) Fallback: size-keyed variants, largest first, highest density first - for size_key in ("x350", "x250", "x150"): - variant = cover.get(size_key) - if isinstance(variant, dict): - for density in ("x3", "x2", "x1"): - url = variant.get(density) - if isinstance(url, str) and url: - return url - elif isinstance(variant, str) and variant: - return variant - - # 3) Last-ditch fallback: any http URL anywhere in the structure - for val in cover.values(): - if isinstance(val, str) and val.startswith("http"): - return val - if isinstance(val, dict): - for sub in val.values(): - if isinstance(sub, str) and sub.startswith("http"): - return sub - return None +# Alias: _pick_image_url (from MangaBakaWorksResolver) is the canonical +# generic image-block picker; _pick_cover_url is kept for backward compat. +_pick_cover_url = _pick_image_url def _pick_thumbnail_url(cover) -> "str | None": diff --git a/src/MangaBakaWorksResolver.py b/src/MangaBakaWorksResolver.py index da4d851..e95c7b1 100644 --- a/src/MangaBakaWorksResolver.py +++ b/src/MangaBakaWorksResolver.py @@ -2,7 +2,7 @@ mangabaka_works_resolver.py =========================== -Fetches volume-level (work) data from the MangaBaka API. +Fetches volume-level (work) data and volume cover images from the MangaBaka API. Each "work" is a physical tankobon volume and may carry: - volume number @@ -11,10 +11,16 @@ Each "work" is a physical tankobon volume and may carry: - release date - cover image (raw / default / small variants) -Only works that have a usable cover are kept in the cache. -Works without a cover are discarded at fetch time. -If no volume is assigned for a chapter, callers fall back to the -default series cover from the series object itself. +Cover resolution order (per volume) +------------------------------------ +1. GET /v1/series/{id}/images — covers that exist independently of a work + (some series have covers but no works). English edition preferred; + original language used when no English cover is available. +2. GET /v1/series/{id}/works — physical tankobon data including covers. + Fallback when /images returns nothing for the requested volume. + +If no volume cover is found at all, callers fall back to the series-level +default cover from the series object itself. Dependencies ------------ @@ -26,10 +32,75 @@ from __future__ import annotations import requests +# -------------------------------------------------------------------------- +# Generic image-block URL picker (shared by /images and /works responses) +# -------------------------------------------------------------------------- +def _pick_image_url(image) -> "str | None": + """ + Returns the best URL from a MangaBaka image block. + + Handles the common ``{raw, x150, x250, x350}`` structure used by both + the ``cover`` field on series/work objects and the ``image`` field on + ``/images`` endpoint items:: + + { + "raw": {"url": "...", "size": ..., "height": ..., "width": ...}, + "x150": {"x1": "...", "x2": "...", "x3": "..."}, + "x250": {...}, + "x350": {...} + } + + Preference: raw original > x350@x3 > x250@x3 > x150@x3 > … (falling + through to lower densities and sizes as needed). + """ + if not image: + return None + if isinstance(image, str): + return image + if not isinstance(image, dict): + return None + + # 1) Raw / unscaled image + raw = image.get("raw") + if isinstance(raw, dict): + url = raw.get("url") + if isinstance(url, str) and url: + return url + elif isinstance(raw, str) and raw: + return raw + + # 2) Size-keyed CDN variants, largest first, highest density first + for size_key in ("x350", "x250", "x150"): + variant = image.get(size_key) + if isinstance(variant, dict): + for density in ("x3", "x2", "x1"): + url = variant.get(density) + if isinstance(url, str) and url: + return url + elif isinstance(variant, str) and variant: + return variant + + # 3) Last-ditch: any HTTP URL anywhere in the structure + for val in image.values(): + if isinstance(val, str) and val.startswith("http"): + return val + if isinstance(val, dict): + for sub_val in val.values(): + if isinstance(sub_val, str) and sub_val.startswith("http"): + return sub_val + return None + + class MangaBakaWorksResolver: """ - Fetches and caches MangaBaka volume (work) data for a series. - Only works that have a cover image are retained in the cache. + Fetches and caches MangaBaka volume (work) data and cover images. + + Cover lookup order per volume + ------------------------------ + 1. ``/v1/series/{id}/images`` — edition covers (English > original). + 2. ``/v1/series/{id}/works`` — physical tankobon covers. + + Only works that carry a cover image are retained in the works cache. """ def __init__(self, api_base_url: str = "https://api.mangabaka.dev/v1", @@ -42,6 +113,8 @@ class MangaBakaWorksResolver: # Cache: series_id (str) -> list of work dicts (only those with covers) self._cache: dict[str, list[dict]] = {} + # Cache: series_id (str) -> {norm_vol (str): url (str)} + self._images_cache: dict[str, dict[str, str]] = {} # ------------------------------------------------------------------ # Public API @@ -101,12 +174,100 @@ class MangaBakaWorksResolver: return work return None - def get_cover_for_volume(self, series_id: str, volume) -> "str | None": - """Returns the cover URL for a specific volume, or None if not found.""" - work = self.get_work_for_volume(series_id, volume) - if not work: + def get_volume_covers(self, series_id: str) -> "dict[str, str]": + """ + Fetches all volume-type cover images for a series from + ``/v1/series/{id}/images`` and returns a + ``{normalised_volume_str: url}`` mapping. + + English-edition covers are preferred; the first available language + is used as fallback when no English cover exists for a volume. + Results are cached per series. + """ + if not series_id: + return {} + + if series_id in self._images_cache: + return self._images_cache[series_id] + + raw_items: list[dict] = [] + page = 1 + try: + while True: + resp = self._session.get( + f"{self.api_base_url}/series/{series_id}/images", + params={"limit": 50, "page": page}, + timeout=self.request_timeout, + ) + resp.raise_for_status() + page_data = resp.json().get("data") or [] + if not page_data: + break + raw_items.extend(page_data) + if len(page_data) < 50: + break + page += 1 + except requests.RequestException: + pass + + # Group by normalised volume index; collect all languages per volume. + by_volume: dict[str, dict[str, str]] = {} # norm_vol -> {lang: url} + for item in raw_items: + if item.get("type") != "volume": + continue + idx = item.get("index_numeric") + if idx is None: + continue + norm = _norm_vol(idx) + lang = (item.get("language") or "").lower() or "unknown" + url = _pick_image_url(item.get("image")) + if not url: + continue + if norm not in by_volume: + by_volume[norm] = {} + # First entry per language wins (API order reflects quality/rank). + if lang not in by_volume[norm]: + by_volume[norm][lang] = url + + # Pick best language per volume: English first, then first available. + result: dict[str, str] = {} + for norm, lang_map in by_volume.items(): + url = lang_map.get("en") or next(iter(lang_map.values()), None) + if url: + result[norm] = url + + self._images_cache[series_id] = result + return result + + def get_cover_for_volume_from_images(self, series_id: str, + volume) -> "str | None": + """ + Returns the cover URL for a specific volume from the /images endpoint, + or None if not available. + """ + covers = self.get_volume_covers(series_id) + if not covers: return None - return self._pick_cover_url(work.get("images")[0].get("image")) + return covers.get(_norm_vol(volume)) + + def get_cover_for_volume(self, series_id: str, volume) -> "str | None": + """ + Returns the best cover URL for a specific volume. + + Tries the ``/images`` endpoint first (covers that exist even when no + physical work has been catalogued), then falls back to the ``/works`` + endpoint. Returns None if neither source has a cover for the volume. + """ + # 1. /images endpoint (covers without works) + url = self.get_cover_for_volume_from_images(series_id, volume) + if url: + return url + + # 2. /works endpoint fallback + work = self.get_work_for_volume(series_id, volume) + if not work or not work.get("images"): + return None + return _pick_image_url(work["images"][0].get("image")) def get_page_counts(self, series_id: str) -> "dict[str, int]": """ @@ -125,59 +286,9 @@ class MangaBakaWorksResolver: return result def clear_cache(self) -> None: - """Clears the internal works cache.""" + """Clears both the works cache and the images cover cache.""" self._cache.clear() - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - @staticmethod - def _pick_cover_url(cover) -> "str | None": - """ - Selects the best cover URL from a MangaBaka cover object. - - Real API shape: - "raw": {"url": "...", "size": ..., "height": ..., "width": ...} - "x150": {"x1": "...", "x2": "...", "x3": "..."} - "x250": {...} - "x350": {...} - - Order: raw original > x350@x3 > x250@x3 > x150@x3 ... - """ - if not cover: - return None - if isinstance(cover, str): - return cover - if not isinstance(cover, dict): - return None - - raw = cover.get("raw") - if isinstance(raw, dict): - url = raw.get("url") - if isinstance(url, str) and url: - return url - elif isinstance(raw, str) and raw: - return raw - - for size_key in ("x350", "x250", "x150"): - variant = cover.get(size_key) - if isinstance(variant, dict): - for density in ("x3", "x2", "x1"): - url = variant.get(density) - if isinstance(url, str) and url: - return url - elif isinstance(variant, str) and variant: - return variant - - # Last-ditch: any HTTP URL anywhere in the structure - for val in cover.values(): - if isinstance(val, str) and val.startswith("http"): - return val - if isinstance(val, dict): - for sub_val in val.values(): - if isinstance(sub_val, str) and sub_val.startswith("http"): - return sub_val - return None + self._images_cache.clear() # -------------------------------------------------------------------------- diff --git a/src/SuwayomiMover.py b/src/SuwayomiMover.py index 33cba4d..ff514ef 100644 --- a/src/SuwayomiMover.py +++ b/src/SuwayomiMover.py @@ -591,21 +591,21 @@ if __name__ == "__main__": ) # ---- Option A: build matches.json only (no moves / no Kavita sync) ---- - data = mover.build_matches_only() - matches = data.get("matches", {}) - print(f"\n[matches] {len(matches)} entries total — file: {MATCHES_PATH}") - for title, entry in list(matches.items())[:10]: - print(f" {title!r:50s} id={entry.get('mangabakaId')} " - f"name={entry.get('mangabakaName')!r}") + # data = mover.build_matches_only() + # matches = data.get("matches", {}) + # print(f"\n[matches] {len(matches)} entries total — file: {MATCHES_PATH}") + # for title, entry in list(matches.items())[:10]: + # print(f" {title!r:50s} id={entry.get('mangabakaId')} " + # f"name={entry.get('mangabakaName')!r}") # ---- Option B: full pipeline for one series (uses the cache too) ---- - # result = mover.process_series("Yofukashi no Uta") - # ok = sum(1 for c in result["chapters"] if c["ok"]) - # failed = sum(1 for c in result["chapters"] if not c["ok"]) - # print(f"\nDone: {ok} ok, {failed} failed") - # for c in result["chapters"]: - # if not c["ok"]: - # print(f" Chapter {c['chapter']}: {c['error']}") + result = mover.process_series("Wistoria - Wand and Sword") + ok = sum(1 for c in result["chapters"] if c["ok"]) + failed = sum(1 for c in result["chapters"] if not c["ok"]) + print(f"\nDone: {ok} ok, {failed} failed") + for c in result["chapters"]: + if not c["ok"]: + print(f" Chapter {c['chapter']}: {c['error']}") # Or process everything at once: # results = mover.process_all()