Files
manga-mover-and-metadata-co…/src/ln/RelationshipSync.py
T
johannesbot 216771f709
Build and Deploy / build (push) Successful in 59s
Build and Deploy / deploy (push) Successful in 24s
merged ln metadata into manga mover
2026-06-14 10:47:47 +02:00

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 ""