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