diff --git a/src/KavitaPersonUpdater.py b/src/KavitaPersonUpdater.py index 1a65692..1eb7b79 100644 --- a/src/KavitaPersonUpdater.py +++ b/src/KavitaPersonUpdater.py @@ -30,7 +30,7 @@ import requests from KavitaClient import KavitaClient from MALResolver import MALResolver from AniListResolver import AniListResolver -from TextUtils import best_similarity, paragraphs_to_html +from TextUtils import best_similarity, paragraphs_to_html, person_name_with_id class KavitaPersonUpdater: @@ -152,11 +152,28 @@ class KavitaPersonUpdater: if not name and not raw_name: continue - # Search by the cleaned (XML-safe) name first; if Kavita stores - # the legacy comma form, retry with the raw MAL name. - matches = self._find_kavita_person(name) if name else [] - if not matches and raw_name and raw_name != name: - matches = self._find_kavita_person(raw_name) + if kind == "character": + # Characters are stored under their disambiguated name + # ("Rem (MAL 118737)") — see person_name_with_id. The + # series metadata write creates the person under exactly + # this name, so only that form is searched. + search_names = [person_name_with_id( + name, mal_id=entry.get("mal_id"), + al_id=entry.get("al_id"))] + else: + # Staff: cleaned (XML-safe) name first; if Kavita stores + # the legacy comma form, retry with the raw MAL name. + search_names = [name] + if raw_name and raw_name != name: + search_names.append(raw_name) + + matches: list[dict] = [] + for search_name in search_names: + if not search_name: + continue + matches = self._find_kavita_person(search_name) + if matches: + break if not matches: result["not_found"] += 1 @@ -234,6 +251,20 @@ class KavitaPersonUpdater: current_mal_id: int = person.get("malId") or 0 current_al_id: int = person.get("aniListId") or 0 + + # Collision guard: the Kavita person is already linked to a + # *different* tracker entity — same display name, different + # character/person. Never overwrite; first writer wins. + if ((mal_id and current_mal_id and current_mal_id != mal_id) + or (al_id and current_al_id and current_al_id != al_id)): + if errors is not None: + errors.append( + f"conflict: '{person_name}' (#{person_id}) is linked to " + f"malId={current_mal_id or '-'}/aniListId={current_al_id or '-'} " + f"but this entry has malId={mal_id or '-'}/aniListId={al_id or '-'} " + f"— skipped") + return False + needs_mal_id = bool(mal_id and current_mal_id != mal_id) needs_al_id = bool(al_id and current_al_id != al_id) diff --git a/src/LightNovelMetadataBuilder.py b/src/LightNovelMetadataBuilder.py index 3a89e02..871c30a 100644 --- a/src/LightNovelMetadataBuilder.py +++ b/src/LightNovelMetadataBuilder.py @@ -26,7 +26,7 @@ from MangaBakaRateLimit import apply_to_session as _apply_mangabaka_rate_limit from MALResolver import MALResolver from AniListResolver import AniListResolver from MatchesCache import MatchesCache -from TextUtils import paragraphs_to_html +from TextUtils import paragraphs_to_html, person_name_with_id # MangaBaka series type for the search endpoint. @@ -311,9 +311,15 @@ class LightNovelMetadataBuilder: if not staff_detailed and al_id: staff_detailed = self._al.get_staff_detailed(al_id) - # Character / writer name lists for SeriesMetadata - character_names = [c["name"] for c in characters_detailed - if c.get("name")] + # Character names for SeriesMetadata, disambiguated with the + # tracker character id ("Rem (MAL 118737)") because Kavita person + # records are global and keyed by name only. + character_names = [ + person_name_with_id(c["name"], + mal_id=c.get("mal_id"), + al_id=c.get("al_id")) + for c in characters_detailed if c.get("name") + ] # Writers come from MangaBaka first (authoritative for novels) writers = list(md.get("authors") or []) # Illustrators / artists -> CoverArtists (Kavita has no dedicated diff --git a/src/TextUtils.py b/src/TextUtils.py index 1924b71..5b343cf 100644 --- a/src/TextUtils.py +++ b/src/TextUtils.py @@ -43,3 +43,30 @@ def best_similarity(query: str, candidates: Iterable[str]) -> float: None, q, str(candidate).lower()).ratio() best = max(best, ratio) return best + + +def person_name_with_id(name: str, *, + mal_id: "int | None" = None, + al_id: "int | None" = None) -> str: + """ + Disambiguates a character name with its tracker id: "Rem (MAL 118737)". + + Kavita Person records are global and keyed by name only, so two + different characters who share a name would collapse into one record. + Suffixing the tracker *character* id keeps them apart while still + sharing the record across the manga and light-novel version of the + same series (MAL/AniList character ids are per character, not per + medium). MAL is preferred; AniList ids get an "AL" marker so the two + id spaces cannot collide. Without any id the name is returned as-is. + + The format must stay in sync with the manga project so both tools + address the same Kavita person records. + """ + name = (name or "").strip() + if not name: + return name + if mal_id: + return f"{name} (MAL {mal_id})" + if al_id: + return f"{name} (AL {al_id})" + return name