diff --git a/src/ComicInfoBuilder.py b/src/ComicInfoBuilder.py index 1d839dc..91b8df1 100644 --- a/src/ComicInfoBuilder.py +++ b/src/ComicInfoBuilder.py @@ -68,14 +68,18 @@ _AGE_RATING_MAP = { } _TRACKER_URL_TEMPLATES = { - "anilist": "https://anilist.co/manga/{id}", - "myanimelist": "https://myanimelist.net/manga/{id}", - "mal": "https://myanimelist.net/manga/{id}", - "mangaupdates": "https://www.mangaupdates.com/series.html?id={id}", - "mangadex": "https://mangadex.org/title/{id}", - "kitsu": "https://kitsu.app/manga/{id}", + # Keys are normalised via _normalise_key (alphanumeric only, lowercase), + # so e.g. the source key "anime_news_network" matches "animenewsnetwork". + "anilist": "https://anilist.co/manga/{id}", + "myanimelist": "https://myanimelist.net/manga/{id}", + "mal": "https://myanimelist.net/manga/{id}", + "mangaupdates": "https://www.mangaupdates.com/series.html?id={id}", + "mangadex": "https://mangadex.org/title/{id}", + "kitsu": "https://kitsu.app/manga/{id}", "animenewsnetwork": "https://www.animenewsnetwork.com/encyclopedia/manga.php?id={id}", - "ann": "https://www.animenewsnetwork.com/encyclopedia/manga.php?id={id}", + "ann": "https://www.animenewsnetwork.com/encyclopedia/manga.php?id={id}", + "animeplanet": "https://www.anime-planet.com/manga/{id}", + "shikimori": "https://shikimori.one/mangas/{id}", } # MangaDex relationship types that indicate child works (spin-offs, sequels …) @@ -96,6 +100,11 @@ def _normalise_key(key) -> str: return re.sub(r"[^a-z0-9]", "", str(key).lower()) +def _format_term(value: str) -> str: + """Converts a MangaBaka genre slug ('slice_of_life') to display form.""" + return str(value).replace("_", " ").strip().title() if value else "" + + # -------------------------------------------------------------------------- # Main class # -------------------------------------------------------------------------- @@ -381,8 +390,11 @@ class ComicInfoBuilder: add("Imprint", orig_pub) # ----- Genres / Tags ------------------------------------------------ - add("Genre", ", ".join(md.get("genres") or [])) - add("Tags", ", ".join(md.get("tags") or [])) + # Genres come back as lowercase snake_case ("slice_of_life"); convert + # to display form ("Slice Of Life") so Kavita / readers show them + # consistently with the (already-titled-cased) Tags field. + add("Genre", ", ".join(_format_term(g) for g in (md.get("genres") or []))) + add("Tags", ", ".join(md.get("tags") or [])) # ----- Characters from MAL ------------------------------------------ characters = self._mal_resolver.get_characters(mal_id) @@ -396,9 +408,11 @@ class ComicInfoBuilder: add("Manga", self._manga_flag(md)) add("AgeRating", _AGE_RATING_MAP.get(md.get("content_rating"), "Unknown")) - if md.get("rating"): + if md.get("rating") is not None: try: - add("CommunityRating", round(float(md["rating"]) / 2, 1)) + # MangaBaka rating is on a 0..100 scale -> ComicInfo + # CommunityRating uses 0..5. + add("CommunityRating", round(float(md["rating"]) / 20, 1)) except (TypeError, ValueError): pass @@ -563,38 +577,81 @@ class ComicInfoBuilder: def _collect_alt_titles(self, md: dict) -> "dict[str, str]": """ - Returns {lang_code: title} for EN, DE and JP (kanji + romaji). - Handles both list-of-dicts and plain-dict formats from the API. + Returns {lang_code: title} for EN, DE, JP kanji and JP romaji. + + MangaBaka stores alt-titles in the `titles` list, where each entry is + a dict {language, title, traits, is_primary, note}. + Important caveats observed against the real API: + * `romanized_title` is the romanization of whatever the series' + native script is — for a Japanese manga with a Korean licence it + can hold the Korean romanization, NOT the Japanese romaji. + Always prefer `titles[language="ja-Latn"]` for romaji instead. + * `native_title` holds the kanji form for Japanese manga, but + `titles[language="ja", traits contains "native"]` is more + reliable when present. + * Each language can have several entries; primary + official + traits win over generic ones. """ - result: dict[str, str] = {} + titles = md.get("titles") or md.get("alt_titles") or [] - if md.get("romanized_title"): - result["romaji"] = md["romanized_title"] - if md.get("native_title"): - result["jp"] = md["native_title"] - - alt = md.get("alt_titles") or md.get("titles") or [] - if isinstance(alt, list): - for entry in alt: + def pick(language_codes: tuple, prefer_trait: "str | None" = None + ) -> "str | None": + """Picks the best title entry for any of the given language codes.""" + if not isinstance(titles, list): + return None + best_score = -1 + best_title: "str | None" = None + for entry in titles: if not isinstance(entry, dict): continue - lang = entry.get("lang") + lang = (entry.get("language") or entry.get("lang") or "").lower() + if lang not in language_codes: + continue title = entry.get("title") - if not lang or not title: - # Single-key format: {"en": "Call of the Night"} - for k, v in entry.items(): - if isinstance(v, str) and len(k) <= 10: - lang, title = k, v - break - if lang and title and lang.lower() in ("en", "de"): - result[lang.lower()] = title - elif isinstance(alt, dict): - for lang, title in alt.items(): - if isinstance(title, str) and lang.lower() in ("en", "de"): - result[lang.lower()] = title + if not title: + continue + traits = entry.get("traits") or [] + score = 0 + if prefer_trait and prefer_trait in traits: + score += 4 + if "official" in traits: + score += 2 + if entry.get("is_primary"): + score += 1 + if score > best_score: + best_score, best_title = score, title + return best_title - if "en" not in result and md.get("title"): - result["en"] = md["title"] + result: dict[str, str] = {} + + # JP kanji (prefer entry with "native" trait, fall back to native_title) + kanji = pick(("ja",), prefer_trait="native") or md.get("native_title") + if kanji: + result["jp"] = kanji + + # JP romaji — explicitly from "ja-Latn" entries. Do NOT fall back to + # `romanized_title` blindly; that field can hold a non-Japanese + # romanization (e.g. Korean) for the same series. + romaji = pick(("ja-latn", "ja-romaji")) + if not romaji: + # Heuristic fallback only when romanized_title looks Latin + rt = md.get("romanized_title") or "" + if rt and all(ord(c) < 128 for c in rt): + romaji = rt + if romaji: + result["romaji"] = romaji + + # English (prefer official + primary) + en = pick(("en",)) + if not en: + en = md.get("title") if md.get("title") else None + if en: + result["en"] = en + + # German + de = pick(("de",)) + if de: + result["de"] = de return result @@ -758,27 +815,55 @@ class ComicInfoBuilder: # Module-level helpers (shared with MangaBakaWorksResolver logic) # -------------------------------------------------------------------------- def _pick_cover_url(cover) -> "str | None": - """Selects the best cover URL (raw preferred) from a MangaBaka cover object.""" + """ + 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 isinstance(cover, dict): - for key in ("raw", "default", "large", "medium", "small"): - val = cover.get(key) - if isinstance(val, str) and val: - return val - if isinstance(val, dict): - for sub in ("x2", "x1"): - if isinstance(val.get(sub), str) and val[sub]: - return val[sub] - 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 + 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 diff --git a/src/MangaBakaWorksResolver.py b/src/MangaBakaWorksResolver.py index 92d5db0..da4d851 100644 --- a/src/MangaBakaWorksResolver.py +++ b/src/MangaBakaWorksResolver.py @@ -133,24 +133,50 @@ class MangaBakaWorksResolver: # ------------------------------------------------------------------ @staticmethod def _pick_cover_url(cover) -> "str | None": - """Selects the best (raw-preferred) cover URL from a cover object.""" + """ + 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 isinstance(cover, dict): - url = cover.get("raw").get("url") or None - if url: - return url + if not isinstance(cover, dict): + return None - # Generic fallback: any HTTP URL in the dict - 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 + 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