added chapter index json
Build and Deploy / deploy (push) Successful in 36s
Build and Deploy / build (push) Successful in 23s

This commit is contained in:
2026-06-10 12:30:24 +02:00
parent d724e9ffcd
commit 59ea1f8c8f
+92 -2
View File
@@ -43,6 +43,7 @@ Dependencies
from __future__ import annotations from __future__ import annotations
import json
import re import re
import shutil import shutil
import xml.etree.ElementTree as ET 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"} _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"}
_CHAPTER_RE = re.compile(r'[Cc]hapter\s+(\d+(?:\.\d+)?)') _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. # Parenthetical source labels that Suwayomi appends to series names.
# These are not part of the actual title and confuse MangaBaka searches. # These are not part of the actual title and confuse MangaBaka searches.
_SOURCE_LABEL_RE = re.compile( _SOURCE_LABEL_RE = re.compile(
@@ -509,13 +566,38 @@ class SuwayomiMover:
dest_series = self._dst / _sanitize_dirname(mangabaka_title) dest_series = self._dst / _sanitize_dirname(mangabaka_title)
dest_series.mkdir(parents=True, exist_ok=True) 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] = [] 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( result = self._process_chapter(
builder, chapter_num, chapter_dir, dest_series) builder, chapter_num, chapter_dir, dest_series)
chapter_results.append(result) chapter_results.append(result)
status = "ok" if result["ok"] else f"ERROR: {result.get('error')}" status = "ok" if result["ok"] else f"ERROR: {result.get('error')}"
print(f" Chapter {chapter_num}: {status}") 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. # Sync Kavita persons once per series.
# Both MAL and AniList IDs come from MangaBaka's source map; # Both MAL and AniList IDs come from MangaBaka's source map;
@@ -557,11 +639,19 @@ class SuwayomiMover:
try: try:
builder.chapter = chapter_num builder.chapter = chapter_num
builder.add_pages_from_folder(chapter_dir, cover_filename="000") 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) builder.save_xml(chapter_dir)
_pack_to_cbz(chapter_dir, cbz_path) _pack_to_cbz(chapter_dir, cbz_path)
if self._delete_source: if self._delete_source:
shutil.rmtree(chapter_dir) 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: except Exception as exc:
return {"chapter": chapter_num, "cbz": str(cbz_path), return {"chapter": chapter_num, "cbz": str(cbz_path),
"ok": False, "error": str(exc)} "ok": False, "error": str(exc)}