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])
+44 -27
View File
@@ -47,6 +47,7 @@ import requests
from MangadexVolumeResolver import MangaDexVolumeResolver from MangadexVolumeResolver import MangaDexVolumeResolver
from MangaBakaWorksResolver import MangaBakaWorksResolver from MangaBakaWorksResolver import MangaBakaWorksResolver
from MALResolver import MALResolver from MALResolver import MALResolver
from AniListResolver import AniListResolver
try: try:
from PIL import Image from PIL import Image
@@ -168,7 +169,8 @@ class ComicInfoBuilder:
session: "requests.Session | None" = None, session: "requests.Session | None" = None,
volume_resolver: "MangaDexVolumeResolver | None" = None, volume_resolver: "MangaDexVolumeResolver | None" = None,
works_resolver: "MangaBakaWorksResolver | 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(): if not manga_title or not str(manga_title).strip():
raise ValueError("manga_title must not be empty.") raise ValueError("manga_title must not be empty.")
@@ -190,9 +192,11 @@ class ComicInfoBuilder:
api_base_url=api_base_url, api_base_url=api_base_url,
request_timeout=request_timeout, request_timeout=request_timeout,
session=self._session)) 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( self._mal_resolver = mal_resolver or MALResolver(
request_timeout=request_timeout) request_timeout=request_timeout)
self._al_resolver = al_resolver or AniListResolver(
request_timeout=request_timeout)
self._metadata: "dict | None" = None self._metadata: "dict | None" = None
self._pages: list[dict] = [] self._pages: list[dict] = []
@@ -405,6 +409,8 @@ class ComicInfoBuilder:
mal_id = (self._mal_id_from_source(md) mal_id = (self._mal_id_from_source(md)
or self._mal_resolver.find_mal_id( or self._mal_resolver.find_mal_id(
md.get("title") or self._manga_title)) md.get("title") or self._manga_title))
al_id = self._al_id_from_source(md)
mal_stats = self._mal_resolver.get_stats(mal_id) mal_stats = self._mal_resolver.get_stats(mal_id)
add("Summary", self._build_summary(md, sd, mal_stats)) 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 # to display form ("Slice Of Life") so Kavita / readers show them
# consistently with the (already-titled-cased) Tags field. # consistently with the (already-titled-cased) Tags field.
add("Genre", ", ".join(_format_term(g) for g in (md.get("genres") or []))) 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) 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) add("Characters", ", ".join(characters) if characters else None)
# ----- Web links ---------------------------------------------------- # ----- Web links ----------------------------------------------------
@@ -571,32 +579,28 @@ class ComicInfoBuilder:
# ====================================================================== # ======================================================================
def _determine_series_group(self, md: dict) -> "str | None": 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 has a 'parent' relationship entry → fetch the parent
- If the series itself has child works (spin-offs, sequels …) series and return its MangaBaka title (so arcs/sequels appear under
-> use the series own title so all related works are grouped. the root series in Kavita).
- Otherwise -> None (no SeriesGroup). - 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) for rel in (md.get("relationships_v2") or []):
if not manga_id: if rel.get("relation_type") == "parent":
return None parent_id = rel.get("to_series_id")
try: if parent_id is not None:
relations = self._volume_resolver.get_series_relations(manga_id) try:
except Exception: parent_md = self._fetch_series_by_id(parent_id)
return None parent_title = parent_md.get("title")
if parent_title:
return parent_title
except Exception:
pass
break
if not relations: return md.get("title") or self._manga_title
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 # Title helpers
@@ -867,6 +871,19 @@ class ComicInfoBuilder:
pass pass
return None 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 @staticmethod
def _publishers_by_type(md: dict, ptype: str) -> "str | None": def _publishers_by_type(md: dict, ptype: str) -> "str | None":
names = [p.get("name") for p in (md.get("publishers") or []) names = [p.get("name") for p in (md.get("publishers") or [])
+54 -32
View File
@@ -54,6 +54,7 @@ import re
import requests import requests
from MALResolver import MALResolver from MALResolver import MALResolver
from AniListResolver import AniListResolver
class KavitaPersonUpdater: class KavitaPersonUpdater:
@@ -72,12 +73,14 @@ class KavitaPersonUpdater:
def __init__(self, kavita_base_url: str, api_key: str, *, def __init__(self, kavita_base_url: str, api_key: str, *,
mal_resolver: "MALResolver | None" = None, mal_resolver: "MALResolver | None" = None,
al_resolver: "AniListResolver | None" = None,
request_timeout: int = 30, request_timeout: int = 30,
min_name_score: float = 0.80): min_name_score: float = 0.80):
self._base = kavita_base_url.rstrip("/") self._base = kavita_base_url.rstrip("/")
self._timeout = request_timeout self._timeout = request_timeout
self._min_score = min_name_score self._min_score = min_name_score
self._mal = mal_resolver or MALResolver() self._mal = mal_resolver or MALResolver()
self._al = al_resolver or AniListResolver()
# Session used for Kavita API calls. # Session used for Kavita API calls.
self._session = requests.Session() self._session = requests.Session()
@@ -101,11 +104,13 @@ class KavitaPersonUpdater:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public: combined update # 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_covers: bool = True,
update_descriptions: bool = True) -> dict: update_descriptions: bool = True) -> dict:
""" """
Runs a full update pass for both characters and staff of the manga. 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 Returns
------- -------
@@ -116,11 +121,11 @@ class KavitaPersonUpdater:
""" """
return { return {
"characters": self.update_characters( "characters": self.update_characters(
mal_manga_id, mal_manga_id, al_manga_id=al_manga_id,
update_covers=update_covers, update_covers=update_covers,
update_descriptions=update_descriptions), update_descriptions=update_descriptions),
"staff": self.update_staff( "staff": self.update_staff(
mal_manga_id, mal_manga_id, al_manga_id=al_manga_id,
update_covers=update_covers, update_covers=update_covers,
update_descriptions=update_descriptions), update_descriptions=update_descriptions),
} }
@@ -128,32 +133,44 @@ class KavitaPersonUpdater:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public: character update # 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_covers: bool = True,
update_descriptions: bool = True) -> dict: 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}. Returns {"updated": n, "skipped": n, "not_found": n}.
""" """
entries = self._mal.get_characters_detailed(mal_manga_id) entries = self._mal.get_characters_detailed(mal_manga_id) if mal_manga_id else []
return self._sync_entries(entries, "character", 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_covers=update_covers,
update_descriptions=update_descriptions) update_descriptions=update_descriptions)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public: staff update # 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_covers: bool = True,
update_descriptions: bool = True) -> dict: 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}. Returns {"updated": n, "skipped": n, "not_found": n}.
""" """
entries = self._mal.get_staff_detailed(mal_manga_id) entries = self._mal.get_staff_detailed(mal_manga_id) if mal_manga_id else []
return self._sync_entries(entries, "staff", 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_covers=update_covers,
update_descriptions=update_descriptions) update_descriptions=update_descriptions)
@@ -167,7 +184,7 @@ class KavitaPersonUpdater:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal: main sync loop # 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_covers: bool,
update_descriptions: bool) -> dict: update_descriptions: bool) -> dict:
result: dict = {"updated": 0, "skipped": 0, "not_found": 0, result: dict = {"updated": 0, "skipped": 0, "not_found": 0,
@@ -189,7 +206,7 @@ class KavitaPersonUpdater:
continue continue
changed = self._apply_mal_data( changed = self._apply_mal_data(
matches[0], entry, kind, matches[0], entry, kind, resolver,
update_cover=update_covers, update_cover=update_covers,
update_desc=update_descriptions, update_desc=update_descriptions,
errors=result["errors"]) errors=result["errors"])
@@ -242,17 +259,19 @@ class KavitaPersonUpdater:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal: apply MAL data to a single Kavita person # 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, update_cover: bool, update_desc: bool,
errors: "list | None" = None) -> 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 Fields updated
-------------- --------------
- malId : set when not already pointing to this MAL entity - malId : set when the entry carries a MAL ID and it differs
- description : set when empty and MAL provides 'about' text - aniListId : set when the entry carries an AniList ID and it differs
- cover image : uploaded when not locked and no prior sync cover exists - 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 Returns True if any change was made. Failures are appended to the
`errors` list (if provided) instead of being silently swallowed. `errors` list (if provided) instead of being silently swallowed.
@@ -262,20 +281,27 @@ class KavitaPersonUpdater:
return False return False
person_name = person.get("name") or "" 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") 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_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_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 ----------------------------------- # ------ Lazy description fetch -----------------------------------
description: "str | None" = None description: "str | None" = None
if update_desc and not (person.get("description") or "").strip(): if update_desc and not (person.get("description") or "").strip():
if mal_id: if entity_id:
if kind == "character": if kind == "character":
details = self._mal.get_character_details(mal_id) details = resolver.get_character_details(entity_id)
if details: if details:
description = _build_character_description(details) or None description = _build_character_description(details) or None
else: else:
details = self._mal.get_person_details(mal_id) details = resolver.get_person_details(entity_id)
if details: if details:
description = _build_person_description(details) or None description = _build_person_description(details) or None
@@ -283,7 +309,7 @@ class KavitaPersonUpdater:
# ------ Metadata update ------------------------------------------ # ------ Metadata update ------------------------------------------
changed = False changed = False
if needs_mal_id or True or needs_desc: if needs_mal_id or needs_al_id or needs_desc:
payload: dict = { payload: dict = {
"id": person_id, "id": person_id,
"name": person_name, "name": person_name,
@@ -293,8 +319,8 @@ class KavitaPersonUpdater:
"coverImageLocked": bool(person.get("coverImageLocked", False)), "coverImageLocked": bool(person.get("coverImageLocked", False)),
"aliases": person.get("aliases") or [], "aliases": person.get("aliases") or [],
"description": description or person.get("description"), "description": description or person.get("description"),
"malId": mal_id if needs_mal_id else (current_mal_id or None), "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: try:
resp = self._session.post( resp = self._session.post(
@@ -314,17 +340,13 @@ class KavitaPersonUpdater:
# Upload whenever: # Upload whenever:
# - caller requested cover updates # - caller requested cover updates
# - cover is NOT locked (user did not manually pin it) # - cover is NOT locked (user did not manually pin it)
# - we have not already uploaded this exact MAL entity's image # - we have not already uploaded this exact tracker entity's image
# (i.e. malId differs OR there is no cover yet). # (i.e. the tracked ID 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"): if update_cover and not person.get("coverImageLocked"):
image_url = mal_entry.get("image_url") image_url = mal_entry.get("image_url")
already_uploaded = ( already_uploaded = (
mal_id is not None entity_id is not None
and current_mal_id == mal_id and (current_mal_id == mal_id or current_al_id == al_id)
and bool(person.get("coverImage")) and bool(person.get("coverImage"))
) )
if image_url and not already_uploaded: if image_url and not already_uploaded:
+9 -2
View File
@@ -35,8 +35,10 @@ import time
import requests import requests
from MediaResolver import MediaResolver
class MALResolver:
class MALResolver(MediaResolver):
""" """
Singleton: fetches and caches MAL manga data via Jikan API v4. Singleton: fetches and caches MAL manga data via Jikan API v4.
@@ -86,6 +88,10 @@ class MALResolver:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public: ID lookup # 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": def find_mal_id(self, title: str) -> "int | None":
""" """
Searches MAL for a manga by title and returns the best-matching MAL ID. Searches MAL for a manga by title and returns the best-matching MAL ID.
@@ -222,7 +228,8 @@ class MALResolver:
"about": None, "about": None,
}) })
self._char_detailed_cache[mal_id] = results if results:
self._char_detailed_cache[mal_id] = results
return 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 MangadexVolumeResolver import MangaDexVolumeResolver
from MangaBakaWorksResolver import MangaBakaWorksResolver from MangaBakaWorksResolver import MangaBakaWorksResolver
from MALResolver import MALResolver from MALResolver import MALResolver
from AniListResolver import AniListResolver
from KavitaPersonUpdater import KavitaPersonUpdater from KavitaPersonUpdater import KavitaPersonUpdater
@@ -133,14 +134,7 @@ def _clean_suwayomi_title(title: str) -> str:
def _mal_id_from_metadata(md: dict) -> "int | None": def _mal_id_from_metadata(md: dict) -> "int | None":
""" """Extracts the MAL ID from a MangaBaka series dict's source map."""
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.
"""
for raw_key, info in (md.get("source") or {}).items(): for raw_key, info in (md.get("source") or {}).items():
if re.sub(r"[^a-z0-9]", "", raw_key.lower()) in ("myanimelist", "mal"): if re.sub(r"[^a-z0-9]", "", raw_key.lower()) in ("myanimelist", "mal"):
if isinstance(info, dict): if isinstance(info, dict):
@@ -153,6 +147,20 @@ def _mal_id_from_metadata(md: dict) -> "int | None":
return 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": def _extract_chapter_num(folder_name: str) -> "str | None":
""" """
Fallback: extracts chapter number from the folder name. Fallback: extracts chapter number from the folder name.
@@ -239,6 +247,7 @@ class SuwayomiMover:
self._session = session self._session = session
self._mal = MALResolver(request_timeout=request_timeout) self._mal = MALResolver(request_timeout=request_timeout)
self._al = AniListResolver(request_timeout=request_timeout)
self._vol_resolver = MangaDexVolumeResolver( self._vol_resolver = MangaDexVolumeResolver(
request_timeout=request_timeout, session=session) request_timeout=request_timeout, session=session)
self._works_resolver = MangaBakaWorksResolver( self._works_resolver = MangaBakaWorksResolver(
@@ -249,6 +258,7 @@ class SuwayomiMover:
self._person_updater = KavitaPersonUpdater( self._person_updater = KavitaPersonUpdater(
kavita_base_url, kavita_api_key, kavita_base_url, kavita_api_key,
mal_resolver=self._mal, mal_resolver=self._mal,
al_resolver=self._al,
request_timeout=request_timeout) request_timeout=request_timeout)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -333,6 +343,7 @@ class SuwayomiMover:
volume_resolver=self._vol_resolver, volume_resolver=self._vol_resolver,
works_resolver=self._works_resolver, works_resolver=self._works_resolver,
mal_resolver=self._mal, mal_resolver=self._mal,
al_resolver=self._al,
) )
# Fetch MangaBaka metadata now to get the canonical title and MAL ID. # Fetch MangaBaka metadata now to get the canonical title and MAL ID.
@@ -358,14 +369,17 @@ class SuwayomiMover:
print(f" Chapter {chapter_num}: {status}") print(f" Chapter {chapter_num}: {status}")
# Sync Kavita persons once per series. # 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 person_result: "dict | None" = None
if self._person_updater: if self._person_updater:
mal_id = (_mal_id_from_metadata(md) if md else None mal_id = (_mal_id_from_metadata(md) if md else None
or self._mal.find_mal_id(builder_title)) 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: 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')} " print(f" Persons: chars={person_result['characters'].get('updated')} "
f"staff={person_result['staff'].get('updated')}") f"staff={person_result['staff'].get('updated')}")
except Exception as exc: except Exception as exc: