Add AniList resolver as MAL fallback; fix SeriesGroup, tag formatting, empty-cache bug

This commit is contained in:
2026-05-23 22:35:08 +02:00
parent ec1342d146
commit b8f897fa2e
6 changed files with 730 additions and 72 deletions
+507
View File
@@ -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 (010), rank, popularity, members, favorites
- Character list for a manga (names only — for <Characters> 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_not_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 0100; normalise to 0.010.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 <Characters> 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])
+41 -24
View File
@@ -47,6 +47,7 @@ import requests
from MangadexVolumeResolver import MangaDexVolumeResolver
from MangaBakaWorksResolver import MangaBakaWorksResolver
from MALResolver import MALResolver
from AniListResolver import AniListResolver
try:
from PIL import Image
@@ -168,7 +169,8 @@ class ComicInfoBuilder:
session: "requests.Session | None" = None,
volume_resolver: "MangaDexVolumeResolver | None" = None,
works_resolver: "MangaBakaWorksResolver | None" = None,
mal_resolver: "MALResolver | None" = None):
mal_resolver: "MALResolver | None" = None,
al_resolver: "AniListResolver | None" = None):
if not manga_title or not str(manga_title).strip():
raise ValueError("manga_title must not be empty.")
@@ -190,9 +192,11 @@ class ComicInfoBuilder:
api_base_url=api_base_url,
request_timeout=request_timeout,
session=self._session))
# MALResolver is a Singleton — it manages its own session and caches.
# Both resolvers are Singletons — they manage their own sessions/caches.
self._mal_resolver = mal_resolver or MALResolver(
request_timeout=request_timeout)
self._al_resolver = al_resolver or AniListResolver(
request_timeout=request_timeout)
self._metadata: "dict | None" = None
self._pages: list[dict] = []
@@ -405,6 +409,8 @@ class ComicInfoBuilder:
mal_id = (self._mal_id_from_source(md)
or self._mal_resolver.find_mal_id(
md.get("title") or self._manga_title))
al_id = self._al_id_from_source(md)
mal_stats = self._mal_resolver.get_stats(mal_id)
add("Summary", self._build_summary(md, sd, mal_stats))
@@ -432,10 +438,12 @@ class ComicInfoBuilder:
# to display form ("Slice Of Life") so Kavita / readers show them
# consistently with the (already-titled-cased) Tags field.
add("Genre", ", ".join(_format_term(g) for g in (md.get("genres") or [])))
add("Tags", ", ".join(md.get("tags") or []))
add("Tags", ", ".join(_format_term(t) for t in (md.get("tags") or [])))
# ----- Characters from MAL ------------------------------------------
# ----- Characters — MAL first, AniList fallback ---------------------
characters = self._mal_resolver.get_characters(mal_id)
if not characters and al_id:
characters = self._al_resolver.get_characters(al_id)
add("Characters", ", ".join(characters) if characters else None)
# ----- Web links ----------------------------------------------------
@@ -571,33 +579,29 @@ class ComicInfoBuilder:
# ======================================================================
def _determine_series_group(self, md: dict) -> "str | None":
"""
Determines the SeriesGroup value from MangaDex relationships.
Determines SeriesGroup from MangaBaka's relationships_v2 field.
- If the series has a `main_story` parent -> use that title.
- If the series itself has child works (spin-offs, sequels …)
-> use the series own title so all related works are grouped.
- Otherwise -> None (no SeriesGroup).
- If the series has a 'parent' relationship entry → fetch the parent
series and return its MangaBaka title (so arcs/sequels appear under
the root series in Kavita).
- Otherwise → return the series' own title (it is the root, or a
standalone series with no parent).
"""
manga_id = self._mangadex_id_from_source(md)
if not manga_id:
return None
for rel in (md.get("relationships_v2") or []):
if rel.get("relation_type") == "parent":
parent_id = rel.get("to_series_id")
if parent_id is not None:
try:
relations = self._volume_resolver.get_series_relations(manga_id)
parent_md = self._fetch_series_by_id(parent_id)
parent_title = parent_md.get("title")
if parent_title:
return parent_title
except Exception:
return None
pass
break
if not relations:
return None
main_stories = relations.get("main_story") or []
if main_stories:
return main_stories[0]
if any(t in relations for t in _CHILD_RELATION_TYPES):
return md.get("title") or self._manga_title
return None
# ======================================================================
# Title helpers
# ======================================================================
@@ -867,6 +871,19 @@ class ComicInfoBuilder:
pass
return None
@staticmethod
def _al_id_from_source(md: dict) -> "int | None":
for raw_key, info in (md.get("source") or {}).items():
if _normalise_key(raw_key) == "anilist":
if isinstance(info, dict):
mid = info.get("id")
if mid is not None:
try:
return int(mid)
except (TypeError, ValueError):
pass
return None
@staticmethod
def _publishers_by_type(md: dict, ptype: str) -> "str | None":
names = [p.get("name") for p in (md.get("publishers") or [])
+52 -30
View File
@@ -54,6 +54,7 @@ import re
import requests
from MALResolver import MALResolver
from AniListResolver import AniListResolver
class KavitaPersonUpdater:
@@ -72,12 +73,14 @@ class KavitaPersonUpdater:
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()
@@ -101,11 +104,13 @@ class KavitaPersonUpdater:
# ------------------------------------------------------------------
# Public: combined update
# ------------------------------------------------------------------
def update_for_manga(self, mal_manga_id: int, *,
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
-------
@@ -116,11 +121,11 @@ class KavitaPersonUpdater:
"""
return {
"characters": self.update_characters(
mal_manga_id,
mal_manga_id, al_manga_id=al_manga_id,
update_covers=update_covers,
update_descriptions=update_descriptions),
"staff": self.update_staff(
mal_manga_id,
mal_manga_id, al_manga_id=al_manga_id,
update_covers=update_covers,
update_descriptions=update_descriptions),
}
@@ -128,32 +133,44 @@ class KavitaPersonUpdater:
# ------------------------------------------------------------------
# Public: character update
# ------------------------------------------------------------------
def update_characters(self, mal_manga_id: int, *,
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 characters for the manga.
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)
return self._sync_entries(entries, "character",
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, *,
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 staff (authors / artists).
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)
return self._sync_entries(entries, "staff",
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)
@@ -167,7 +184,7 @@ class KavitaPersonUpdater:
# ------------------------------------------------------------------
# Internal: main sync loop
# ------------------------------------------------------------------
def _sync_entries(self, entries: list[dict], kind: str, *,
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,
@@ -189,7 +206,7 @@ class KavitaPersonUpdater:
continue
changed = self._apply_mal_data(
matches[0], entry, kind,
matches[0], entry, kind, resolver,
update_cover=update_covers,
update_desc=update_descriptions,
errors=result["errors"])
@@ -242,16 +259,18 @@ class KavitaPersonUpdater:
# ------------------------------------------------------------------
# Internal: apply MAL data to a single Kavita person
# ------------------------------------------------------------------
def _apply_mal_data(self, person: dict, mal_entry: dict, kind: str, *,
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 MAL data to one Kavita person record.
Applies tracker data (MAL or AniList) 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
- 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
@@ -262,20 +281,27 @@ class KavitaPersonUpdater:
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 mal_id:
if entity_id:
if kind == "character":
details = self._mal.get_character_details(mal_id)
details = resolver.get_character_details(entity_id)
if details:
description = _build_character_description(details) or None
else:
details = self._mal.get_person_details(mal_id)
details = resolver.get_person_details(entity_id)
if details:
description = _build_person_description(details) or None
@@ -283,7 +309,7 @@ class KavitaPersonUpdater:
# ------ Metadata update ------------------------------------------
changed = False
if needs_mal_id or True or needs_desc:
if needs_mal_id or needs_al_id or needs_desc:
payload: dict = {
"id": person_id,
"name": person_name,
@@ -294,7 +320,7 @@ class KavitaPersonUpdater:
"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,
"aniListId": al_id if needs_al_id else (current_al_id or None),
}
try:
resp = self._session.post(
@@ -314,17 +340,13 @@ class KavitaPersonUpdater:
# 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.
# - 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 = (
mal_id is not None
and current_mal_id == mal_id
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:
+8 -1
View File
@@ -35,8 +35,10 @@ import time
import requests
from MediaResolver import MediaResolver
class MALResolver:
class MALResolver(MediaResolver):
"""
Singleton: fetches and caches MAL manga data via Jikan API v4.
@@ -86,6 +88,10 @@ class MALResolver:
# ------------------------------------------------------------------
# Public: ID lookup
# ------------------------------------------------------------------
def find_id(self, title: str) -> "int | None":
"""MediaResolver interface — delegates to find_mal_id."""
return self.find_mal_id(title)
def find_mal_id(self, title: str) -> "int | None":
"""
Searches MAL for a manga by title and returns the best-matching MAL ID.
@@ -222,6 +228,7 @@ class MALResolver:
"about": None,
})
if results:
self._char_detailed_cache[mal_id] = results
return results
+91
View File
@@ -0,0 +1,91 @@
"""
media_resolver.py
=================
Abstract base class for tracker-specific manga metadata resolvers.
Concrete implementations (MALResolver, AniListResolver) must implement
every abstract method, ensuring a uniform interface regardless of the
underlying data source (Jikan/MAL, AniList GraphQL, …).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class MediaResolver(ABC):
"""
Abstract base for tracker-specific manga metadata resolvers.
Subclasses connect to a specific tracker API and expose a common
interface for:
- Searching a manga by title → tracker-specific numeric ID
- Fetching summary statistics (score, rank, popularity, …)
- Listing characters and staff (name-only and detailed forms)
- Fetching full details for a single character or person
Methods that accept a tracker ID treat None as "unknown" and return
a safe empty value rather than raising.
"""
@abstractmethod
def find_id(self, title: str) -> "int | None":
"""
Searches the tracker for a manga by title.
Returns the best-matching tracker ID, or None on failure.
"""
@abstractmethod
def get_stats(self, tracker_id: "int | None") -> "dict | None":
"""
Returns a statistics dict for the given tracker 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.
"""
@abstractmethod
def get_characters(self, tracker_id: "int | None") -> "list[str]":
"""
Returns a flat list of character name strings for the manga.
Used to populate the ComicInfo <Characters> XML element.
"""
@abstractmethod
def get_characters_detailed(self, tracker_id: "int | None") -> "list[dict]":
"""
Returns detailed character entries for a manga:
[{id, name, image_url, role, about=None, ...}, ...]
'about' is not populated here; call get_character_details() lazily.
"""
@abstractmethod
def get_staff_detailed(self, tracker_id: "int | None") -> "list[dict]":
"""
Returns detailed staff/author entries for a manga:
[{id, name, image_url, positions, about=None, ...}, ...]
'about' is not populated here; call get_person_details() lazily.
"""
@abstractmethod
def get_character_details(self, char_id: "int | None") -> "dict | None":
"""
Returns full details for a single character, including description.
Implementations should cache the result.
"""
@abstractmethod
def get_person_details(self, person_id: "int | None") -> "dict | None":
"""
Returns full details for a single person (staff), including description.
Implementations should cache the result.
"""
@abstractmethod
def clear_cache(self) -> None:
"""Clears all internal caches."""
+25 -11
View File
@@ -55,6 +55,7 @@ from ComicInfoBuilder import ComicInfoBuilder
from MangadexVolumeResolver import MangaDexVolumeResolver
from MangaBakaWorksResolver import MangaBakaWorksResolver
from MALResolver import MALResolver
from AniListResolver import AniListResolver
from KavitaPersonUpdater import KavitaPersonUpdater
@@ -133,14 +134,7 @@ def _clean_suwayomi_title(title: str) -> str:
def _mal_id_from_metadata(md: dict) -> "int | None":
"""
Extracts the MAL ID directly from a MangaBaka series dict.
MangaBaka stores tracker IDs in md["source"], e.g.:
{"myanimelist": {"id": 121480}, "mangadex": {"id": "..."}, ...}
Returns the integer MAL ID, or None if not present.
"""
"""Extracts the MAL ID from a MangaBaka series dict's source map."""
for raw_key, info in (md.get("source") or {}).items():
if re.sub(r"[^a-z0-9]", "", raw_key.lower()) in ("myanimelist", "mal"):
if isinstance(info, dict):
@@ -153,6 +147,20 @@ def _mal_id_from_metadata(md: dict) -> "int | None":
return None
def _al_id_from_metadata(md: dict) -> "int | None":
"""Extracts the AniList ID from a MangaBaka series dict's source map."""
for raw_key, info in (md.get("source") or {}).items():
if re.sub(r"[^a-z0-9]", "", raw_key.lower()) == "anilist":
if isinstance(info, dict):
al_id = info.get("id")
if al_id is not None:
try:
return int(al_id)
except (TypeError, ValueError):
pass
return None
def _extract_chapter_num(folder_name: str) -> "str | None":
"""
Fallback: extracts chapter number from the folder name.
@@ -239,6 +247,7 @@ class SuwayomiMover:
self._session = session
self._mal = MALResolver(request_timeout=request_timeout)
self._al = AniListResolver(request_timeout=request_timeout)
self._vol_resolver = MangaDexVolumeResolver(
request_timeout=request_timeout, session=session)
self._works_resolver = MangaBakaWorksResolver(
@@ -249,6 +258,7 @@ class SuwayomiMover:
self._person_updater = KavitaPersonUpdater(
kavita_base_url, kavita_api_key,
mal_resolver=self._mal,
al_resolver=self._al,
request_timeout=request_timeout)
# ------------------------------------------------------------------
@@ -333,6 +343,7 @@ class SuwayomiMover:
volume_resolver=self._vol_resolver,
works_resolver=self._works_resolver,
mal_resolver=self._mal,
al_resolver=self._al,
)
# Fetch MangaBaka metadata now to get the canonical title and MAL ID.
@@ -358,14 +369,17 @@ class SuwayomiMover:
print(f" Chapter {chapter_num}: {status}")
# Sync Kavita persons once per series.
# MAL ID comes directly from MangaBaka; no extra Jikan title search needed.
# Both MAL and AniList IDs come from MangaBaka's source map;
# AniList is used as fallback when MAL returns no characters/staff.
person_result: "dict | None" = None
if self._person_updater:
mal_id = (_mal_id_from_metadata(md) if md else None
or self._mal.find_mal_id(builder_title))
if mal_id:
al_id = _al_id_from_metadata(md) if md else None
if mal_id or al_id:
try:
person_result = self._person_updater.update_for_manga(mal_id)
person_result = self._person_updater.update_for_manga(
mal_id, al_manga_id=al_id)
print(f" Persons: chars={person_result['characters'].get('updated')} "
f"staff={person_result['staff'].get('updated')}")
except Exception as exc: