Add AniList resolver as MAL fallback; fix SeriesGroup, tag formatting, empty-cache bug
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
"""
|
||||
anilist_resolver.py
|
||||
===================
|
||||
|
||||
Fetches and caches AniList manga metadata (statistics, characters, staff)
|
||||
using the public AniList GraphQL API.
|
||||
|
||||
AniList API: https://graphql.anilist.co (no authentication required)
|
||||
Rate limit: 90 req/min -> a 700 ms guard between calls is applied.
|
||||
On HTTP 429 (rate-limit exceeded) the response Retry-After header is
|
||||
honoured; the request is retried once automatically.
|
||||
|
||||
Singleton
|
||||
---------
|
||||
Only one instance of this class exists per process. Subsequent calls to
|
||||
AniListResolver() return the same object with its warm caches intact.
|
||||
|
||||
Provided features
|
||||
-----------------
|
||||
- Title-based AniList ID lookup with best-match scoring
|
||||
- Manga statistics: score (0–10), rank, popularity, members, favorites
|
||||
- Character list for a manga (names only — for <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 0–100; normalise to 0.0–10.0 for consistency
|
||||
# with the MALResolver stats dict shape.
|
||||
raw_score = entry.get("meanScore")
|
||||
score = round(raw_score / 10, 1) if raw_score is not None else None
|
||||
|
||||
# Ranked and popularity ranks are in the rankings array.
|
||||
rated_rank = None
|
||||
popular_rank = None
|
||||
for r in (entry.get("rankings") or []):
|
||||
if r.get("allTime"):
|
||||
if r.get("type") == "RATED" and rated_rank is None:
|
||||
rated_rank = r.get("rank")
|
||||
if r.get("type") == "POPULAR" and popular_rank is None:
|
||||
popular_rank = r.get("rank")
|
||||
|
||||
stats: dict = {
|
||||
"score": score,
|
||||
"rank": rated_rank,
|
||||
"scored_by": None, # not exposed by AniList API
|
||||
"popularity": popular_rank,
|
||||
"members": entry.get("popularity"), # AniList's popularity = member count
|
||||
"favorites": entry.get("favourites"),
|
||||
"url": entry.get("siteUrl") or f"https://anilist.co/manga/{tracker_id}",
|
||||
"title": title,
|
||||
"as_of": datetime.date.today().strftime("%d-%m-%Y"),
|
||||
}
|
||||
self._stats_cache[tracker_id] = stats
|
||||
return stats
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public: character names (for ComicInfo <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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user