MAl and Kavita update
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
# General
|
||||
.DS_Store
|
||||
.idea
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Node modules (all subprojects)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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 "<unreadable response body>"
|
||||
text = text.strip().replace("\n", " ").replace("\r", " ")
|
||||
if len(text) > limit:
|
||||
text = text[:limit] + "…"
|
||||
return text or "<empty body>"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 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}")
|
||||
+240
-52
@@ -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 <Characters> 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 <Characters>)
|
||||
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 <Characters> 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 <Characters> 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
|
||||
# <Characters> 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 <Characters> 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])
|
||||
|
||||
Reference in New Issue
Block a user