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'')
+ 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