diff --git a/src/KavitaPersonUpdater.py b/src/KavitaPersonUpdater.py index 77e1f58..cb5c8ef 100644 --- a/src/KavitaPersonUpdater.py +++ b/src/KavitaPersonUpdater.py @@ -47,7 +47,9 @@ Dependencies from __future__ import annotations import base64 +import datetime import difflib +import re import requests @@ -270,16 +272,18 @@ class KavitaPersonUpdater: if mal_id: if kind == "character": details = self._mal.get_character_details(mal_id) + if details: + description = _build_character_description(details) or None else: details = self._mal.get_person_details(mal_id) - if details: - description = (details.get("about") or "").strip() or None + if details: + description = _build_person_description(details) or None needs_desc = bool(description) # ------ Metadata update ------------------------------------------ changed = False - if needs_mal_id or needs_desc: + if needs_mal_id or True or needs_desc: payload: dict = { "id": person_id, "name": person_name, @@ -391,6 +395,94 @@ class KavitaPersonUpdater: return False +# -------------------------------------------------------------------------- +# Module helpers: description builders +# -------------------------------------------------------------------------- +def _plain_to_html(text: str) -> str: + """Converts plain text with paragraph breaks to compact HTML (no raw \\n).""" + if not text: + return "" + parts: list[str] = [] + for para in re.split(r"\n{2,}", text.strip()): + para = para.strip() + if para: + parts.append(f"

{para.replace(chr(10), '
')}

") + return "".join(parts) + + +def _format_birthday(birthday: str) -> str: + """Converts an ISO 8601 birthday string to "D Month YYYY".""" + if not birthday: + return "" + try: + dt = datetime.date.fromisoformat(birthday.split("T")[0]) + return f"{dt.day} {dt.strftime('%B %Y')}" + except (ValueError, AttributeError): + return "" + + +def _build_character_description(details: dict) -> str: + """ + Builds a Kavita-safe HTML description for a MAL character. + + Top line: "Favorites: N" as a link to the character's MAL page. + Remainder: the character's `about` text converted to HTML paragraphs. + """ + parts: list[str] = [] + url = details.get("url") or "" + favorites = details.get("favorites") + if url and favorites is not None: + parts.append(f'

Favorites: {favorites:,}

') + about = (details.get("about") or "").strip() + if about: + parts.append(_plain_to_html(about)) + return "
".join(parts) + + +def _build_person_description(details: dict) -> str: + """ + Builds a Kavita-safe HTML description for a MAL person (mangaka / staff). + + Renders a summary table (given name, family name, birthday, website, + member favorites) followed by the `about` biography as HTML paragraphs. + """ + _TD = 'style="padding-right:1.5em"' + rows: list[str] = [] + + given = (details.get("given_name") or "").strip() + family = (details.get("family_name") or "").strip() + birthday = details.get("birthday") or "" + favorites = details.get("favorites") + website = (details.get("website_url") or "").strip() + url = (details.get("url") or "").strip() + + if given: + rows.append(f"Given name{given}") + if family: + rows.append(f"Family name{family}") + bday_str = _format_birthday(birthday) + if bday_str: + rows.append(f"Birthday{bday_str}") + if website: + rows.append( + f'Website' + f'{website}' + ) + if favorites is not None: + fav_cell = (f'{favorites:,}' if url + else f"{favorites:,}") + rows.append( + f"Member Favorites{fav_cell}") + + parts: list[str] = [] + if rows: + parts.append(f'{"".join(rows)}
') + about = (details.get("about") or "").strip() + if about: + parts.append(_plain_to_html(about)) + return "
".join(parts) + + # -------------------------------------------------------------------------- # Module helper # -------------------------------------------------------------------------- diff --git a/src/MALResolver.py b/src/MALResolver.py index 08ecf09..df34865 100644 --- a/src/MALResolver.py +++ b/src/MALResolver.py @@ -230,11 +230,14 @@ class MALResolver: # ------------------------------------------------------------------ def get_staff_detailed(self, mal_id: "int | None") -> list[dict]: """ - Returns detailed staff entries for a manga: + Returns detailed staff (author) entries for a manga: [{mal_id, name, image_url, positions, about=None}, ...] - `about` is not populated here; call get_person_details(person_mal_id) - to fetch it lazily when needed. + Jikan has no `/manga/{id}/staff` endpoint — that route only exists for + anime. For manga the authors are listed on `/manga/{id}` under + `data.authors`, but each entry only has {mal_id, name, url}; the image + URL is fetched lazily via get_person_details (cached, so the later + description fetch is free). """ if mal_id is None: return [] @@ -242,28 +245,29 @@ class MALResolver: return self._staff_detailed_cache[mal_id] try: - data = self._get(f"{self.JIKAN_BASE}/manga/{mal_id}/staff") - entries = data.get("data") or [] + data = self._get(f"{self.JIKAN_BASE}/manga/{mal_id}") + entry = data.get("data") or {} except requests.RequestException: return [] results = [] - for entry in entries: - person = entry.get("person") or {} - raw_name = person.get("name") or "" - if not raw_name: + for author in (entry.get("authors") or []): + raw_name = author.get("name") or "" + person_mal_id = author.get("mal_id") + if not raw_name or person_mal_id is None: continue - jpg = (person.get("images") or {}).get("jpg") or {} + details = self.get_person_details(person_mal_id) or {} results.append({ - "mal_id": person.get("mal_id"), + "mal_id": person_mal_id, "name": _clean_mal_name(raw_name), "raw_name": raw_name, - "image_url": jpg.get("image_url") or jpg.get("small_image_url"), - "positions": entry.get("positions") or [], + "image_url": details.get("image_url"), + "positions": [], "about": None, }) - self._staff_detailed_cache[mal_id] = results + if results: + self._staff_detailed_cache[mal_id] = results return results # ------------------------------------------------------------------ @@ -291,6 +295,9 @@ class MALResolver: "name": entry.get("name") or "", "image_url": jpg.get("image_url") or jpg.get("small_image_url"), "about": entry.get("about"), + "favorites": entry.get("favorites"), + "url": (entry.get("url") + or f"https://myanimelist.net/character/{char_mal_id}"), } self._char_info_cache[char_mal_id] = result return result @@ -315,9 +322,15 @@ class MALResolver: result = { "mal_id": entry.get("mal_id"), "name": entry.get("name") or "", + "given_name": entry.get("given_name"), + "family_name": entry.get("family_name"), + "birthday": entry.get("birthday"), "image_url": jpg.get("image_url") or jpg.get("small_image_url"), "about": entry.get("about"), + "favorites": entry.get("favorites"), "website_url": entry.get("website_url"), + "url": (entry.get("url") + or f"https://myanimelist.net/people/{person_mal_id}"), } self._person_info_cache[person_mal_id] = result return result