diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0cc3361
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+KAVITA_URL=http://192.168.1.100:5000
+KAVITA_API_KEY=your-api-key-here
+LIBRARY_IDS=3,5
+LANGUAGE=en
+MATCH_PATH=matches.json
+WEB_PORT=8080
diff --git a/.gitignore b/.gitignore
index 0360e1f..64d56c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -267,3 +267,10 @@ pyvenv.cfg
.venv
pip-selfcheck.json
+manga-mover-and-metadata-collector/
+
+# Project-local state
+matches.json
+config/
+output/
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ab73b5a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY src/ /app/src/
+COPY main.py /app/main.py
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1
+
+VOLUME ["/config"]
+
+EXPOSE 8080
+
+CMD ["python", "/app/main.py"]
diff --git a/README.md b/README.md
index aa1c63c..672932d 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,56 @@
# kavita-lightnovel-metadata-fetcher
+Pulls metadata (summary, tags, genres, characters, staff, score,
+cover, links, related series) for light novels from **MangaBaka**,
+enriched with **MyAnimeList** and **AniList** data, and writes it
+back to a **Kavita** server through its REST API.
+
+No file mover, no ComicInfo.xml — the source of truth is Kavita
+itself. Series are discovered via the Kavita library API.
+
+## Features
+
+- Match every series in one or more Kavita libraries against
+ MangaBaka and persist the match in `matches.json` (editable via
+ the web UI).
+- Update metadata for a single series or all matched series at
+ once. Updates are diff-based:
+ - Locked fields in Kavita are never overwritten.
+ - List fields (tags, genres, characters, writers, …) are merged:
+ new items are added, removed items are dropped.
+ - Cover images are only re-uploaded when MangaBaka's cover URL
+ actually changed.
+- Characters and authors are synced to Kavita Person records
+ (image, description, MAL/AniList id) via Kavita's `/api/Person`
+ endpoints.
+- MangaBaka relationships (sequel / prequel / spin-off / …) are
+ mirrored as Kavita series relationships, and every related
+ series that exists in Kavita is added to a shared collection.
+
+## Environment
+
+| Variable | Default | Description |
+| ------------------ | ------------------------- | -------------------------------------------------------- |
+| `KAVITA_URL` | — | Base URL of the Kavita server, e.g. `http://kavita:5000` |
+| `KAVITA_API_KEY` | — | API key from Kavita user settings |
+| `LIBRARY_IDS` | _(empty)_ | Default libraries (CSV of ids). Empty = pick in WebUI. |
+| `LANGUAGE` | `en` | Series language ISO code (used for `language` field) |
+| `REQUEST_TIMEOUT` | `30` | HTTP timeout in seconds |
+| `MATCH_PATH` | `/config/matches.json` | Where to persist the match cache |
+| `WEB_HOST` | `0.0.0.0` | Bind host for the Flask UI |
+| `WEB_PORT` | `8080` | Bind port for the Flask UI |
+
+## Running locally
+
+```bash
+pip install -r requirements.txt
+KAVITA_URL=http://localhost:5000 KAVITA_API_KEY=... python main.py
+```
+
+Then open .
+
+## Docker
+
+```bash
+docker compose -f docker-compose.prod.yml up -d
+```
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..dd77b58
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,16 @@
+services:
+ kavita-lightnovel-metadata-fetcher:
+ image: gitea.johannesbot.de/johannesbot/kavita-lightnovel-metadata-fetcher:latest
+ container_name: kavita-lightnovel-metadata-fetcher
+ restart: unless-stopped
+ environment:
+ KAVITA_URL: "${KAVITA_URL}"
+ KAVITA_API_KEY: "${KAVITA_API_KEY}"
+ LIBRARY_IDS: "${LIBRARY_IDS}"
+ LANGUAGE: "${LANGUAGE:-en}"
+ MATCH_PATH: "${MATCH_PATH:-/config/matches.json}"
+ WEB_PORT: "${WEB_PORT:-8080}"
+ ports:
+ - "${WEB_PORT:-8080}:${WEB_PORT:-8080}"
+ volumes:
+ - "${HOST_CONFIG_PATH}:/config"
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..5fdf5d0
--- /dev/null
+++ b/main.py
@@ -0,0 +1,122 @@
+"""
+main.py
+=======
+
+Container entry point for the Kavita light-novel metadata fetcher.
+
+Reads configuration from environment variables, starts the orchestrator
+and exposes the Flask WebApp on WEB_HOST:WEB_PORT. Everything happens
+through HTTP — there is no folder watcher and no file mover (Kavita is
+the source of truth for the library content; this service only writes
+metadata back to it).
+
+Environment variables
+---------------------
+ Required:
+ KAVITA_URL base URL of the Kavita server, e.g. http://kavita:5000
+ KAVITA_API_KEY Kavita API key (Settings -> User -> API key)
+
+ Optional:
+ LIBRARY_IDS comma-separated default library ids (e.g. "3,5").
+ Empty = user picks in the WebUI each time.
+ LANGUAGE default "en"
+ REQUEST_TIMEOUT default 30
+ MATCH_PATH default /config/matches.json
+ WEB_PORT default 8080
+ WEB_HOST default 0.0.0.0
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+try:
+ from dotenv import load_dotenv
+ load_dotenv()
+except ImportError:
+ pass
+
+# Make src/ importable when running as `python main.py`.
+sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
+
+from src.MatchesCache import MatchesCache # noqa: E402
+from src.LightNovelOrchestrator import LightNovelOrchestrator # noqa: E402
+from src.MatchesWebApp import MatchesWebApp # noqa: E402
+
+
+def _env_str(name: str, default: "str | None" = None,
+ required: bool = False) -> "str | None":
+ value = os.environ.get(name, default)
+ if required and not value:
+ print(f"[main] missing required env var: {name}", flush=True)
+ sys.exit(2)
+ return value
+
+
+def _env_int(name: str, default: int) -> int:
+ raw = os.environ.get(name)
+ if raw is None or raw == "":
+ return default
+ try:
+ return int(raw)
+ except ValueError:
+ print(f"[main] {name}={raw!r} is not a valid integer; "
+ f"falling back to {default}", flush=True)
+ return default
+
+
+def _env_int_list(name: str) -> list[int]:
+ raw = os.environ.get(name) or ""
+ out: list[int] = []
+ for part in raw.split(","):
+ part = part.strip()
+ if not part:
+ continue
+ try:
+ out.append(int(part))
+ except ValueError:
+ print(f"[main] {name}: ignoring non-integer value {part!r}",
+ flush=True)
+ return out
+
+
+def main() -> int:
+ kavita_url = _env_str("KAVITA_URL", required=True)
+ kavita_api_key = _env_str("KAVITA_API_KEY", required=True)
+ language = _env_str("LANGUAGE", "en") or "en"
+ request_timeout = _env_int("REQUEST_TIMEOUT", 30)
+ match_path = _env_str("MATCH_PATH", "/config/matches.json")
+ web_host = _env_str("WEB_HOST", "0.0.0.0") or "0.0.0.0"
+ web_port = _env_int("WEB_PORT", 8080)
+ library_ids = _env_int_list("LIBRARY_IDS")
+
+ print(f"[main] kavita url = {kavita_url}", flush=True)
+ print(f"[main] language = {language}", flush=True)
+ print(f"[main] match path = {match_path}", flush=True)
+ print(f"[main] libraries = {library_ids or '(picked in WebUI)'}",
+ flush=True)
+ print(f"[main] web = {web_host}:{web_port}", flush=True)
+
+ cache = MatchesCache(match_path)
+ orchestrator = LightNovelOrchestrator(
+ kavita_url=kavita_url,
+ kavita_api_key=kavita_api_key,
+ matches_cache=cache,
+ language=language,
+ request_timeout=request_timeout,
+ )
+
+ app = MatchesWebApp(
+ cache, orchestrator=orchestrator,
+ default_library_ids=library_ids,
+ host=web_host, port=web_port,
+ )
+ app.start()
+ app.wait()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..a3b5e10
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+requests>=2.31
+Flask>=3.0
+python-dotenv>=1.0
diff --git a/src/AniListResolver.py b/src/AniListResolver.py
new file mode 100644
index 0000000..10591c8
--- /dev/null
+++ b/src/AniListResolver.py
@@ -0,0 +1,507 @@
+"""
+anilist_resolver.py
+===================
+
+Fetches and caches AniList manga metadata (statistics, characters, staff)
+using the public AniList GraphQL API.
+
+AniList API: https://graphql.anilist.co (no authentication required)
+Rate limit: 90 req/min -> a 700 ms guard between calls is applied.
+On HTTP 429 (rate-limit exceeded) the response Retry-After header is
+honoured; the request is retried once automatically.
+
+Singleton
+---------
+Only one instance of this class exists per process. Subsequent calls to
+AniListResolver() return the same object with its warm caches intact.
+
+Provided features
+-----------------
+- Title-based AniList ID lookup with best-match scoring
+- Manga statistics: score (0–10), rank, popularity, members, favorites
+- Character list for a manga (names only — for XML tag)
+- Detailed character list: name, AniList character ID, image URL, role
+- Detailed staff list: name, AniList person ID, image URL, positions
+- Lazy full-detail fetches per character / person (for descriptions)
+
+Dependencies
+------------
+ requests -> pip install requests
+"""
+
+from __future__ import annotations
+
+import datetime
+import difflib
+import time
+
+import requests
+
+from MediaResolver import MediaResolver
+
+
+# --------------------------------------------------------------------------
+# GraphQL query strings
+# --------------------------------------------------------------------------
+_SEARCH_MANGA = """
+query ($search: String) {
+ Page(page: 1, perPage: 5) {
+ media(search: $search, type: MANGA, format_in: [NOVEL]) {
+ id title { romaji english native } siteUrl
+ }
+ }
+}
+"""
+
+_MANGA_STATS = """
+query ($id: Int) {
+ Media(id: $id, type: MANGA) {
+ id title { romaji english native }
+ meanScore popularity favourites
+ rankings { rank type allTime }
+ siteUrl
+ }
+}
+"""
+
+_MANGA_CHARACTERS = """
+query ($id: Int) {
+ Media(id: $id, type: MANGA) {
+ characters(sort: [ROLE, RELEVANCE], perPage: 25) {
+ nodes { id name { full } image { large } siteUrl }
+ edges { role }
+ }
+ }
+}
+"""
+
+_MANGA_STAFF = """
+query ($id: Int) {
+ Media(id: $id, type: MANGA) {
+ staff(perPage: 25) {
+ nodes { id name { full } image { large } siteUrl }
+ edges { role }
+ }
+ }
+}
+"""
+
+_CHARACTER_DETAILS = """
+query ($id: Int) {
+ Character(id: $id) {
+ id name { full } image { large }
+ description(asHtml: false)
+ favourites siteUrl
+ }
+}
+"""
+
+_PERSON_DETAILS = """
+query ($id: Int) {
+ Staff(id: $id) {
+ id name { full native } image { large }
+ description(asHtml: false)
+ favourites siteUrl
+ dateOfBirth { year month day }
+ primaryOccupations
+ homeTown
+ }
+}
+"""
+
+_ANILIST_GQL = "https://graphql.anilist.co"
+
+
+class AniListResolver(MediaResolver):
+ """
+ Singleton: fetches and caches AniList manga data via GraphQL API.
+
+ The first call to AniListResolver() creates and initialises the instance;
+ all subsequent calls return the same object.
+ """
+
+ _instance: "AniListResolver | None" = None
+
+ # ------------------------------------------------------------------
+ # Singleton machinery
+ # ------------------------------------------------------------------
+ def __new__(cls, **kwargs):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self, *, request_timeout: int = 30):
+ if self._initialized:
+ return
+
+ self.request_timeout = request_timeout
+
+ self._session = requests.Session()
+ self._session.headers.update({
+ "User-Agent": "AniListResolver/1.0",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ })
+
+ # title_lower -> al_id
+ self._id_cache: dict[str, "int | None"] = {}
+ # al_id -> stats dict
+ self._stats_cache: dict[int, dict] = {}
+ # manga_al_id -> [name_str, ...]
+ self._char_names_cache: dict[int, list[str]] = {}
+ # manga_al_id -> [{al_id, name, image_url, role}]
+ self._char_detailed_cache: dict[int, list[dict]] = {}
+ # manga_al_id -> [{al_id, name, image_url, positions}]
+ self._staff_detailed_cache: dict[int, list[dict]] = {}
+ # char_al_id -> {al_id, name, image_url, about, favorites, url}
+ self._char_info_cache: dict[int, dict] = {}
+ # person_al_id -> {al_id, name, image_url, about, favorites, url, ...}
+ self._person_info_cache: dict[int, dict] = {}
+
+ self._last_request_at: float = 0.0
+ self._initialized = True
+
+ # ------------------------------------------------------------------
+ # Public: ID lookup
+ # ------------------------------------------------------------------
+ def find_id(self, title: str) -> "int | None":
+ """
+ Searches AniList for a manga by title and returns the best-matching
+ AniList ID. Returns None on failure or when no result is found.
+ """
+ if not title or not title.strip():
+ return None
+
+ key = title.strip().lower()
+ if key in self._id_cache:
+ return self._id_cache[key]
+
+ try:
+ data = self._gql(_SEARCH_MANGA, {"search": title})
+ results = ((data.get("data") or {})
+ .get("Page", {})
+ .get("media") or [])
+ except requests.RequestException:
+ return None
+
+ if not results:
+ self._id_cache[key] = None
+ return None
+
+ results.sort(key=lambda e: _score_title(title, e), reverse=True)
+ al_id = results[0].get("id")
+ self._id_cache[key] = al_id
+ return al_id
+
+ # ------------------------------------------------------------------
+ # Public: statistics
+ # ------------------------------------------------------------------
+ def get_stats(self, tracker_id: "int | None") -> "dict | None":
+ """
+ Returns a statistics dict for the given AniList manga ID:
+
+ {score, rank, scored_by, popularity, members, favorites,
+ url, title, as_of (DD-MM-YYYY)}
+
+ Returns None if tracker_id is None or on network failure.
+ """
+ if tracker_id is None:
+ return None
+ if tracker_id in self._stats_cache:
+ return self._stats_cache[tracker_id]
+
+ try:
+ data = self._gql(_MANGA_STATS, {"id": tracker_id})
+ entry = (data.get("data") or {}).get("Media") or {}
+ except requests.RequestException:
+ return None
+
+ title_obj = entry.get("title") or {}
+ title = (title_obj.get("romaji")
+ or title_obj.get("english")
+ or title_obj.get("native") or "")
+
+ # AniList meanScore is 0–100; normalise to 0.0–10.0 for consistency
+ # with the MALResolver stats dict shape.
+ raw_score = entry.get("meanScore")
+ score = round(raw_score / 10, 1) if raw_score is not None else None
+
+ # Ranked and popularity ranks are in the rankings array.
+ rated_rank = None
+ popular_rank = None
+ for r in (entry.get("rankings") or []):
+ if r.get("allTime"):
+ if r.get("type") == "RATED" and rated_rank is None:
+ rated_rank = r.get("rank")
+ if r.get("type") == "POPULAR" and popular_rank is None:
+ popular_rank = r.get("rank")
+
+ stats: dict = {
+ "score": score,
+ "rank": rated_rank,
+ "scored_by": None, # not exposed by AniList API
+ "popularity": popular_rank,
+ "members": entry.get("popularity"), # AniList's popularity = member count
+ "favorites": entry.get("favourites"),
+ "url": entry.get("siteUrl") or f"https://anilist.co/manga/{tracker_id}",
+ "title": title,
+ "as_of": datetime.date.today().strftime("%d-%m-%Y"),
+ }
+ self._stats_cache[tracker_id] = stats
+ return stats
+
+ # ------------------------------------------------------------------
+ # Public: character names (for ComicInfo tag)
+ # ------------------------------------------------------------------
+ def get_characters(self, tracker_id: "int | None") -> list[str]:
+ """Returns a flat list of character names for the manga."""
+ if tracker_id is None:
+ return []
+ if tracker_id in self._char_names_cache:
+ return self._char_names_cache[tracker_id]
+
+ detailed = self.get_characters_detailed(tracker_id)
+ names = [e["name"] for e in detailed if e.get("name")]
+ if names:
+ self._char_names_cache[tracker_id] = names
+ return names
+
+ # ------------------------------------------------------------------
+ # Public: detailed character data
+ # ------------------------------------------------------------------
+ def get_characters_detailed(self, tracker_id: "int | None") -> list[dict]:
+ """
+ Returns detailed character entries for a manga:
+ [{al_id, mal_id, name, image_url, role, about=None}, ...]
+ """
+ if tracker_id is None:
+ return []
+ if tracker_id in self._char_detailed_cache:
+ return self._char_detailed_cache[tracker_id]
+
+ try:
+ data = self._gql(_MANGA_CHARACTERS, {"id": tracker_id})
+ chars = ((data.get("data") or {})
+ .get("Media", {})
+ .get("characters") or {})
+ nodes = chars.get("nodes") or []
+ edges = chars.get("edges") or []
+ except requests.RequestException:
+ return []
+
+ results = []
+ for node, edge in zip(nodes, edges):
+ name = (node.get("name") or {}).get("full") or ""
+ if not name:
+ continue
+ results.append({
+ "al_id": node.get("id"),
+ "mal_id": None,
+ "name": name,
+ "raw_name": name,
+ "image_url": (node.get("image") or {}).get("large"),
+ "role": edge.get("role") or "SUPPORTING",
+ "about": None,
+ })
+
+ if results:
+ self._char_detailed_cache[tracker_id] = results
+ return results
+
+ # ------------------------------------------------------------------
+ # Public: detailed staff data
+ # ------------------------------------------------------------------
+ def get_staff_detailed(self, tracker_id: "int | None") -> list[dict]:
+ """
+ Returns detailed staff entries for a manga:
+ [{al_id, mal_id, name, image_url, positions, about=None}, ...]
+ """
+ if tracker_id is None:
+ return []
+ if tracker_id in self._staff_detailed_cache:
+ return self._staff_detailed_cache[tracker_id]
+
+ try:
+ data = self._gql(_MANGA_STAFF, {"id": tracker_id})
+ staff = ((data.get("data") or {})
+ .get("Media", {})
+ .get("staff") or {})
+ nodes = staff.get("nodes") or []
+ edges = staff.get("edges") or []
+ except requests.RequestException:
+ return []
+
+ results = []
+ for node, edge in zip(nodes, edges):
+ name = (node.get("name") or {}).get("full") or ""
+ if not name:
+ continue
+ results.append({
+ "al_id": node.get("id"),
+ "mal_id": None,
+ "name": name,
+ "raw_name": name,
+ "image_url": (node.get("image") or {}).get("large"),
+ "positions": [edge.get("role")] if edge.get("role") else [],
+ "about": None,
+ })
+
+ if results:
+ self._staff_detailed_cache[tracker_id] = results
+ return results
+
+ # ------------------------------------------------------------------
+ # Public: individual character / person details
+ # ------------------------------------------------------------------
+ def get_character_details(self, char_id: "int | None") -> "dict | None":
+ """Returns full details for a single AniList character."""
+ if char_id is None:
+ return None
+ if char_id in self._char_info_cache:
+ return self._char_info_cache[char_id]
+
+ try:
+ data = self._gql(_CHARACTER_DETAILS, {"id": char_id})
+ entry = (data.get("data") or {}).get("Character") or {}
+ except requests.RequestException:
+ return None
+
+ result = {
+ "al_id": entry.get("id"),
+ "mal_id": None,
+ "name": (entry.get("name") or {}).get("full") or "",
+ "image_url": (entry.get("image") or {}).get("large"),
+ "about": entry.get("description"),
+ "favorites": entry.get("favourites"),
+ "url": entry.get("siteUrl") or f"https://anilist.co/character/{char_id}",
+ }
+ self._char_info_cache[char_id] = result
+ return result
+
+ def get_person_details(self, person_id: "int | None") -> "dict | None":
+ """Returns full details for a single AniList staff person."""
+ if person_id is None:
+ return None
+ if person_id in self._person_info_cache:
+ return self._person_info_cache[person_id]
+
+ try:
+ data = self._gql(_PERSON_DETAILS, {"id": person_id})
+ entry = (data.get("data") or {}).get("Staff") or {}
+ except requests.RequestException:
+ return None
+
+ # dateOfBirth: {year, month, day} → ISO string for _format_birthday
+ dob = entry.get("dateOfBirth") or {}
+ birthday: "str | None" = None
+ if dob.get("year"):
+ m = dob.get("month") or 1
+ d = dob.get("day") or 1
+ birthday = f"{dob['year']}-{m:02d}-{d:02d}"
+
+ name_obj = entry.get("name") or {}
+ result = {
+ "al_id": entry.get("id"),
+ "mal_id": None,
+ "name": name_obj.get("full") or "",
+ "given_name": None, # AniList does not break names into given/family
+ "family_name": None,
+ "birthday": birthday,
+ "image_url": (entry.get("image") or {}).get("large"),
+ "about": entry.get("description"),
+ "favorites": entry.get("favourites"),
+ "website_url": None, # not exposed by AniList public API
+ "url": entry.get("siteUrl") or f"https://anilist.co/staff/{person_id}",
+ }
+ self._person_info_cache[person_id] = result
+ return result
+
+ # ------------------------------------------------------------------
+ # Public: cache management
+ # ------------------------------------------------------------------
+ def clear_cache(self) -> None:
+ """Clears all internal caches (the Singleton instance is retained)."""
+ self._id_cache.clear()
+ self._stats_cache.clear()
+ self._char_names_cache.clear()
+ self._char_detailed_cache.clear()
+ self._staff_detailed_cache.clear()
+ self._char_info_cache.clear()
+ self._person_info_cache.clear()
+
+ # ------------------------------------------------------------------
+ # Internal: rate-limited GraphQL POST
+ # ------------------------------------------------------------------
+ def _gql(self, query: str, variables: "dict | None" = None) -> dict:
+ """
+ Rate-limited GraphQL POST request (respects AniList's 90 req/min limit).
+
+ On HTTP 429 the Retry-After header is honoured and the request is
+ retried once.
+ """
+ elapsed = time.monotonic() - self._last_request_at
+ if elapsed < 0.7:
+ time.sleep(0.7 - elapsed)
+
+ payload: dict = {"query": query}
+ if variables:
+ payload["variables"] = variables
+
+ resp = self._session.post(
+ _ANILIST_GQL, json=payload, timeout=self.request_timeout)
+ self._last_request_at = time.monotonic()
+
+ if resp.status_code == 429:
+ retry_after = int(resp.headers.get("Retry-After", 60))
+ time.sleep(retry_after)
+ resp = self._session.post(
+ _ANILIST_GQL, json=payload, timeout=self.request_timeout)
+ self._last_request_at = time.monotonic()
+
+ resp.raise_for_status()
+ return resp.json()
+
+
+# --------------------------------------------------------------------------
+# Module helpers
+# --------------------------------------------------------------------------
+def _score_title(query: str, entry: dict) -> float:
+ """Returns the best title-similarity score for an AniList media entry."""
+ title_obj = entry.get("title") or {}
+ candidates = [
+ title_obj.get("romaji") or "",
+ title_obj.get("english") or "",
+ title_obj.get("native") or "",
+ ]
+ best = 0.0
+ q = query.lower()
+ for t in candidates:
+ if t:
+ ratio = difflib.SequenceMatcher(None, q, t.lower()).ratio()
+ best = max(best, ratio)
+ return best
+
+
+# --------------------------------------------------------------------------
+# Usage example
+# --------------------------------------------------------------------------
+if __name__ == "__main__":
+ r1 = AniListResolver()
+ r2 = AniListResolver()
+ assert r1 is r2, "AniListResolver must be a Singleton"
+
+ al_id = r1.find_id("Yofukashi no Uta")
+ print("AniList ID :", al_id)
+
+ stats = r1.get_stats(al_id)
+ if stats:
+ print("Score :", stats["score"])
+ print("Rank :", stats["rank"])
+ print("Members :", stats["members"])
+
+ chars = r1.get_characters_detailed(al_id)
+ print("Characters (first 3):", [c["name"] for c in chars[:3]])
+
+ staff = r1.get_staff_detailed(al_id)
+ print("Staff :", [s["name"] for s in staff])
diff --git a/src/KavitaClient.py b/src/KavitaClient.py
new file mode 100644
index 0000000..fd0e77a
--- /dev/null
+++ b/src/KavitaClient.py
@@ -0,0 +1,229 @@
+"""
+kavita_client.py
+================
+
+Thin HTTP client for the Kavita server REST API (v0.9.x).
+
+Authenticates via the ``x-api-key`` header. All series / library /
+collection / metadata reads and writes used by the light-novel updater
+go through this single client so request shaping (paging, content types,
+timeouts, retries) is consistent.
+
+The class is intentionally state-light: no caching layer, just one
+``requests.Session``. Higher-level diff / update logic lives in
+KavitaSeriesUpdater, KavitaPersonUpdater and RelationshipSync.
+"""
+
+from __future__ import annotations
+
+import base64
+from typing import Iterable
+
+import requests
+
+
+class KavitaClient:
+ def __init__(self, base_url: str, api_key: str, *,
+ request_timeout: int = 30):
+ self._base = base_url.rstrip("/")
+ self._timeout = request_timeout
+
+ # API session: sends + receives JSON.
+ self._session = requests.Session()
+ self._session.headers.update({
+ "x-api-key": api_key,
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ })
+
+ # Plain session for downloading external images (covers). Must NOT
+ # carry the API headers — some CDNs refuse to return image bytes
+ # when the client sends Accept: application/json.
+ self._image_session = requests.Session()
+ self._image_session.headers.update({
+ "User-Agent": "KavitaLightNovelUpdater/1.0",
+ })
+
+ # ------------------------------------------------------------------
+ # Libraries
+ # ------------------------------------------------------------------
+ def list_libraries(self) -> list[dict]:
+ """Returns all libraries the authenticated user can access."""
+ r = self._session.get(f"{self._base}/api/Library/libraries",
+ timeout=self._timeout)
+ r.raise_for_status()
+ return r.json() or []
+
+ # ------------------------------------------------------------------
+ # Series
+ # ------------------------------------------------------------------
+ def list_series_in_library(self, library_id: int, *,
+ page_size: int = 200) -> list[dict]:
+ """
+ Returns all SeriesDto entries in the given library.
+
+ Uses POST /api/Series/all-v2 with a FilterV2 that scopes by
+ library id. Pages through until an empty page is returned.
+ """
+ results: list[dict] = []
+ page = 1
+ while True:
+ body = {
+ "statements": [
+ {
+ "comparison": 0, # Equal
+ "field": 19, # Libraries field id (Kavita v0.9.x)
+ "value": str(library_id),
+ }
+ ],
+ "combination": 1, # And
+ "sortOptions": {"isAscending": True, "sortField": 1},
+ "limitTo": 0,
+ }
+ r = self._session.post(
+ f"{self._base}/api/Series/all-v2",
+ params={"PageNumber": page, "PageSize": page_size},
+ json=body, timeout=self._timeout)
+ r.raise_for_status()
+ chunk = r.json() or []
+ if not chunk:
+ break
+ results.extend(chunk)
+ if len(chunk) < page_size:
+ break
+ page += 1
+ return results
+
+ def get_series(self, series_id: int) -> dict:
+ """Returns the SeriesDto for the given series id."""
+ r = self._session.get(f"{self._base}/api/Series/{series_id}",
+ timeout=self._timeout)
+ r.raise_for_status()
+ return r.json() or {}
+
+ def update_series(self, series: dict) -> None:
+ """Updates the Series-level data (name, sortName, malId, …)."""
+ r = self._session.post(f"{self._base}/api/Series/update",
+ json=series, timeout=self._timeout)
+ r.raise_for_status()
+
+ # ------------------------------------------------------------------
+ # Series metadata
+ # ------------------------------------------------------------------
+ def get_series_metadata(self, series_id: int) -> dict:
+ """Returns the SeriesMetadataDto for a series."""
+ r = self._session.get(
+ f"{self._base}/api/Series/metadata",
+ params={"seriesId": series_id}, timeout=self._timeout)
+ r.raise_for_status()
+ return r.json() or {}
+
+ def update_series_metadata(self, metadata: dict) -> None:
+ """
+ Writes a SeriesMetadataDto back to Kavita.
+
+ Kavita expects the payload wrapped: {seriesMetadata: {...}}.
+ """
+ r = self._session.post(
+ f"{self._base}/api/Series/metadata",
+ json={"seriesMetadata": metadata},
+ timeout=self._timeout)
+ r.raise_for_status()
+
+ # ------------------------------------------------------------------
+ # Related series
+ # ------------------------------------------------------------------
+ def get_related(self, series_id: int) -> dict:
+ """Returns all related series grouped by relation type."""
+ r = self._session.get(
+ f"{self._base}/api/Series/all-related",
+ params={"seriesId": series_id}, timeout=self._timeout)
+ r.raise_for_status()
+ return r.json() or {}
+
+ def update_related(self, payload: dict) -> None:
+ """
+ Sets the related-series relationships for a series.
+
+ Payload shape (UpdateRelatedSeriesDto):
+ {seriesId, prequels, sequels, sideStories, spinOffs,
+ adaptations, characters, contains, others,
+ alternativeSettings, alternativeVersions, doujinshis,
+ editions, annuals}
+ Each *_ids list contains target series ids (ints).
+ """
+ r = self._session.post(
+ f"{self._base}/api/Series/update-related",
+ json=payload, timeout=self._timeout)
+ r.raise_for_status()
+
+ # ------------------------------------------------------------------
+ # Collections
+ # ------------------------------------------------------------------
+ def list_collections(self) -> list[dict]:
+ """Returns all collection tags visible to the authenticated user."""
+ r = self._session.get(
+ f"{self._base}/api/Collection",
+ params={"ownedOnly": "false", "sortByLastModified": "false"},
+ timeout=self._timeout)
+ r.raise_for_status()
+ return r.json() or []
+
+ def add_series_to_collection(self, *, collection_id: int,
+ title: str,
+ series_ids: Iterable[int]) -> dict:
+ """
+ Adds (or creates) a collection and attaches series to it.
+
+ Pass collection_id=0 to create a new collection named `title`.
+ For an existing collection set collection_id to its id (title is
+ still required by the API but acts as no-op when the id matches).
+ """
+ body = {
+ "collectionTagId": int(collection_id),
+ "collectionTagTitle": title,
+ "seriesIds": [int(s) for s in series_ids],
+ }
+ r = self._session.post(
+ f"{self._base}/api/Collection/update-for-series",
+ json=body, timeout=self._timeout)
+ r.raise_for_status()
+ try:
+ return r.json() or {}
+ except ValueError:
+ return {}
+
+ # ------------------------------------------------------------------
+ # Series cover upload
+ # ------------------------------------------------------------------
+ def upload_series_cover(self, series_id: int, image_url: str, *,
+ lock: bool = False) -> None:
+ """
+ Downloads an external image and uploads it as the series cover.
+
+ Mirrors the cover-upload trick used in KavitaPersonUpdater:
+ Kavita's `/api/Upload/series` accepts a raw base64 blob (no
+ ``data:`` prefix) in the ``url`` field.
+ """
+ img = self._image_session.get(image_url, timeout=self._timeout)
+ img.raise_for_status()
+ b64 = base64.b64encode(img.content).decode()
+ r = self._session.post(
+ f"{self._base}/api/Upload/series",
+ json={"id": series_id, "url": b64, "lockCover": lock},
+ timeout=self._timeout)
+ r.raise_for_status()
+
+ # ------------------------------------------------------------------
+ # Generic GET helper (used by callers that need a response object)
+ # ------------------------------------------------------------------
+ def get(self, path: str, params: "dict | None" = None) -> requests.Response:
+ return self._session.get(f"{self._base}{path}",
+ params=params, timeout=self._timeout)
+
+ def post(self, path: str, *,
+ json: "dict | list | None" = None,
+ params: "dict | None" = None) -> requests.Response:
+ return self._session.post(f"{self._base}{path}",
+ json=json, params=params,
+ timeout=self._timeout)
diff --git a/src/KavitaPersonUpdater.py b/src/KavitaPersonUpdater.py
new file mode 100644
index 0000000..a1fbee1
--- /dev/null
+++ b/src/KavitaPersonUpdater.py
@@ -0,0 +1,545 @@
+"""
+kavita_person_updater.py
+========================
+
+Synchronises Kavita person / character records with MyAnimeList data.
+
+For every character and staff member that MAL knows about for a given manga
+the updater:
+ 1. Searches Kavita for a matching Person record (by name similarity /
+ alias match, configurable threshold).
+ 2. Sets the MAL ID on the Kavita person if it is not yet linked.
+ 3. Uploads the MAL profile image when the cover is not locked and has
+ not been set in a previous sync run.
+ 4. Populates the description field when Kavita has none and MAL provides
+ an 'about' text (requires an extra Jikan request per character; only
+ performed when update_descriptions=True).
+
+Kavita API version
+------------------
+Tested against Kavita 0.9.0.2.
+
+Authentication
+--------------
+Uses the `x-api-key` header (API key from Kavita user settings).
+No JWT login is required.
+
+Relevant endpoints (Kavita 0.9.0.2)
+-------------------------------------
+ GET /api/Person/search find persons by name / alias
+ POST /api/Person/update write metadata (malId, description, …)
+ POST /api/Upload/person set cover image (base64 data URI)
+ POST /api/Upload/upload-by-url download an external URL to temp storage
+ (used as an alternative upload path)
+
+Cover upload flow
+-----------------
+The image is downloaded locally, base64-encoded, and sent as a data URI
+to POST /api/Upload/person. This is more reliable than the
+upload-by-url → upload/person two-step because it avoids Kavita's temp
+file handling (which had known issues in 0.8.x – 0.9.x, GitHub #3900).
+
+Dependencies
+------------
+ requests -> pip install requests
+"""
+
+from __future__ import annotations
+
+import base64
+import datetime
+import difflib
+import re
+
+import requests
+
+from MALResolver import MALResolver
+from AniListResolver import AniListResolver
+
+
+class KavitaPersonUpdater:
+ """
+ Syncs Kavita Person records with MyAnimeList data.
+
+ Parameters
+ ----------
+ kavita_base_url : Base URL of the Kavita server, e.g. "http://192.168.2.2:5000"
+ api_key : Kavita API key (Settings → User → API key)
+ mal_resolver : Shared MALResolver singleton (created automatically if omitted)
+ request_timeout : HTTP timeout in seconds for both Kavita and image requests
+ min_name_score : Minimum difflib similarity ratio (0–1) required to accept a
+ Kavita person as a match for a MAL name. Default 0.80.
+ """
+
+ def __init__(self, kavita_base_url: str, api_key: str, *,
+ mal_resolver: "MALResolver | None" = None,
+ al_resolver: "AniListResolver | None" = None,
+ request_timeout: int = 30,
+ min_name_score: float = 0.80):
+ self._base = kavita_base_url.rstrip("/")
+ self._timeout = request_timeout
+ self._min_score = min_name_score
+ self._mal = mal_resolver or MALResolver()
+ self._al = al_resolver or AniListResolver()
+
+ # Session used for Kavita API calls.
+ self._session = requests.Session()
+ self._session.headers.update({
+ "x-api-key": api_key,
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ })
+
+ # Plain session used to download external images (MAL CDN etc.).
+ # Must NOT carry the Kavita API headers — Accept: application/json
+ # would prevent MAL CDN from returning the image bytes.
+ self._image_session = requests.Session()
+ self._image_session.headers.update({
+ "User-Agent": "KavitaPersonUpdater/1.0",
+ })
+
+ # Cache: normalised name -> list of PersonDto dicts (best matches first)
+ self._person_search_cache: dict[str, list[dict]] = {}
+
+ # ------------------------------------------------------------------
+ # Public: combined update
+ # ------------------------------------------------------------------
+ def update_for_manga(self, mal_manga_id: "int | None", *,
+ al_manga_id: "int | None" = None,
+ update_covers: bool = True,
+ update_descriptions: bool = True) -> dict:
+ """
+ Runs a full update pass for both characters and staff of the manga.
+ MAL is tried first; AniList is used as fallback when MAL returns nothing.
+
+ Returns
+ -------
+ {
+ "characters": {"updated": n, "skipped": n, "not_found": n},
+ "staff": {"updated": n, "skipped": n, "not_found": n},
+ }
+ """
+ return {
+ "characters": self.update_characters(
+ mal_manga_id, al_manga_id=al_manga_id,
+ update_covers=update_covers,
+ update_descriptions=update_descriptions),
+ "staff": self.update_staff(
+ mal_manga_id, al_manga_id=al_manga_id,
+ update_covers=update_covers,
+ update_descriptions=update_descriptions),
+ }
+
+ # ------------------------------------------------------------------
+ # Public: character update
+ # ------------------------------------------------------------------
+ def update_characters(self, mal_manga_id: "int | None", *,
+ al_manga_id: "int | None" = None,
+ update_covers: bool = True,
+ update_descriptions: bool = True) -> dict:
+ """
+ Updates Kavita persons that match MAL/AniList characters for the manga.
+ MAL is tried first; AniList is the fallback when MAL returns nothing.
+
+ Returns {"updated": n, "skipped": n, "not_found": n}.
+ """
+ entries = self._mal.get_characters_detailed(mal_manga_id) if mal_manga_id else []
+ resolver = self._mal
+ if not entries and al_manga_id:
+ entries = self._al.get_characters_detailed(al_manga_id)
+ resolver = self._al
+ return self._sync_entries(entries, "character", resolver,
+ update_covers=update_covers,
+ update_descriptions=update_descriptions)
+
+ # ------------------------------------------------------------------
+ # Public: staff update
+ # ------------------------------------------------------------------
+ def update_staff(self, mal_manga_id: "int | None", *,
+ al_manga_id: "int | None" = None,
+ update_covers: bool = True,
+ update_descriptions: bool = True) -> dict:
+ """
+ Updates Kavita persons that match MAL/AniList staff for the manga.
+ MAL is tried first; AniList is the fallback when MAL returns nothing.
+
+ Returns {"updated": n, "skipped": n, "not_found": n}.
+ """
+ entries = self._mal.get_staff_detailed(mal_manga_id) if mal_manga_id else []
+ resolver = self._mal
+ if not entries and al_manga_id:
+ entries = self._al.get_staff_detailed(al_manga_id)
+ resolver = self._al
+ return self._sync_entries(entries, "staff", resolver,
+ update_covers=update_covers,
+ update_descriptions=update_descriptions)
+
+ # ------------------------------------------------------------------
+ # Public: cache management
+ # ------------------------------------------------------------------
+ def clear_cache(self) -> None:
+ """Clears the Kavita person search cache."""
+ self._person_search_cache.clear()
+
+ # ------------------------------------------------------------------
+ # Internal: main sync loop
+ # ------------------------------------------------------------------
+ def _sync_entries(self, entries: list[dict], kind: str, resolver, *,
+ update_covers: bool,
+ update_descriptions: bool) -> dict:
+ result: dict = {"updated": 0, "skipped": 0, "not_found": 0,
+ "errors": []}
+ for entry in entries:
+ name = (entry.get("name") or "").strip()
+ raw_name = (entry.get("raw_name") or "").strip()
+ if not name and not raw_name:
+ continue
+
+ # Search by the cleaned (XML-safe) name first; if Kavita stores
+ # the legacy comma form, retry with the raw MAL name.
+ matches = self._find_kavita_person(name) if name else []
+ if not matches and raw_name and raw_name != name:
+ matches = self._find_kavita_person(raw_name)
+
+ if not matches:
+ result["not_found"] += 1
+ continue
+
+ changed = self._apply_mal_data(
+ matches[0], entry, kind, resolver,
+ update_cover=update_covers,
+ update_desc=update_descriptions,
+ errors=result["errors"])
+ result["updated" if changed else "skipped"] += 1
+
+ return result
+
+ # ------------------------------------------------------------------
+ # Internal: Kavita person search
+ # ------------------------------------------------------------------
+ def _find_kavita_person(self, name: str) -> list[dict]:
+ """
+ Searches Kavita for persons matching `name`.
+
+ Checks both the main name and any stored aliases.
+ Returns persons sorted by similarity, filtered by min_name_score.
+ Results are cached per (normalised) query name.
+ """
+ key = name.lower().strip()
+ if key in self._person_search_cache:
+ return self._person_search_cache[key]
+
+ try:
+ resp = self._session.get(
+ f"{self._base}/api/Person/search",
+ params={"queryString": name},
+ timeout=self._timeout,
+ )
+ resp.raise_for_status()
+ persons: list[dict] = resp.json() or []
+ except requests.RequestException:
+ self._person_search_cache[key] = []
+ return []
+
+ def score(p: dict) -> float:
+ candidates = [p.get("name") or ""]
+ candidates += [a for a in (p.get("aliases") or []) if a]
+ best = 0.0
+ q = key
+ for c in candidates:
+ r = difflib.SequenceMatcher(None, q, c.lower()).ratio()
+ best = max(best, r)
+ return best
+
+ ranked = sorted(persons, key=score, reverse=True)
+ filtered = [p for p in ranked if score(p) >= self._min_score]
+ self._person_search_cache[key] = filtered
+ return filtered
+
+ # ------------------------------------------------------------------
+ # Internal: apply MAL data to a single Kavita person
+ # ------------------------------------------------------------------
+ def _apply_mal_data(self, person: dict, mal_entry: dict, kind: str,
+ resolver, *,
+ update_cover: bool, update_desc: bool,
+ errors: "list | None" = None) -> bool:
+ """
+ Applies tracker data (MAL or AniList) to one Kavita person record.
+
+ Fields updated
+ --------------
+ - malId : set when the entry carries a MAL ID and it differs
+ - aniListId : set when the entry carries an AniList ID and it differs
+ - description: set when empty and the tracker provides a description
+ - cover image: uploaded when not locked and no prior sync cover exists
+
+ Returns True if any change was made. Failures are appended to the
+ `errors` list (if provided) instead of being silently swallowed.
+ """
+ person_id: "int | None" = person.get("id")
+ if not person_id:
+ return False
+
+ person_name = person.get("name") or ""
+
+ # Tracker IDs — a MAL entry has mal_id set; an AniList entry has al_id.
+ mal_id: "int | None" = mal_entry.get("mal_id")
+ al_id: "int | None" = mal_entry.get("al_id")
+ entity_id = mal_id or al_id # used for resolver detail calls
+
+ current_mal_id: int = person.get("malId") or 0
+ current_al_id: int = person.get("aniListId") or 0
+ needs_mal_id = bool(mal_id and current_mal_id != mal_id)
+ needs_al_id = bool(al_id and current_al_id != al_id)
+
+ # ------ Lazy description fetch -----------------------------------
+ description: "str | None" = None
+ if update_desc and not (person.get("description") or "").strip():
+ if entity_id:
+ if kind == "character":
+ details = resolver.get_character_details(entity_id)
+ if details:
+ description = _build_character_description(details) or None
+ else:
+ details = resolver.get_person_details(entity_id)
+ if details:
+ description = _build_person_description(details) or None
+
+ needs_desc = bool(description)
+
+ # ------ Metadata update ------------------------------------------
+ changed = False
+ if needs_mal_id or needs_al_id or needs_desc:
+ payload: dict = {
+ "id": person_id,
+ "name": person_name,
+ # MUST stay a boolean — the cover image itself is uploaded
+ # separately via POST /api/Upload/person (below). Putting a
+ # URL here makes Kavita reject the whole payload with HTTP 400.
+ "coverImageLocked": bool(person.get("coverImageLocked", False)),
+ "aliases": person.get("aliases") or [],
+ "description": description or person.get("description"),
+ "malId": mal_id if needs_mal_id else (current_mal_id or None),
+ "aniListId": al_id if needs_al_id else (current_al_id or None),
+ }
+ try:
+ resp = self._session.post(
+ f"{self._base}/api/Person/update",
+ json=payload,
+ timeout=self._timeout,
+ )
+ resp.raise_for_status()
+ changed = True
+ except requests.RequestException as e:
+ if errors is not None:
+ errors.append(
+ f"Person/update failed for #{person_id} "
+ f"'{person_name}': {e}")
+
+ # ------ Cover image upload ----------------------------------------
+ # Upload whenever:
+ # - caller requested cover updates
+ # - cover is NOT locked (user did not manually pin it)
+ # - we have not already uploaded this exact tracker entity's image
+ # (i.e. the tracked ID differs OR there is no cover yet).
+ if update_cover and not person.get("coverImageLocked"):
+ image_url = mal_entry.get("image_url")
+ already_uploaded = (
+ entity_id is not None
+ and (current_mal_id == mal_id or current_al_id == al_id)
+ and bool(person.get("coverImage"))
+ )
+ if image_url and not already_uploaded:
+ if self._upload_cover(person_id, image_url,
+ person_name=person_name,
+ errors=errors):
+ changed = True
+
+ return changed
+
+ # ------------------------------------------------------------------
+ # Internal: cover upload
+ # ------------------------------------------------------------------
+ def _upload_cover(self, person_id: int, image_url: str,
+ lock: bool = False, *,
+ person_name: str = "",
+ errors: "list | None" = None) -> bool:
+ """
+ Uploads a cover image to a Kavita person.
+
+ The image is downloaded with the plain (header-less) image session
+ and posted to `POST /api/Upload/person` as a raw base64 string in
+ the `url` field.
+
+ Notes on protocol quirks discovered against Kavita 0.9.0.2:
+ - The two-step `upload-by-url` -> `Upload/person` flow returns
+ "Unable to save cover image to Person" (HTTP 400).
+ - A `data:image/jpeg;base64,...` data URI is rejected with the
+ same error.
+ - Only the raw base64 blob (no prefix) is accepted.
+ """
+ label = (f"#{person_id} '{person_name}'"
+ if person_name else f"#{person_id}")
+
+ # 1) Download the image with a clean session — the Kavita session's
+ # `Accept: application/json` header makes some CDNs refuse to
+ # return image bytes.
+ try:
+ img_resp = self._image_session.get(image_url,
+ timeout=self._timeout)
+ img_resp.raise_for_status()
+ except requests.RequestException as e:
+ if errors is not None:
+ errors.append(
+ f"image download failed for {label} ({image_url}): {e}")
+ return False
+
+ b64 = base64.b64encode(img_resp.content).decode()
+
+ # 2) POST the raw base64 blob.
+ try:
+ resp = self._session.post(
+ f"{self._base}/api/Upload/person",
+ json={"id": person_id, "url": b64, "lockCover": lock},
+ timeout=self._timeout,
+ )
+ if resp.status_code >= 400:
+ if errors is not None:
+ errors.append(
+ f"Upload/person HTTP {resp.status_code} for {label}: "
+ f"{_short_body(resp)}")
+ return False
+ return True
+ except requests.RequestException as e:
+ if errors is not None:
+ errors.append(
+ f"Upload/person failed for {label}: {e}")
+ 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'
')
+ 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"