diff --git a/src/AniListResolver.py b/src/AniListResolver.py new file mode 100644 index 0000000..39df248 --- /dev/null +++ b/src/AniListResolver.py @@ -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 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 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]) diff --git a/src/ComicInfoBuilder.py b/src/ComicInfoBuilder.py index a87d237..0da1488 100644 --- a/src/ComicInfoBuilder.py +++ b/src/ComicInfoBuilder.py @@ -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,32 +579,28 @@ 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 - try: - relations = self._volume_resolver.get_series_relations(manga_id) - except Exception: - 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: + parent_md = self._fetch_series_by_id(parent_id) + parent_title = parent_md.get("title") + if parent_title: + return parent_title + except Exception: + 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 + return md.get("title") or self._manga_title # ====================================================================== # 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 []) diff --git a/src/KavitaPersonUpdater.py b/src/KavitaPersonUpdater.py index 0bd93cf..c03bf32 100644 --- a/src/KavitaPersonUpdater.py +++ b/src/KavitaPersonUpdater.py @@ -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,17 +259,19 @@ 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 - - cover image : uploaded when not locked and no prior sync cover exists + - 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 `errors` list (if provided) instead of being silently swallowed. @@ -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, @@ -293,8 +319,8 @@ class KavitaPersonUpdater: "coverImageLocked": bool(person.get("coverImageLocked", False)), "aliases": person.get("aliases") or [], "description": description or person.get("description"), - "malId": mal_id if needs_mal_id else (current_mal_id or None), - "aniListId": person.get("aniListId") or None, + "malId": mal_id if needs_mal_id else (current_mal_id 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: diff --git a/src/MALResolver.py b/src/MALResolver.py index df34865..d16f824 100644 --- a/src/MALResolver.py +++ b/src/MALResolver.py @@ -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,7 +228,8 @@ class MALResolver: "about": None, }) - self._char_detailed_cache[mal_id] = results + if results: + self._char_detailed_cache[mal_id] = results return results # ------------------------------------------------------------------ diff --git a/src/MediaResolver.py b/src/MediaResolver.py new file mode 100644 index 0000000..3e960cb --- /dev/null +++ b/src/MediaResolver.py @@ -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 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.""" diff --git a/src/SuwayomiMover.py b/src/SuwayomiMover.py index 644b86f..792cb90 100644 --- a/src/SuwayomiMover.py +++ b/src/SuwayomiMover.py @@ -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: