This commit is contained in:
s0len 2026-07-01 19:50:45 +02:00 committed by GitHub
commit fee5e8ae1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 132 additions and 3 deletions

View File

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

View File

@ -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"<dc:title[\s/>]"):
inject.append(f"<dc:title>{esc(metadata.title)}</dc:title>")
if metadata.authors and missing(r"<dc:creator[\s/>]"):
inject += [f"<dc:creator>{esc(a)}</dc:creator>" for a in metadata.authors]
if metadata.language and missing(r"<dc:language[\s/>]"):
inject.append(f"<dc:language>{esc(metadata.language)}</dc:language>")
if metadata.publisher and missing(r"<dc:publisher[\s/>]"):
inject.append(f"<dc:publisher>{esc(metadata.publisher)}</dc:publisher>")
if metadata.release_date and missing(r"<dc:date[\s/>]"):
inject.append(f"<dc:date>{esc(metadata.release_date)}</dc:date>")
if metadata.series and missing(r"""(?:name=['"]calibre:series['"]|belongs-to-collection)"""):
inject.append(f'<meta name="calibre:series" content="{esc(metadata.series)}"/>')
if metadata.index is not None:
inject.append(f'<meta name="calibre:series_index" content="{esc(metadata.index)}"/>')
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()

View File

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