175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
"""
|
|
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 ""
|