""" relationship_sync.py ==================== Mirrors MangaBaka's ``relationships_v2`` graph into Kavita: 1. Every related MangaBaka series that is *also* present in Kavita (resolved via MatchesCache) is added to a shared Kavita collection so the whole franchise can be browsed in one place. 2. Series-level relationships (prequel / sequel / spin-off / …) are written via ``POST /api/Series/update-related`` so navigating between entries surfaces the right neighbours. Only relationships where both endpoints exist in Kavita are written. Relationships pointing to series that have not been imported yet are silently skipped (the next match run picks them up). """ from __future__ import annotations from KavitaClient import KavitaClient from MatchesCache import MatchesCache # MangaBaka relation_type -> Kavita UpdateRelatedSeriesDto bucket _RELATION_MAP = { "prequel": "prequels", "sequel": "sequels", "side_story": "sideStories", "spin_off": "spinOffs", "spinoff": "spinOffs", "alternative_version": "alternativeVersions", "alternative_story": "alternativeVersions", "alternative_setting": "alternativeSettings", "adapted_from": "adaptations", "adaptation": "adaptations", "doujinshi": "doujinshis", "parent": "contains", # the parent "contains" the child } _ALL_BUCKETS = ( "adaptations", "characters", "contains", "others", "prequels", "sequels", "sideStories", "spinOffs", "alternativeSettings", "alternativeVersions", "doujinshis", "editions", "annuals", ) class RelationshipSync: def __init__(self, client: KavitaClient, cache: MatchesCache, *, builder=None): """ Parameters ---------- client : KavitaClient for collection / relation writes. cache : MatchesCache to resolve mangabakaId -> kavitaSeriesId. builder : optional LightNovelMetadataBuilder used to fetch parent series titles when picking the collection name. """ self._client = client self._cache = cache self._builder = builder # ------------------------------------------------------------------ # Public # ------------------------------------------------------------------ def sync(self, kavita_series_id: int, built: dict) -> dict: """ Applies the relationship and collection links described by `built["relationships"]` (raw MangaBaka relationships_v2 list) for the given Kavita series. Returns a small status dict. """ report: dict = {"relations": {}, "collection": None, "missing_series": []} relationships = built.get("relationships") or [] if not relationships: return report # Resolve mangabakaId -> kavitaSeriesId for every related entry. related: dict[str, list[int]] = {b: [] for b in _ALL_BUCKETS} all_kavita_ids: set[int] = set() for rel in relationships: mb_id = rel.get("to_series_id") if mb_id is None: continue hit = self._cache.get_by_mangabaka_id(mb_id) if not hit: report["missing_series"].append(int(mb_id)) continue _title, entry = hit ksid = int(entry.get("kavitaSeriesId") or 0) if not ksid: report["missing_series"].append(int(mb_id)) continue bucket = _RELATION_MAP.get((rel.get("relation_type") or "").lower(), "others") if ksid not in related[bucket]: related[bucket].append(ksid) all_kavita_ids.add(ksid) # ----- Relationships ------------------------------------------ if any(related.values()): payload = {"seriesId": int(kavita_series_id)} for bucket in _ALL_BUCKETS: payload[bucket] = related[bucket] try: self._client.update_related(payload) report["relations"] = {k: v for k, v in related.items() if v} except Exception as exc: report["relations"] = {"error": str(exc)} # ----- Collection --------------------------------------------- # Include the current series in the collection so it shows up too. all_kavita_ids.add(int(kavita_series_id)) if len(all_kavita_ids) >= 2: collection_name = self._collection_name(built, relationships) collection_id = self._find_collection_id(collection_name) try: self._client.add_series_to_collection( collection_id=collection_id, title=collection_name, series_ids=sorted(all_kavita_ids), ) report["collection"] = collection_name except Exception as exc: report["collection"] = f"error: {exc}" return report # ------------------------------------------------------------------ # Internal # ------------------------------------------------------------------ def _find_collection_id(self, name: str) -> int: """Returns the id of an existing collection by title, or 0 to create.""" if not name: return 0 target = name.strip().lower() try: for col in self._client.list_collections(): if (col.get("title") or "").strip().lower() == target: try: return int(col.get("id") or 0) except (TypeError, ValueError): return 0 except Exception: pass return 0 def _collection_name(self, built: dict, relationships: list[dict]) -> str: """ Picks the collection name. Uses the parent series title from MangaBaka if the current series has one; otherwise falls back to the current series' own title. """ for rel in relationships: if (rel.get("relation_type") or "").lower() == "parent": parent_id = rel.get("to_series_id") if parent_id is not None and self._builder is not None: try: parent_md = self._builder.fetch_series(parent_id) if parent_md and parent_md.get("title"): return parent_md["title"] except Exception: pass # Even without a builder, the cache may know the parent. hit = self._cache.get_by_mangabaka_id(parent_id) if hit: _title, entry = hit name = entry.get("mangabakaName") if name: return name return built.get("mangabakaTitle") or ""