diff --git a/src/SuwayomiMover.py b/src/SuwayomiMover.py index ff514ef..8dd2643 100644 --- a/src/SuwayomiMover.py +++ b/src/SuwayomiMover.py @@ -43,6 +43,7 @@ Dependencies from __future__ import annotations +import json import re import shutil import xml.etree.ElementTree as ET @@ -64,6 +65,62 @@ from MangaBakaRateLimit import apply_to_session as _apply_mangabaka_rate_limit _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"} _CHAPTER_RE = re.compile(r'[Cc]hapter\s+(\d+(?:\.\d+)?)') +# JSON file written into each Kavita series folder, listing every chapter +# already moved. Avoids opening CBZ archives to determine what is present. +# Absence is interpreted as "folder empty" (per spec), not "scan the folder". +_CHAPTER_INDEX_FILENAME = "chapter_index.json" + + +def _normalise_volume_value(value): + """ + Normalises a volume identifier for storage in chapter_index.json. + + Returns int when the value is a whole number, float for fractional + volumes, None when missing. Mirrors how the user wants volumes + rendered (``"volume": 1`` rather than ``"volume": "1"``). + """ + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + f = float(text) + return int(f) if f.is_integer() else f + except (TypeError, ValueError): + return text + + +def _load_chapter_index(dest_series: Path) -> dict: + """ + Reads chapter_index.json from a Kavita series folder. + + Returns ``{"chapter": {}}`` when the file is missing or unreadable — + per the project spec, absence means "no chapters are present yet". + """ + path = dest_series / _CHAPTER_INDEX_FILENAME + if not path.is_file(): + return {"chapter": {}} + try: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print(f" [warn] chapter_index unreadable ({path.name}): {exc} — " + f"treating folder as empty") + return {"chapter": {}} + if not isinstance(data, dict) or not isinstance(data.get("chapter"), dict): + return {"chapter": {}} + return data + + +def _save_chapter_index(dest_series: Path, index: dict) -> None: + """Writes chapter_index.json atomically into a Kavita series folder.""" + path = dest_series / _CHAPTER_INDEX_FILENAME + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as f: + json.dump(index, f, ensure_ascii=False, indent=2) + tmp.replace(path) + # Parenthetical source labels that Suwayomi appends to series names. # These are not part of the actual title and confuse MangaBaka searches. _SOURCE_LABEL_RE = re.compile( @@ -509,13 +566,38 @@ class SuwayomiMover: dest_series = self._dst / _sanitize_dirname(mangabaka_title) dest_series.mkdir(parents=True, exist_ok=True) + # Skip chapters that have already been moved to Kavita. The index + # file in the destination folder is the authoritative source — we + # never open CBZ archives or stat them individually. + chapter_index = _load_chapter_index(dest_series) + already_moved = chapter_index["chapter"] + skipped: list[tuple[Path, str]] = [] + pending: list[tuple[Path, dict, str]] = [] + for item in chapter_items: + chapter_dir, _fields, chapter_num = item + if chapter_num in already_moved: + skipped.append((chapter_dir, chapter_num)) + else: + pending.append(item) + + for chapter_dir, chapter_num in skipped: + print(f" Chapter {chapter_num}: skip (already in Kavita)") + if self._delete_source: + shutil.rmtree(chapter_dir, ignore_errors=True) + chapter_results: list[dict] = [] - for chapter_dir, _fields, chapter_num in chapter_items: + for chapter_dir, _fields, chapter_num in pending: result = self._process_chapter( builder, chapter_num, chapter_dir, dest_series) chapter_results.append(result) status = "ok" if result["ok"] else f"ERROR: {result.get('error')}" print(f" Chapter {chapter_num}: {status}") + if result["ok"]: + already_moved[chapter_num] = { + "volume": _normalise_volume_value(result.get("volume")), + "archiveName": Path(result["cbz"]).name, + } + _save_chapter_index(dest_series, chapter_index) # Sync Kavita persons once per series. # Both MAL and AniList IDs come from MangaBaka's source map; @@ -557,11 +639,19 @@ class SuwayomiMover: try: builder.chapter = chapter_num builder.add_pages_from_folder(chapter_dir, cover_filename="000") + # Resolving the volume here piggy-backs on caches already warmed + # by add_pages_from_folder, so it's effectively free. Used by + # the chapter index in the Kavita destination folder. + try: + volume = builder._determine_volume() + except Exception: + volume = None builder.save_xml(chapter_dir) _pack_to_cbz(chapter_dir, cbz_path) if self._delete_source: shutil.rmtree(chapter_dir) - return {"chapter": chapter_num, "cbz": str(cbz_path), "ok": True} + return {"chapter": chapter_num, "cbz": str(cbz_path), + "ok": True, "volume": volume} except Exception as exc: return {"chapter": chapter_num, "cbz": str(cbz_path), "ok": False, "error": str(exc)}