init
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
"""
|
||||
comicinfo_builder.py
|
||||
====================
|
||||
|
||||
Generates a ComicInfo.xml (compatible with Kavita v0.9.0.2 / ComicInfo v2.1)
|
||||
from series metadata provided by the MangaBaka API, enriched with data from
|
||||
MangaDex (volume mapping), MangaBaka works (volume covers / ISBN / dates),
|
||||
and MyAnimeList / Jikan (statistics and characters).
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
requests (required -> API calls / cover download)
|
||||
Pillow (PIL) (optional -> image dimensions for <Page> entries)
|
||||
|
||||
pip install requests pillow
|
||||
|
||||
The modules MangadexVolumeResolver, MangaBakaWorksResolver and
|
||||
MALResolver must reside in the same directory.
|
||||
|
||||
API address note
|
||||
----------------
|
||||
The official MangaBaka API is hosted at https://api.mangabaka.dev/v1
|
||||
(domain ".dev", not ".org"). Use the `api_base_url` constructor parameter
|
||||
to override this if needed.
|
||||
|
||||
Data source notes
|
||||
-----------------
|
||||
* Volume assignment per chapter is resolved via MangaDex
|
||||
(MangaDexVolumeResolver). Chapters missing from MangaDex are estimated
|
||||
from neighbouring volume boundaries and MangaBaka page-count data.
|
||||
* Volume-specific covers, ISBNs and publication dates come from MangaBaka
|
||||
works (MangaBakaWorksResolver). If no volume is assigned the series
|
||||
cover is used instead.
|
||||
* MAL statistics and character names are fetched via the Jikan API
|
||||
(MALResolver).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from MangadexVolumeResolver import MangaDexVolumeResolver
|
||||
from MangaBakaWorksResolver import MangaBakaWorksResolver
|
||||
from MALResolver import MALResolver
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
_HAS_PIL = True
|
||||
except ImportError:
|
||||
_HAS_PIL = False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Constants
|
||||
# --------------------------------------------------------------------------
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"}
|
||||
|
||||
_AGE_RATING_MAP = {
|
||||
"safe": "Everyone",
|
||||
"suggestive": "Teen",
|
||||
"erotica": "Mature 17+",
|
||||
"pornographic": "Adults Only 18+",
|
||||
}
|
||||
|
||||
_TRACKER_URL_TEMPLATES = {
|
||||
"anilist": "https://anilist.co/manga/{id}",
|
||||
"myanimelist": "https://myanimelist.net/manga/{id}",
|
||||
"mal": "https://myanimelist.net/manga/{id}",
|
||||
"mangaupdates": "https://www.mangaupdates.com/series.html?id={id}",
|
||||
"mangadex": "https://mangadex.org/title/{id}",
|
||||
"kitsu": "https://kitsu.app/manga/{id}",
|
||||
"animenewsnetwork": "https://www.animenewsnetwork.com/encyclopedia/manga.php?id={id}",
|
||||
"ann": "https://www.animenewsnetwork.com/encyclopedia/manga.php?id={id}",
|
||||
}
|
||||
|
||||
# MangaDex relationship types that indicate child works (spin-offs, sequels …)
|
||||
_CHILD_RELATION_TYPES = {"side_story", "spin_off", "sequel", "prequel",
|
||||
"doujinshi", "adapted_from", "alternative_story",
|
||||
"alternative_version"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Module helpers
|
||||
# --------------------------------------------------------------------------
|
||||
def _natural_key(name: str):
|
||||
return [int(p) if p.isdigit() else p.lower()
|
||||
for p in re.split(r"(\d+)", name)]
|
||||
|
||||
|
||||
def _normalise_key(key) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", str(key).lower())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Main class
|
||||
# --------------------------------------------------------------------------
|
||||
class ComicInfoBuilder:
|
||||
"""
|
||||
Builds a ComicInfo.xml for a single manga chapter.
|
||||
|
||||
Constructor arguments
|
||||
---------------------
|
||||
manga_title : Title of the manga (used for the API search).
|
||||
chapter : Chapter number (int, float, or str — e.g. "10.5").
|
||||
|
||||
Setter behaviour
|
||||
----------------
|
||||
* Changing `manga_title` discards both the cached API metadata
|
||||
AND the current results (pages / cover).
|
||||
* Changing `chapter` discards only the current results;
|
||||
the API metadata is kept.
|
||||
"""
|
||||
|
||||
def __init__(self, manga_title, chapter, *,
|
||||
api_base_url: str = "https://api.mangabaka.dev/v1",
|
||||
language: str = "en",
|
||||
request_timeout: int = 30,
|
||||
session: "requests.Session | None" = None,
|
||||
volume_resolver: "MangaDexVolumeResolver | None" = None,
|
||||
works_resolver: "MangaBakaWorksResolver | None" = None,
|
||||
mal_resolver: "MALResolver | None" = None):
|
||||
if not manga_title or not str(manga_title).strip():
|
||||
raise ValueError("manga_title must not be empty.")
|
||||
|
||||
self._manga_title = str(manga_title).strip()
|
||||
self._chapter = chapter
|
||||
|
||||
self.api_base_url = api_base_url.rstrip("/")
|
||||
self.language = language
|
||||
self.request_timeout = request_timeout
|
||||
self._session = session or requests.Session()
|
||||
self._session.headers.setdefault("User-Agent", "ComicInfoBuilder/1.0")
|
||||
|
||||
self._volume_resolver = (volume_resolver
|
||||
or MangaDexVolumeResolver(
|
||||
request_timeout=request_timeout,
|
||||
session=self._session))
|
||||
self._works_resolver = (works_resolver
|
||||
or MangaBakaWorksResolver(
|
||||
api_base_url=api_base_url,
|
||||
request_timeout=request_timeout,
|
||||
session=self._session))
|
||||
self._mal_resolver = (mal_resolver
|
||||
or MALResolver(
|
||||
request_timeout=request_timeout,
|
||||
session=self._session))
|
||||
|
||||
self._metadata: "dict | None" = None
|
||||
self._pages: list[dict] = []
|
||||
self._cover_path: "Path | None" = None
|
||||
self._suwayomi_data: dict = {}
|
||||
|
||||
# ----- Repr -----------------------------------------------------------
|
||||
def __repr__(self) -> str:
|
||||
return (f"ComicInfoBuilder(manga_title={self._manga_title!r}, "
|
||||
f"chapter={self._chapter!r})")
|
||||
|
||||
# ======================================================================
|
||||
# Properties / setters
|
||||
# ======================================================================
|
||||
@property
|
||||
def manga_title(self) -> str:
|
||||
return self._manga_title
|
||||
|
||||
@manga_title.setter
|
||||
def manga_title(self, value):
|
||||
value = str(value).strip()
|
||||
if not value:
|
||||
raise ValueError("manga_title must not be empty.")
|
||||
if value == self._manga_title:
|
||||
return
|
||||
self._manga_title = value
|
||||
self._metadata = None
|
||||
self._clear_results()
|
||||
|
||||
@property
|
||||
def chapter(self):
|
||||
return self._chapter
|
||||
|
||||
@chapter.setter
|
||||
def chapter(self, value):
|
||||
if value == self._chapter:
|
||||
return
|
||||
self._chapter = value
|
||||
self._clear_results()
|
||||
|
||||
def _clear_results(self) -> None:
|
||||
self._pages = []
|
||||
self._cover_path = None
|
||||
self._suwayomi_data = {}
|
||||
|
||||
# ======================================================================
|
||||
# Public XML functions
|
||||
# ======================================================================
|
||||
def to_xml_string(self, *, pretty: bool = True) -> str:
|
||||
"""Returns the ComicInfo.xml as a string."""
|
||||
tree = self._build_tree()
|
||||
if pretty:
|
||||
try:
|
||||
ET.indent(tree, space=" ")
|
||||
except AttributeError:
|
||||
pass
|
||||
body = ET.tostring(tree.getroot(), encoding="unicode")
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + body
|
||||
|
||||
def save_xml(self, path) -> Path:
|
||||
"""
|
||||
Writes the ComicInfo.xml to `path`.
|
||||
If a directory is passed, ComicInfo.xml is created inside it.
|
||||
Returns the actual file path used.
|
||||
"""
|
||||
path = Path(path)
|
||||
if path.is_dir():
|
||||
path = path / "ComicInfo.xml"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(self.to_xml_string(), encoding="utf-8")
|
||||
return path
|
||||
|
||||
# ======================================================================
|
||||
# Optional: analyse an image folder
|
||||
# ======================================================================
|
||||
def add_pages_from_folder(self, folder, *,
|
||||
download_cover: bool = True,
|
||||
cover_filename: str = "cover") -> dict:
|
||||
"""
|
||||
Scans a chapter image folder and populates <Page> entries.
|
||||
Reads an existing Suwayomi ComicInfo.xml for supplementary fields.
|
||||
Downloads the cover (volume-specific if a volume is found, otherwise
|
||||
the series default cover).
|
||||
"""
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
raise NotADirectoryError(f"Folder not found: {folder}")
|
||||
|
||||
self._suwayomi_data = self._read_existing_comicinfo(folder)
|
||||
|
||||
self._cover_path = None
|
||||
if download_cover:
|
||||
self._cover_path = self._download_cover(folder, cover_filename)
|
||||
|
||||
cover_resolved = self._cover_path.resolve() if self._cover_path else None
|
||||
story_images: list[Path] = []
|
||||
for entry in folder.iterdir():
|
||||
if not entry.is_file():
|
||||
continue
|
||||
if entry.suffix.lower() not in _IMAGE_EXTS:
|
||||
continue
|
||||
if cover_resolved and entry.resolve() == cover_resolved:
|
||||
continue
|
||||
story_images.append(entry)
|
||||
story_images.sort(key=lambda p: _natural_key(p.name))
|
||||
|
||||
ordered: list[tuple[Path, str]] = []
|
||||
if self._cover_path:
|
||||
ordered.append((self._cover_path, "FrontCover"))
|
||||
ordered.extend((img, "Story") for img in story_images)
|
||||
|
||||
self._pages = []
|
||||
for index, (img_path, page_type) in enumerate(ordered):
|
||||
width, height = self._image_dimensions(img_path)
|
||||
try:
|
||||
size = img_path.stat().st_size
|
||||
except OSError:
|
||||
size = None
|
||||
self._pages.append({
|
||||
"image": index,
|
||||
"type": page_type,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"size": size,
|
||||
"double": bool(width and height and width > height),
|
||||
})
|
||||
|
||||
return {
|
||||
"page_count": len(self._pages),
|
||||
"cover": str(self._cover_path) if self._cover_path else None,
|
||||
"suwayomi_fields": dict(self._suwayomi_data),
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# Metadata retrieval (MangaBaka API)
|
||||
# ======================================================================
|
||||
def fetch_metadata(self, *, force: bool = False) -> dict:
|
||||
"""Fetches (and caches) the series metadata. Pass force=True to refresh."""
|
||||
return self._get_metadata(force=force)
|
||||
|
||||
def _get_metadata(self, *, force: bool = False) -> dict:
|
||||
if self._metadata is not None and not force:
|
||||
return self._metadata
|
||||
|
||||
series = self._search_best_series(self._manga_title)
|
||||
if series is None:
|
||||
raise RuntimeError(
|
||||
f"No series found for '{self._manga_title}' on MangaBaka.")
|
||||
|
||||
if series.get("state") == "merged" and series.get("merged_with"):
|
||||
series = self._fetch_series_by_id(series["merged_with"])
|
||||
|
||||
self._metadata = series
|
||||
return series
|
||||
|
||||
def _search_best_series(self, title: str):
|
||||
"""Searches for `title` and returns the best matching series entry."""
|
||||
url = f"{self.api_base_url}/series/search"
|
||||
resp = self._session.get(
|
||||
url, params={"q": title, "page": 1, "limit": 1},
|
||||
timeout=self.request_timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data") or []
|
||||
|
||||
return data[0] # I trust the API's relevance sorting and just take the first result, if any
|
||||
|
||||
def _fetch_series_by_id(self, series_id) -> dict:
|
||||
url = f"{self.api_base_url}/series/{series_id}"
|
||||
resp = self._session.get(url, timeout=self.request_timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data")
|
||||
if not data:
|
||||
raise RuntimeError(f"Series with ID {series_id} not found.")
|
||||
return data
|
||||
|
||||
# ======================================================================
|
||||
# XML construction
|
||||
# ======================================================================
|
||||
def _build_tree(self) -> "ET.ElementTree":
|
||||
md = self._get_metadata()
|
||||
sd = self._suwayomi_data
|
||||
|
||||
volume = self._determine_volume()
|
||||
work = self._get_work_for_volume(md, volume) if volume else None
|
||||
|
||||
root = ET.Element("ComicInfo", {
|
||||
"xmlns:xsd": "http://www.w3.org/2001/XMLSchema",
|
||||
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
||||
})
|
||||
|
||||
def add(tag: str, value) -> None:
|
||||
if value is None:
|
||||
return
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
ET.SubElement(root, tag).text = text
|
||||
|
||||
# ----- Title / Series -----------------------------------------------
|
||||
add("Title", sd.get("Title") or f"Chapter {self._chapter}")
|
||||
add("Series", md.get("title") or self._manga_title)
|
||||
add("LocalizedSeries",
|
||||
md.get("native_title") or md.get("romanized_title"))
|
||||
add("SeriesSort", self._get_sort_title(md))
|
||||
add("Number", sd.get("Number") or self._chapter)
|
||||
add("Count", md.get("total_chapters"))
|
||||
add("Volume", volume)
|
||||
|
||||
# ----- Description with MAL stats -----------------------------------
|
||||
mal_id = self._mal_resolver.find_mal_id(
|
||||
md.get("title") or self._manga_title)
|
||||
mal_stats = self._mal_resolver.get_stats(mal_id)
|
||||
add("Summary", self._build_summary(md, sd, mal_stats))
|
||||
|
||||
# ----- Release date -------------------------------------------------
|
||||
# Volume publication date takes precedence over the chapter date.
|
||||
vol_year, vol_month, vol_day = self._parse_work_date(work)
|
||||
add("Year", vol_year or sd.get("Year") or md.get("year"))
|
||||
add("Month", vol_month or sd.get("Month"))
|
||||
add("Day", vol_day or sd.get("Day"))
|
||||
|
||||
# ----- Contributors -------------------------------------------------
|
||||
add("Writer", ", ".join(md.get("authors") or []))
|
||||
add("Penciller", ", ".join(md.get("artists") or []))
|
||||
add("Translator", sd.get("Translator"))
|
||||
|
||||
# ----- Publisher ----------------------------------------------------
|
||||
eng_pub = self._publishers_by_type(md, "English")
|
||||
orig_pub = self._publishers_by_type(md, "Original")
|
||||
add("Publisher", eng_pub or orig_pub)
|
||||
if eng_pub and orig_pub:
|
||||
add("Imprint", orig_pub)
|
||||
|
||||
# ----- Genres / Tags ------------------------------------------------
|
||||
add("Genre", ", ".join(md.get("genres") or []))
|
||||
add("Tags", ", ".join(md.get("tags") or []))
|
||||
|
||||
# ----- Characters from MAL ------------------------------------------
|
||||
characters = self._mal_resolver.get_characters(mal_id)
|
||||
add("Characters", ", ".join(characters) if characters else None)
|
||||
|
||||
# ----- Web links ----------------------------------------------------
|
||||
add("Web", " ".join(self._collect_web_links(md, sd)))
|
||||
|
||||
# ----- Miscellaneous ------------------------------------------------
|
||||
add("LanguageISO", self.language)
|
||||
add("Manga", self._manga_flag(md))
|
||||
add("AgeRating", _AGE_RATING_MAP.get(md.get("content_rating"), "Unknown"))
|
||||
|
||||
if md.get("rating"):
|
||||
try:
|
||||
add("CommunityRating", round(float(md["rating"]) / 2, 1))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# ----- ISBN (GTIN) from volume work ---------------------------------
|
||||
isbn = (work or {}).get('identifiers')[0].get("id")
|
||||
add("GTIN", isbn)
|
||||
|
||||
# ----- SeriesGroup from related works -------------------------------
|
||||
add("SeriesGroup", self._determine_series_group(md))
|
||||
|
||||
# ----- Alternate title notes ----------------------------------------
|
||||
add("Notes", self._build_notes(md))
|
||||
|
||||
# ----- Pages --------------------------------------------------------
|
||||
if self._pages:
|
||||
add("PageCount", len(self._pages))
|
||||
pages_el = ET.SubElement(root, "Pages")
|
||||
for page in self._pages:
|
||||
attrs = {"Image": str(page["image"]), "Type": page["type"]}
|
||||
if page.get("size") is not None:
|
||||
attrs["ImageSize"] = str(page["size"])
|
||||
if page.get("width"):
|
||||
attrs["ImageWidth"] = str(page["width"])
|
||||
if page.get("height"):
|
||||
attrs["ImageHeight"] = str(page["height"])
|
||||
if page.get("double"):
|
||||
attrs["DoublePage"] = "true"
|
||||
ET.SubElement(pages_el, "Page", attrs)
|
||||
|
||||
return ET.ElementTree(root)
|
||||
|
||||
# ======================================================================
|
||||
# Volume determination
|
||||
# ======================================================================
|
||||
def _determine_volume(self) -> "str | None":
|
||||
"""
|
||||
Resolves the volume for the current chapter via MangaDex.
|
||||
Falls back to estimation when the chapter is absent from MangaDex.
|
||||
Returns None if no volume can be determined.
|
||||
"""
|
||||
md = self._get_metadata()
|
||||
try:
|
||||
manga_id = self._mangadex_id_from_source(md)
|
||||
if not manga_id:
|
||||
manga_id = self._volume_resolver.find_manga_id(
|
||||
md.get("native_title") or self._manga_title)
|
||||
if not manga_id:
|
||||
return None
|
||||
|
||||
series_id = str(md.get("id") or "")
|
||||
page_counts = {}
|
||||
if series_id:
|
||||
page_counts = self._works_resolver.get_page_counts(series_id)
|
||||
|
||||
return self._volume_resolver.volume_for_chapter(
|
||||
manga_id, self._chapter,
|
||||
volume_page_counts=page_counts or None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_work_for_volume(self, md: dict,
|
||||
volume: "str | None") -> "dict | None":
|
||||
"""Returns the MangaBaka work dict for the current volume, or None."""
|
||||
if not volume:
|
||||
return None
|
||||
series_id = str(md.get("id") or "")
|
||||
if not series_id:
|
||||
return None
|
||||
try:
|
||||
return self._works_resolver.get_work_for_volume(series_id, volume)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ======================================================================
|
||||
# Cover download
|
||||
# ======================================================================
|
||||
def _download_cover(self, folder: Path, cover_filename: str) -> "Path | None":
|
||||
"""
|
||||
Downloads the cover for the current chapter/volume.
|
||||
|
||||
If a volume is known and a volume-specific cover exists in MangaBaka
|
||||
works, that cover is used. Otherwise the series default cover is
|
||||
downloaded (raw variant preferred).
|
||||
"""
|
||||
md = self._get_metadata()
|
||||
volume = self._determine_volume()
|
||||
cover_url: "str | None" = None
|
||||
|
||||
if volume:
|
||||
series_id = str(md.get("id") or "")
|
||||
if series_id:
|
||||
try:
|
||||
cover_url = self._works_resolver.get_cover_for_volume(
|
||||
series_id, volume)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not cover_url:
|
||||
cover_url = _pick_cover_url(md.get("cover"))
|
||||
|
||||
if not cover_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = self._session.get(cover_url, timeout=self.request_timeout)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
ext = _guess_extension(cover_url, resp.headers.get("Content-Type", ""))
|
||||
target = folder / f"{cover_filename}{ext}"
|
||||
target.write_bytes(resp.content)
|
||||
return target
|
||||
|
||||
# ======================================================================
|
||||
# Series group
|
||||
# ======================================================================
|
||||
def _determine_series_group(self, md: dict) -> "str | None":
|
||||
"""
|
||||
Determines the SeriesGroup value from MangaDex relationships.
|
||||
|
||||
- If the series has a `main_story` parent -> use that title.
|
||||
- If the series itself has child works (spin-offs, sequels …)
|
||||
-> use the series own title so all related works are grouped.
|
||||
- Otherwise -> None (no SeriesGroup).
|
||||
"""
|
||||
manga_id = self._mangadex_id_from_source(md)
|
||||
if not manga_id:
|
||||
return None
|
||||
try:
|
||||
relations = self._volume_resolver.get_series_relations(manga_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not relations:
|
||||
return None
|
||||
|
||||
main_stories = relations.get("main_story") or []
|
||||
if main_stories:
|
||||
return main_stories[0]
|
||||
|
||||
if any(t in relations for t in _CHILD_RELATION_TYPES):
|
||||
return md.get("title") or self._manga_title
|
||||
|
||||
return None
|
||||
|
||||
# ======================================================================
|
||||
# Title helpers
|
||||
# ======================================================================
|
||||
def _get_sort_title(self, md: dict) -> "str | None":
|
||||
"""
|
||||
Returns the SeriesSort title in the configured language.
|
||||
Looks for an alt-title with matching language code first;
|
||||
falls back to the primary title.
|
||||
"""
|
||||
lang = self.language.lower()
|
||||
alt_titles = self._collect_alt_titles(md)
|
||||
if lang in alt_titles:
|
||||
return alt_titles[lang]
|
||||
# For 'en' the primary MangaBaka title is usually already English
|
||||
return md.get("title") or self._manga_title
|
||||
|
||||
def _collect_alt_titles(self, md: dict) -> "dict[str, str]":
|
||||
"""
|
||||
Returns {lang_code: title} for EN, DE and JP (kanji + romaji).
|
||||
Handles both list-of-dicts and plain-dict formats from the API.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
|
||||
if md.get("romanized_title"):
|
||||
result["romaji"] = md["romanized_title"]
|
||||
if md.get("native_title"):
|
||||
result["jp"] = md["native_title"]
|
||||
|
||||
alt = md.get("alt_titles") or md.get("titles") or []
|
||||
if isinstance(alt, list):
|
||||
for entry in alt:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
lang = entry.get("lang")
|
||||
title = entry.get("title")
|
||||
if not lang or not title:
|
||||
# Single-key format: {"en": "Call of the Night"}
|
||||
for k, v in entry.items():
|
||||
if isinstance(v, str) and len(k) <= 10:
|
||||
lang, title = k, v
|
||||
break
|
||||
if lang and title and lang.lower() in ("en", "de"):
|
||||
result[lang.lower()] = title
|
||||
elif isinstance(alt, dict):
|
||||
for lang, title in alt.items():
|
||||
if isinstance(title, str) and lang.lower() in ("en", "de"):
|
||||
result[lang.lower()] = title
|
||||
|
||||
if "en" not in result and md.get("title"):
|
||||
result["en"] = md["title"]
|
||||
|
||||
return result
|
||||
|
||||
# ======================================================================
|
||||
# Summary / notes
|
||||
# ======================================================================
|
||||
def _build_summary(self, md: dict, sd: dict,
|
||||
mal_stats: "dict | None") -> "str | None":
|
||||
"""
|
||||
Builds the <Summary> content.
|
||||
Appends a MAL statistics table (if available) after the description.
|
||||
"""
|
||||
desc = (md.get("description") or sd.get("Summary") or "").strip()
|
||||
|
||||
if not mal_stats:
|
||||
return desc or None
|
||||
|
||||
as_of = mal_stats.get("as_of", "")
|
||||
score = mal_stats.get("score")
|
||||
rank = mal_stats.get("rank")
|
||||
scored = mal_stats.get("scored_by")
|
||||
pop = mal_stats.get("popularity")
|
||||
members = mal_stats.get("members")
|
||||
favs = mal_stats.get("favorites")
|
||||
url = mal_stats.get("url", "")
|
||||
|
||||
rows: list[str] = []
|
||||
if score is not None: rows.append(f"Score\t{score}")
|
||||
if rank is not None: rows.append(f"Ranked\t#{rank}")
|
||||
if scored is not None: rows.append(f"Scored by\t{scored:,} users")
|
||||
if pop is not None: rows.append(f"Popularity\t#{pop}")
|
||||
if members is not None: rows.append(f"Members\t{members:,}")
|
||||
if favs is not None: rows.append(f"Favorites\t{favs:,}")
|
||||
|
||||
if not rows:
|
||||
return desc or None
|
||||
|
||||
table = f"[MyAnimeList]({url}) stats as of {as_of}:\n" + "\n".join(rows)
|
||||
return f"{desc}\n\n{table}" if desc else table
|
||||
|
||||
def _build_notes(self, md: dict) -> "str | None":
|
||||
"""
|
||||
Builds the <Notes> field containing alternate titles and the
|
||||
MangaBaka metadata source URL.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
alt = self._collect_alt_titles(md)
|
||||
if alt:
|
||||
label_map = {"en": "EN", "de": "DE",
|
||||
"romaji": "Romaji", "jp": "JP (kanji)"}
|
||||
lines = []
|
||||
for code in ("en", "de", "romaji", "jp"):
|
||||
if code in alt:
|
||||
lines.append(f"• {label_map[code]}: {alt[code]}")
|
||||
if lines:
|
||||
parts.append("Alternate titles:\n" + "\n".join(lines))
|
||||
|
||||
series_id = str(md.get("id") or "")
|
||||
if series_id:
|
||||
parts.append(f"Metadata source: https://mangabaka.org/{series_id}")
|
||||
|
||||
return "\n\n".join(parts) if parts else None
|
||||
|
||||
# ======================================================================
|
||||
# Static helpers
|
||||
# ======================================================================
|
||||
@staticmethod
|
||||
def _parse_work_date(work: "dict | None") -> tuple:
|
||||
"""Returns (year, month, day) strings from a MangaBaka work dict."""
|
||||
if not work:
|
||||
return (None, None, None)
|
||||
raw = (work.get("release_date") or work.get("publication_date") or "")
|
||||
if not raw:
|
||||
return (None, None, None)
|
||||
parts = str(raw).split("-")
|
||||
year = parts[0] if len(parts) > 0 and parts[0] else None
|
||||
month = parts[1] if len(parts) > 1 and parts[1] else None
|
||||
day = parts[2] if len(parts) > 2 and parts[2] else None
|
||||
return (year, month, day)
|
||||
|
||||
@staticmethod
|
||||
def _mangadex_id_from_source(md: dict) -> "str | None":
|
||||
for raw_key, info in (md.get("source") or {}).items():
|
||||
if _normalise_key(raw_key) in ("mangadex", "mangadexorg", "md"):
|
||||
if isinstance(info, dict) and info.get("id") is not None:
|
||||
return str(info["id"])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _publishers_by_type(md: dict, ptype: str) -> "str | None":
|
||||
names = [p.get("name") for p in (md.get("publishers") or [])
|
||||
if p.get("type") == ptype and p.get("name")]
|
||||
return ", ".join(names) if names else None
|
||||
|
||||
@staticmethod
|
||||
def _manga_flag(md: dict) -> str:
|
||||
mtype = (md.get("type") or "").lower()
|
||||
if mtype == "manga":
|
||||
return "YesAndRightToLeft"
|
||||
if mtype in ("manhwa", "manhua", "oel"):
|
||||
return "Yes"
|
||||
return "Unknown"
|
||||
|
||||
def _collect_web_links(self, md: dict, sd: dict) -> list[str]:
|
||||
links: list[str] = []
|
||||
|
||||
links.extend(l for l in (md.get("links") or []) if l)
|
||||
|
||||
for raw_key, info in (md.get("source") or {}).items():
|
||||
template = _TRACKER_URL_TEMPLATES.get(_normalise_key(raw_key))
|
||||
if not template or not isinstance(info, dict):
|
||||
continue
|
||||
source_id = info.get("id")
|
||||
if source_id is not None:
|
||||
links.append(template.format(id=source_id))
|
||||
|
||||
if sd.get("Web"):
|
||||
links.extend(str(sd["Web"]).split())
|
||||
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for link in links:
|
||||
if link not in seen:
|
||||
seen.add(link)
|
||||
unique.append(link)
|
||||
return unique
|
||||
|
||||
@staticmethod
|
||||
def _read_existing_comicinfo(folder: Path) -> dict:
|
||||
xml_path = folder / "ComicInfo.xml"
|
||||
if not xml_path.is_file():
|
||||
return {}
|
||||
try:
|
||||
root = ET.parse(xml_path).getroot()
|
||||
except ET.ParseError:
|
||||
return {}
|
||||
|
||||
wanted = {"Title", "Series", "Number", "Summary", "Writer",
|
||||
"Penciller", "Translator", "Genre", "Web",
|
||||
"Year", "Month", "Day"}
|
||||
data: dict = {}
|
||||
for child in root:
|
||||
tag = child.tag.split("}")[-1]
|
||||
if tag in wanted and child.text and child.text.strip():
|
||||
data[tag] = child.text.strip()
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _image_dimensions(path: Path):
|
||||
if not _HAS_PIL:
|
||||
return (None, None)
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
return im.size
|
||||
except Exception:
|
||||
return (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Module-level helpers (shared with MangaBakaWorksResolver logic)
|
||||
# --------------------------------------------------------------------------
|
||||
def _pick_cover_url(cover) -> "str | None":
|
||||
"""Selects the best cover URL (raw preferred) from a MangaBaka cover object."""
|
||||
if not cover:
|
||||
return None
|
||||
if isinstance(cover, str):
|
||||
return cover
|
||||
if isinstance(cover, dict):
|
||||
for key in ("raw", "default", "large", "medium", "small"):
|
||||
val = cover.get(key)
|
||||
if isinstance(val, str) and val:
|
||||
return val
|
||||
if isinstance(val, dict):
|
||||
for sub in ("x2", "x1"):
|
||||
if isinstance(val.get(sub), str) and val[sub]:
|
||||
return val[sub]
|
||||
for val in cover.values():
|
||||
if isinstance(val, str) and val.startswith("http"):
|
||||
return val
|
||||
if isinstance(val, dict):
|
||||
for sub in val.values():
|
||||
if isinstance(sub, str) and sub.startswith("http"):
|
||||
return sub
|
||||
return None
|
||||
|
||||
|
||||
def _guess_extension(url: str, content_type: str) -> str:
|
||||
url_ext = Path(url.split("?")[0]).suffix.lower()
|
||||
if url_ext in _IMAGE_EXTS:
|
||||
return url_ext
|
||||
ct = (content_type or "").lower()
|
||||
if "png" in ct: return ".png"
|
||||
if "webp" in ct: return ".webp"
|
||||
if "gif" in ct: return ".gif"
|
||||
return ".jpg"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Usage example
|
||||
# --------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
builder = ComicInfoBuilder("Yofukashi no Uta", 66)
|
||||
|
||||
builder.add_pages_from_folder(
|
||||
r"\\192.168.2.2\root\Temp\managdl\mangas\ComicK Fanmade (EN)"
|
||||
r"\Yofukashi no Uta\Official_Chapter 66")
|
||||
builder.save_xml(
|
||||
r"\\192.168.2.2\root\Temp\managdl\mangas\ComicK Fanmade (EN)"
|
||||
r"\Yofukashi no Uta\Official_Chapter 66\ComicInfo.xml")
|
||||
|
||||
# Setter behaviour:
|
||||
# builder.chapter = 2 # only results discarded, metadata is kept
|
||||
# builder.manga_title = "X" # metadata + results discarded
|
||||
Reference in New Issue
Block a user