From 852f6b84ef7a2e0a822c644824048988c2e808bc Mon Sep 17 00:00:00 2001 From: JohannesBOT Date: Sat, 23 May 2026 10:21:09 +0200 Subject: [PATCH] MAl and Kavita update --- .gitignore | 2 +- src/ComicInfoBuilder.py | 7 +- src/KavitaPersonUpdater.py | 431 +++++++++++++++++++++++++++++++++++++ src/MALResolver.py | 292 ++++++++++++++++++++----- 4 files changed, 675 insertions(+), 57 deletions(-) create mode 100644 src/KavitaPersonUpdater.py diff --git a/.gitignore b/.gitignore index 76c8a67..c4682f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # General .DS_Store -.idea +.idea/ .vscode/ # Node modules (all subprojects) diff --git a/src/ComicInfoBuilder.py b/src/ComicInfoBuilder.py index 913e5f7..1d839dc 100644 --- a/src/ComicInfoBuilder.py +++ b/src/ComicInfoBuilder.py @@ -145,10 +145,9 @@ class ComicInfoBuilder: api_base_url=api_base_url, request_timeout=request_timeout, session=self._session)) - self._mal_resolver = (mal_resolver - or MALResolver( - request_timeout=request_timeout, - session=self._session)) + # MALResolver is a Singleton — it manages its own session and caches. + self._mal_resolver = mal_resolver or MALResolver( + request_timeout=request_timeout) self._metadata: "dict | None" = None self._pages: list[dict] = [] diff --git a/src/KavitaPersonUpdater.py b/src/KavitaPersonUpdater.py new file mode 100644 index 0000000..859f05a --- /dev/null +++ b/src/KavitaPersonUpdater.py @@ -0,0 +1,431 @@ +""" +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 difflib + +import requests + +from MALResolver import MALResolver + + +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, + 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() + + # 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, *, + update_covers: bool = True, + update_descriptions: bool = True) -> dict: + """ + Runs a full update pass for both characters and staff of the manga. + + 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, + update_covers=update_covers, + update_descriptions=update_descriptions), + "staff": self.update_staff( + mal_manga_id, + update_covers=update_covers, + update_descriptions=update_descriptions), + } + + # ------------------------------------------------------------------ + # Public: character update + # ------------------------------------------------------------------ + def update_characters(self, mal_manga_id: int, *, + update_covers: bool = True, + update_descriptions: bool = True) -> dict: + """ + Updates Kavita persons that match MAL characters for the manga. + + Returns {"updated": n, "skipped": n, "not_found": n}. + """ + entries = self._mal.get_characters_detailed(mal_manga_id) + return self._sync_entries(entries, "character", + update_covers=update_covers, + update_descriptions=update_descriptions) + + # ------------------------------------------------------------------ + # Public: staff update + # ------------------------------------------------------------------ + def update_staff(self, mal_manga_id: int, *, + update_covers: bool = True, + update_descriptions: bool = True) -> dict: + """ + Updates Kavita persons that match MAL staff (authors / artists). + + Returns {"updated": n, "skipped": n, "not_found": n}. + """ + entries = self._mal.get_staff_detailed(mal_manga_id) + return self._sync_entries(entries, "staff", + 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, *, + 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, + 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, *, + update_cover: bool, update_desc: bool, + errors: "list | None" = None) -> bool: + """ + Applies MAL data to one Kavita person record. + + Fields updated + -------------- + - malId : set when not already pointing to this MAL entity + - description : set when empty and MAL provides 'about' text + - 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 "" + mal_id: "int | None" = mal_entry.get("mal_id") + current_mal_id: int = person.get("malId") or 0 + needs_mal_id = bool(mal_id and current_mal_id != mal_id) + + # ------ Lazy description fetch ----------------------------------- + description: "str | None" = None + if update_desc and not (person.get("description") or "").strip(): + if mal_id: + if kind == "character": + details = self._mal.get_character_details(mal_id) + else: + details = self._mal.get_person_details(mal_id) + if details: + description = (details.get("about") or "").strip() or None + + needs_desc = bool(description) + + # ------ Metadata update ------------------------------------------ + changed = False + if needs_mal_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": person.get("aniListId") 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 MAL entity's image + # (i.e. malId differs OR there is no cover yet). + # + # Persons are auto-created by Kavita on ComicInfo.xml import without + # a cover, so on the first sync we ALWAYS need to upload — regardless + # of whether the metadata payload above also needed updating. + if update_cover and not person.get("coverImageLocked"): + image_url = mal_entry.get("image_url") + already_uploaded = ( + mal_id is not None + and current_mal_id == mal_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 helper +# -------------------------------------------------------------------------- +def _short_body(resp: requests.Response, limit: int = 400) -> str: + """Returns the response body trimmed to `limit` chars for error logging.""" + try: + text = resp.text or "" + except Exception: + return "" + text = text.strip().replace("\n", " ").replace("\r", " ") + if len(text) > limit: + text = text[:limit] + "…" + return text or "" + + +# -------------------------------------------------------------------------- +# Usage example +# -------------------------------------------------------------------------- +if __name__ == "__main__": + KAVITA_URL = "http://192.168.2.2:5000" + KAVITA_KEY = "Sq4a3hcV171dn3gzCl0K4eN7hZNk4sOA" + + updater = KavitaPersonUpdater(KAVITA_URL, KAVITA_KEY) + + mal = MALResolver() + mal_id = mal.find_mal_id("One Punch-Man") + print("MAL ID:", mal_id) + + if mal_id: + result = updater.update_for_manga(mal_id) + print("Characters:", {k: v for k, v in result["characters"].items() + if k != "errors"}) + print("Staff :", {k: v for k, v in result["staff"].items() + if k != "errors"}) + # Surface any non-fatal upload / API errors for debugging + for section in ("characters", "staff"): + for err in result[section].get("errors", []): + print(f"[{section}] {err}") diff --git a/src/MALResolver.py b/src/MALResolver.py index 08f9ba5..1f6b7f6 100644 --- a/src/MALResolver.py +++ b/src/MALResolver.py @@ -2,18 +2,25 @@ mal_resolver.py =============== -Fetches and caches MyAnimeList manga metadata (statistics and characters) +Fetches and caches MyAnimeList manga metadata (statistics, characters, staff) using the public Jikan REST API v4. Jikan API: https://api.jikan.moe/v4 (no authentication required) -Rate limit: 3 req/s, 60 req/min -> a 400 ms delay between calls is applied. +Rate limit: 3 req/s, 60 req/min -> a 400 ms guard between calls is applied. + +Singleton +--------- +Only one instance of this class exists per process. Subsequent calls to +MALResolver() return the same object with its warm caches intact. Provided features ----------------- -- Title-based MAL ID lookup with best-match scoring (cached) +- Title-based MAL ID lookup with best-match scoring - MAL statistics: score, rank, scored_by, popularity, members, favorites -- Character list for a manga (names only, cached) -- Convenience: get_characters_for_manga(title) -> list[str] +- Character list for a manga (names only — for XML tag) +- Detailed character list: name, MAL character ID, image URL, role +- Detailed staff list: name, MAL person ID, image URL, positions +- Lazy full-detail fetches per character / person (for descriptions) Dependencies ------------ @@ -31,23 +38,50 @@ import requests class MALResolver: """ - Fetches and caches MyAnimeList manga data via the Jikan API v4. + Singleton: fetches and caches MAL manga data via Jikan API v4. + + The first call to MALResolver() creates and initialises the instance; + all subsequent calls return the same object. """ - JIKAN_BASE = "https://api.jikan.moe/v4" + _instance: "MALResolver | None" = None - def __init__(self, *, - request_timeout: int = 30, - session: "requests.Session | 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.JIKAN_BASE = "https://api.jikan.moe/v4" self.request_timeout = request_timeout - self._session = session or requests.Session() + + self._session = requests.Session() self._session.headers.setdefault("User-Agent", "MALResolver/1.0") - self._id_cache: dict[str, "int | None"] = {} # title_lower -> mal_id - self._stats_cache: dict[int, dict] = {} # mal_id -> stats dict - self._char_cache: dict[int, list[str]] = {} # mal_id -> [name, ...] + # title_lower -> mal_id + self._id_cache: dict[str, "int | None"] = {} + # mal_id -> stats dict + self._stats_cache: dict[int, dict] = {} + # manga_mal_id -> [name_str, ...] (for ComicInfo ) + self._char_names_cache: dict[int, list[str]] = {} + # manga_mal_id -> [{mal_id, name, image_url, role}] + self._char_detailed_cache: dict[int, list[dict]] = {} + # manga_mal_id -> [{mal_id, name, image_url, positions}] + self._staff_detailed_cache: dict[int, list[dict]] = {} + # char_mal_id -> {mal_id, name, image_url, about} + self._char_info_cache: dict[int, dict] = {} + # person_mal_id -> {mal_id, name, image_url, about, website_url} + self._person_info_cache: dict[int, dict] = {} self._last_request_at: float = 0.0 + self._initialized = True # ------------------------------------------------------------------ # Public: ID lookup @@ -87,23 +121,13 @@ class MALResolver: """ Returns a statistics dict for the given MAL manga ID: - { - "score": float | None, - "rank": int | None, - "scored_by": int | None, - "popularity": int | None, - "members": int | None, - "favorites": int | None, - "url": str, - "title": str, - "as_of": str (DD-MM-YYYY), - } + {score, rank, scored_by, popularity, members, favorites, + url, title, as_of (DD-MM-YYYY)} Returns None if mal_id is None or on network failure. """ if mal_id is None: return None - if mal_id in self._stats_cache: return self._stats_cache[mal_id] @@ -133,18 +157,42 @@ class MALResolver: return self.get_stats(self.find_mal_id(title)) # ------------------------------------------------------------------ - # Public: characters + # Public: character names (for ComicInfo tag) # ------------------------------------------------------------------ def get_characters(self, mal_id: "int | None") -> list[str]: """ - Returns a list of character names (strings) for the manga. - Returns an empty list on failure. + Returns a flat list of character names for the manga. + Used by ComicInfoBuilder to populate the XML element. """ if mal_id is None: return [] + if mal_id in self._char_names_cache: + return self._char_names_cache[mal_id] - if mal_id in self._char_cache: - return self._char_cache[mal_id] + detailed = self.get_characters_detailed(mal_id) + names = [e["name"] for e in detailed if e.get("name")] + self._char_names_cache[mal_id] = names + return names + + def get_characters_for_manga(self, title: str) -> list[str]: + """Convenience: search by title, then return character names.""" + return self.get_characters(self.find_mal_id(title)) + + # ------------------------------------------------------------------ + # Public: detailed character data (for KavitaPersonUpdater) + # ------------------------------------------------------------------ + def get_characters_detailed(self, mal_id: "int | None") -> list[dict]: + """ + Returns detailed character entries for a manga: + [{mal_id, name, image_url, role, about=None}, ...] + + `about` is not populated here; call get_character_details(char_mal_id) + to fetch it lazily when needed. + """ + if mal_id is None: + return [] + if mal_id in self._char_detailed_cache: + return self._char_detailed_cache[mal_id] try: data = self._get(f"{self.JIKAN_BASE}/manga/{mal_id}/characters") @@ -152,36 +200,143 @@ class MALResolver: except requests.RequestException: return [] - names = [] + results = [] for entry in entries: char = entry.get("character") or {} - name = char.get("name") - if name: - names.append(name) + raw_name = char.get("name") or "" + if not raw_name: + continue + jpg = (char.get("images") or {}).get("jpg") or {} + results.append({ + "mal_id": char.get("mal_id"), + # Cleaned name: "Hibino, Susuki" -> "Susuki Hibino". ComicInfo + # is comma-separated, so commas in names would + # cause Kavita to split a single character into two persons. + "name": _clean_mal_name(raw_name), + "raw_name": raw_name, + "image_url": jpg.get("image_url") or jpg.get("small_image_url"), + "role": entry.get("role") or "Supporting", + "about": None, + }) - self._char_cache[mal_id] = names - return names + self._char_detailed_cache[mal_id] = results + return results - def get_characters_for_manga(self, title: str) -> list[str]: + # ------------------------------------------------------------------ + # Public: detailed staff data (for KavitaPersonUpdater) + # ------------------------------------------------------------------ + def get_staff_detailed(self, mal_id: "int | None") -> list[dict]: """ - Convenience: search for manga by title, then return its characters. + Returns detailed staff 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. """ - return self.get_characters(self.find_mal_id(title)) + if mal_id is None: + return [] + if mal_id in self._staff_detailed_cache: + return self._staff_detailed_cache[mal_id] + + try: + data = self._get(f"{self.JIKAN_BASE}/manga/{mal_id}/staff") + entries = 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: + continue + jpg = (person.get("images") or {}).get("jpg") or {} + results.append({ + "mal_id": person.get("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 [], + "about": None, + }) + + self._staff_detailed_cache[mal_id] = results + return results + + # ------------------------------------------------------------------ + # Public: individual character / person details (lazy, with description) + # ------------------------------------------------------------------ + def get_character_details(self, char_mal_id: "int | None") -> "dict | None": + """ + Returns full details for a single MAL character, including `about`. + Result is cached. + """ + if char_mal_id is None: + return None + if char_mal_id in self._char_info_cache: + return self._char_info_cache[char_mal_id] + + try: + data = self._get(f"{self.JIKAN_BASE}/characters/{char_mal_id}") + entry = data.get("data") or {} + except requests.RequestException: + return None + + jpg = (entry.get("images") or {}).get("jpg") or {} + result = { + "mal_id": entry.get("mal_id"), + "name": entry.get("name") or "", + "image_url": jpg.get("image_url") or jpg.get("small_image_url"), + "about": entry.get("about"), + } + self._char_info_cache[char_mal_id] = result + return result + + def get_person_details(self, person_mal_id: "int | None") -> "dict | None": + """ + Returns full details for a single MAL person (staff), including `about`. + Result is cached. + """ + if person_mal_id is None: + return None + if person_mal_id in self._person_info_cache: + return self._person_info_cache[person_mal_id] + + try: + data = self._get(f"{self.JIKAN_BASE}/people/{person_mal_id}") + entry = data.get("data") or {} + except requests.RequestException: + return None + + jpg = (entry.get("images") or {}).get("jpg") or {} + result = { + "mal_id": entry.get("mal_id"), + "name": entry.get("name") or "", + "image_url": jpg.get("image_url") or jpg.get("small_image_url"), + "about": entry.get("about"), + "website_url": entry.get("website_url"), + } + self._person_info_cache[person_mal_id] = result + return result # ------------------------------------------------------------------ # Public: cache management # ------------------------------------------------------------------ def clear_cache(self) -> None: - """Clears all internal caches.""" + """Clears all internal caches (the Singleton instance is retained).""" self._id_cache.clear() self._stats_cache.clear() - self._char_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 HTTP # ------------------------------------------------------------------ def _get(self, url: str, params: "dict | None" = None) -> dict: - """Rate-limited GET request (respects Jikan's 3 req/s limit).""" + """Rate-limited GET request (respects Jikan's ~3 req/s limit).""" elapsed = time.monotonic() - self._last_request_at if elapsed < 0.4: time.sleep(0.4 - elapsed) @@ -194,6 +349,35 @@ class MALResolver: # -------------------------------------------------------------------------- # Module helper # -------------------------------------------------------------------------- +def _clean_mal_name(name: str) -> str: + """ + Converts an MAL name into a comma-free, ComicInfo-safe form. + + The ComicInfo tag is comma-separated, so a single MAL + character "Hibino, Susuki" written into the XML would be parsed by + Kavita as two persons ("Hibino" and "Susuki"). + + Conversion: + "Hibino, Susuki" -> "Susuki Hibino" (Western: First Last) + "Yamori, Kou" -> "Kou Yamori" + "Kotoyama" -> "Kotoyama" (unchanged) + + Trailing/leading commas and stray whitespace are stripped defensively. + """ + if not name: + return "" + name = name.strip() + if "," in name: + last, _, first = name.partition(",") + first = first.strip() + last = last.strip() + if first and last: + return f"{first} {last}" + # Fallback: strip any remaining commas + return name.replace(",", " ").strip() + return name + + def _score_title(query: str, entry: dict) -> float: """Returns the best title-similarity score for a Jikan manga entry.""" candidates = [ @@ -203,7 +387,6 @@ def _score_title(query: str, entry: dict) -> float: ] for alt in (entry.get("titles") or []): candidates.append(alt.get("title") or "") - best = 0.0 q = query.lower() for t in candidates: @@ -217,15 +400,20 @@ def _score_title(query: str, entry: dict) -> float: # Usage example # -------------------------------------------------------------------------- if __name__ == "__main__": - resolver = MALResolver() + r1 = MALResolver() + r2 = MALResolver() + assert r1 is r2, "MALResolver must be a Singleton" - mal_id = resolver.find_mal_id("Yofukashi no Uta") - print("MAL ID :", mal_id) + mal_id = r1.find_mal_id("Yofukashi no Uta") + print("MAL ID :", mal_id) - stats = resolver.get_stats(mal_id) + stats = r1.get_stats(mal_id) if stats: - print("Score :", stats["score"]) - print("Rank :", stats["rank"]) + print("Score :", stats["score"]) + print("Rank :", stats["rank"]) - chars = resolver.get_characters(mal_id) - print("Characters (first 5):", chars[:5]) + chars = r1.get_characters_detailed(mal_id) + print("Characters (first 3):", [c["name"] for c in chars[:3]]) + + staff = r1.get_staff_detailed(mal_id) + print("Staff :", [s["name"] for s in staff])