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"\s*metadata\s*>", 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"""