api bugfix
This commit is contained in:
+139
-54
@@ -68,14 +68,18 @@ _AGE_RATING_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_TRACKER_URL_TEMPLATES = {
|
_TRACKER_URL_TEMPLATES = {
|
||||||
"anilist": "https://anilist.co/manga/{id}",
|
# Keys are normalised via _normalise_key (alphanumeric only, lowercase),
|
||||||
"myanimelist": "https://myanimelist.net/manga/{id}",
|
# so e.g. the source key "anime_news_network" matches "animenewsnetwork".
|
||||||
"mal": "https://myanimelist.net/manga/{id}",
|
"anilist": "https://anilist.co/manga/{id}",
|
||||||
"mangaupdates": "https://www.mangaupdates.com/series.html?id={id}",
|
"myanimelist": "https://myanimelist.net/manga/{id}",
|
||||||
"mangadex": "https://mangadex.org/title/{id}",
|
"mal": "https://myanimelist.net/manga/{id}",
|
||||||
"kitsu": "https://kitsu.app/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}",
|
"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 …)
|
# 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())
|
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
|
# Main class
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -381,8 +390,11 @@ class ComicInfoBuilder:
|
|||||||
add("Imprint", orig_pub)
|
add("Imprint", orig_pub)
|
||||||
|
|
||||||
# ----- Genres / Tags ------------------------------------------------
|
# ----- Genres / Tags ------------------------------------------------
|
||||||
add("Genre", ", ".join(md.get("genres") or []))
|
# Genres come back as lowercase snake_case ("slice_of_life"); convert
|
||||||
add("Tags", ", ".join(md.get("tags") or []))
|
# 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 from MAL ------------------------------------------
|
||||||
characters = self._mal_resolver.get_characters(mal_id)
|
characters = self._mal_resolver.get_characters(mal_id)
|
||||||
@@ -396,9 +408,11 @@ class ComicInfoBuilder:
|
|||||||
add("Manga", self._manga_flag(md))
|
add("Manga", self._manga_flag(md))
|
||||||
add("AgeRating", _AGE_RATING_MAP.get(md.get("content_rating"), "Unknown"))
|
add("AgeRating", _AGE_RATING_MAP.get(md.get("content_rating"), "Unknown"))
|
||||||
|
|
||||||
if md.get("rating"):
|
if md.get("rating") is not None:
|
||||||
try:
|
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):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -563,38 +577,81 @@ class ComicInfoBuilder:
|
|||||||
|
|
||||||
def _collect_alt_titles(self, md: dict) -> "dict[str, str]":
|
def _collect_alt_titles(self, md: dict) -> "dict[str, str]":
|
||||||
"""
|
"""
|
||||||
Returns {lang_code: title} for EN, DE and JP (kanji + romaji).
|
Returns {lang_code: title} for EN, DE, JP kanji and JP romaji.
|
||||||
Handles both list-of-dicts and plain-dict formats from the API.
|
|
||||||
|
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"):
|
def pick(language_codes: tuple, prefer_trait: "str | None" = None
|
||||||
result["romaji"] = md["romanized_title"]
|
) -> "str | None":
|
||||||
if md.get("native_title"):
|
"""Picks the best title entry for any of the given language codes."""
|
||||||
result["jp"] = md["native_title"]
|
if not isinstance(titles, list):
|
||||||
|
return None
|
||||||
alt = md.get("alt_titles") or md.get("titles") or []
|
best_score = -1
|
||||||
if isinstance(alt, list):
|
best_title: "str | None" = None
|
||||||
for entry in alt:
|
for entry in titles:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
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")
|
title = entry.get("title")
|
||||||
if not lang or not title:
|
if not title:
|
||||||
# Single-key format: {"en": "Call of the Night"}
|
continue
|
||||||
for k, v in entry.items():
|
traits = entry.get("traits") or []
|
||||||
if isinstance(v, str) and len(k) <= 10:
|
score = 0
|
||||||
lang, title = k, v
|
if prefer_trait and prefer_trait in traits:
|
||||||
break
|
score += 4
|
||||||
if lang and title and lang.lower() in ("en", "de"):
|
if "official" in traits:
|
||||||
result[lang.lower()] = title
|
score += 2
|
||||||
elif isinstance(alt, dict):
|
if entry.get("is_primary"):
|
||||||
for lang, title in alt.items():
|
score += 1
|
||||||
if isinstance(title, str) and lang.lower() in ("en", "de"):
|
if score > best_score:
|
||||||
result[lang.lower()] = title
|
best_score, best_title = score, title
|
||||||
|
return best_title
|
||||||
|
|
||||||
if "en" not in result and md.get("title"):
|
result: dict[str, str] = {}
|
||||||
result["en"] = md["title"]
|
|
||||||
|
# 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
|
return result
|
||||||
|
|
||||||
@@ -758,27 +815,55 @@ class ComicInfoBuilder:
|
|||||||
# Module-level helpers (shared with MangaBakaWorksResolver logic)
|
# Module-level helpers (shared with MangaBakaWorksResolver logic)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
def _pick_cover_url(cover) -> "str | None":
|
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:
|
if not cover:
|
||||||
return None
|
return None
|
||||||
if isinstance(cover, str):
|
if isinstance(cover, str):
|
||||||
return cover
|
return cover
|
||||||
if isinstance(cover, dict):
|
if not isinstance(cover, dict):
|
||||||
for key in ("raw", "default", "large", "medium", "small"):
|
return None
|
||||||
val = cover.get(key)
|
|
||||||
if isinstance(val, str) and val:
|
# 1) Preferred: the unscaled "raw" image
|
||||||
return val
|
raw = cover.get("raw")
|
||||||
if isinstance(val, dict):
|
if isinstance(raw, dict):
|
||||||
for sub in ("x2", "x1"):
|
url = raw.get("url")
|
||||||
if isinstance(val.get(sub), str) and val[sub]:
|
if isinstance(url, str) and url:
|
||||||
return val[sub]
|
return url
|
||||||
for val in cover.values():
|
elif isinstance(raw, str) and raw:
|
||||||
if isinstance(val, str) and val.startswith("http"):
|
return raw
|
||||||
return val
|
|
||||||
if isinstance(val, dict):
|
# 2) Fallback: size-keyed variants, largest first, highest density first
|
||||||
for sub in val.values():
|
for size_key in ("x350", "x250", "x150"):
|
||||||
if isinstance(sub, str) and sub.startswith("http"):
|
variant = cover.get(size_key)
|
||||||
return sub
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,24 +133,50 @@ class MangaBakaWorksResolver:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _pick_cover_url(cover) -> "str | None":
|
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:
|
if not cover:
|
||||||
return None
|
return None
|
||||||
if isinstance(cover, str):
|
if isinstance(cover, str):
|
||||||
return cover
|
return cover
|
||||||
if isinstance(cover, dict):
|
if not isinstance(cover, dict):
|
||||||
url = cover.get("raw").get("url") or None
|
return None
|
||||||
if url:
|
|
||||||
return url
|
|
||||||
|
|
||||||
# Generic fallback: any HTTP URL in the dict
|
raw = cover.get("raw")
|
||||||
for val in cover.values():
|
if isinstance(raw, dict):
|
||||||
if isinstance(val, str) and val.startswith("http"):
|
url = raw.get("url")
|
||||||
return val
|
if isinstance(url, str) and url:
|
||||||
if isinstance(val, dict):
|
return url
|
||||||
for sub_val in val.values():
|
elif isinstance(raw, str) and raw:
|
||||||
if isinstance(sub_val, str) and sub_val.startswith("http"):
|
return raw
|
||||||
return sub_val
|
|
||||||
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user