From 6d22194fb019ef623e97908979222f94f198f459 Mon Sep 17 00:00:00 2001 From: Mattias Svensson Date: Wed, 1 Jul 2026 19:48:58 +0200 Subject: [PATCH] Embed book metadata into epub output (self-describing files) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grawlix wrote no metadata into the epub for EpubInParts sources (e.g. Nextory): the ebooklib rebuild set no title/author/series, so the OPF held only a UUID. SingleFile sources (e.g. Storytel) keep the publisher OPF, which has title and author but no series. Either way a library manager can only fall back to the filename. Add Epub._embed_metadata(location, metadata): after the file is written, ADD any missing dc:title/creator/language/publisher/date + calibre:series/series_index from book.metadata to the OPF. It is add-only (never removes or rewrites existing OPF elements, so publisher metadata, EPUB3 refines and comments stay intact), respects the OPF's declared encoding, reads entries by ZipInfo, and swallows all errors so it can never turn a successful download into a failure. Supporting changes: - Populate the Nextory Metadata with index (volume), language, publisher and release_date so those fields exist to embed and to fill output templates. - str()-coerce metadata values when building the output path, so a numeric series index does not crash remove_unwanted_chars. - Remove the stray exit() at the end of _download_epub_in_parts (also fixed in #27) — a prerequisite for the embed step to run for EpubInParts sources. Co-Authored-By: Claude Opus 4.8 (1M context) --- grawlix/output/__init__.py | 2 +- grawlix/output/epub.py | 91 +++++++++++++++++++++++++++++++++++++- grawlix/sources/nextory.py | 42 ++++++++++++++++++ 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/grawlix/output/__init__.py b/grawlix/output/__init__.py index 234fc61..7081e1c 100644 --- a/grawlix/output/__init__.py +++ b/grawlix/output/__init__.py @@ -44,7 +44,7 @@ def format_output_location(book: Book, output_format: OutputFormat, template: st :param template: Template for output path :returns: Output path """ - values = { key: remove_unwanted_chars(value) for key, value in book.metadata.as_dict().items() } + values = { key: remove_unwanted_chars(str(value)) for key, value in book.metadata.as_dict().items() } path = template.format(**values, ext = output_format.extension) return path diff --git a/grawlix/output/epub.py b/grawlix/output/epub.py index a73a23e..b19d008 100644 --- a/grawlix/output/epub.py +++ b/grawlix/output/epub.py @@ -4,9 +4,11 @@ from .output_format import OutputFormat, Update import asyncio from bs4 import BeautifulSoup +import html as _html import os +import re from ebooklib import epub -from zipfile import ZipFile +from zipfile import ZipFile, ZipInfo, ZIP_STORED, ZIP_DEFLATED import rich class Epub(OutputFormat): @@ -23,6 +25,92 @@ class Epub(OutputFormat): await self._download_epub_in_parts(book.data, book.metadata, location, update) else: raise UnsupportedOutputFormat + # Make the file self-describing regardless of source (Nextory rebuilds + # the epub via ebooklib and would otherwise embed no title/author/series; + # Storytel keeps the publisher epub which lacks series info). + self._embed_metadata(location, book.metadata) + + + @staticmethod + def _embed_metadata(location: str, metadata: Metadata) -> None: + """ + Best-effort: add missing identity/series metadata to the epub's OPF so + the file is self-describing regardless of source. Only fields that are + ABSENT are added — existing OPF content is never removed or rewritten, + which keeps publisher metadata, EPUB3 ``refines``, comments and foreign + namespaces intact. Any failure is swallowed: embedding is an enhancement + and must never break an otherwise successful download. + """ + try: + with ZipFile(location) as zf: + names = set(zf.namelist()) + if "META-INF/container.xml" not in names: + return + container = zf.read("META-INF/container.xml").decode("utf-8") + m = re.search(r"full-path\s*=\s*['\"]([^'\"]+)['\"]", container) + if not m or m.group(1) not in names: + return + opf_name = m.group(1) + raw = zf.read(opf_name) + + # Respect the OPF's declared encoding; bail if we cannot round-trip it. + encoding = "utf-8" + decl = re.match(rb"<\?xml[^>]*?encoding=['\"]([\w.-]+)['\"]", raw) + if decl: + encoding = decl.group(1).decode("ascii", "ignore") or "utf-8" + try: + opf = raw.decode(encoding) + except (LookupError, UnicodeDecodeError): + return + + close = re.search(r"", opf) + if not close: + return + + def esc(value: object) -> str: + return _html.escape(str(value), quote=True) + + def missing(pattern: str) -> bool: + return re.search(pattern, opf) is None + + inject: list[str] = [] + if metadata.title and missing(r"]"): + inject.append(f"{esc(metadata.title)}") + if metadata.authors and missing(r"]"): + inject += [f"{esc(a)}" for a in metadata.authors] + if metadata.language and missing(r"]"): + inject.append(f"{esc(metadata.language)}") + if metadata.publisher and missing(r"]"): + inject.append(f"{esc(metadata.publisher)}") + if metadata.release_date and missing(r"]"): + inject.append(f"{esc(metadata.release_date)}") + if metadata.series and missing(r"""(?:name=['"]calibre:series['"]|belongs-to-collection)"""): + inject.append(f'') + if metadata.index is not None: + inject.append(f'') + if not inject: + return + + opf = opf[:close.start()] + "".join(inject) + opf[close.start():] + new_opf = opf.encode(encoding) + + tmp = f"{location}.tmp" + try: + with ZipFile(location) as zin, ZipFile(tmp, "w") as zout: + for item in zin.infolist(): # preserves entry order (mimetype first) + data = new_opf if item.filename == opf_name else zin.read(item) + compress = ZIP_STORED if item.filename == "mimetype" else ZIP_DEFLATED + info = ZipInfo(item.filename, date_time=item.date_time) + info.compress_type = compress + info.external_attr = item.external_attr + zout.writestr(info, data) + os.replace(tmp, location) + finally: + if os.path.exists(tmp): + os.remove(tmp) + except Exception: + # Never let metadata embedding break a successful download. + return async def _download_html_files(self, html: HtmlFiles, metadata: Metadata, location: str, update: Update) -> None: @@ -135,4 +223,3 @@ class Epub(OutputFormat): output.add_item(epub.EpubNcx()) output.add_item(epub.EpubNav()) epub.write_epub(location, output) - exit() diff --git a/grawlix/sources/nextory.py b/grawlix/sources/nextory.py index 4c88f28..6b39f6b 100644 --- a/grawlix/sources/nextory.py +++ b/grawlix/sources/nextory.py @@ -4,6 +4,7 @@ from grawlix.exceptions import InvalidUrl from .source import Source from typing import Optional +from datetime import date import uuid import rich import base64 @@ -168,16 +169,57 @@ class Nextory(Source): product_data = product_data.json() epub_id = self._find_epub_id(product_data) pages = await self._get_pages(epub_id) + epub_format = self._find_epub_format(product_data) return Book( data = pages, metadata = Metadata( title = product_data["title"], authors = [author["name"] for author in product_data["authors"]], series = self._extract_series_name(product_data), + index = self._to_int(product_data.get("volume")), + language = product_data.get("language"), + publisher = self._extract_publisher(epub_format), + release_date = self._parse_date(epub_format.get("publication_date") if epub_format else None), ) ) + @staticmethod + def _find_epub_format(product_data: dict) -> Optional[dict]: + """Return the epub format entry for a product, if any""" + for format in product_data.get("formats", []): + if format.get("type") == "epub": + return format + return None + + + @staticmethod + def _parse_date(value: Optional[str]) -> Optional[date]: + """Parse a Nextory ``YYYY-MM-DD`` publication date, ignoring bad values""" + if not value: + return None + try: + return date.fromisoformat(value[:10]) + except (TypeError, ValueError): + return None + + + @staticmethod + def _to_int(value) -> Optional[int]: + """Coerce a series index to int, tolerating strings/floats/None""" + try: + return int(value) + except (TypeError, ValueError): + return None + + + @staticmethod + def _extract_publisher(epub_format: Optional[dict]) -> Optional[str]: + """Publisher name from an epub format entry, guarding non-dict shapes""" + publisher = (epub_format or {}).get("publisher") + return publisher.get("name") if isinstance(publisher, dict) else None + + @staticmethod def _fix_key(value: str) -> bytes: """Remove unused data and decode key"""