361 lines
14 KiB
Python
361 lines
14 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Audiobook (m4b) metadata capture & validation.
|
|
|
|
Stores the publishing metadata that gets embedded into an .m4b file (title,
|
|
author, narrator, cover, synopsis, series, ISBN, etc.) as JSON under the
|
|
active project's data dir (``<project_dir>/.umbra/m4b_meta.json``), and
|
|
computes whether the required fields are complete enough to publish.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
META_FILE = "m4b_meta.json"
|
|
|
|
# field -> human label. ``required`` marks the fields needed before an m4b
|
|
# can be produced/embedded meaningfully; the rest are optional enrichments.
|
|
META_FIELDS: list[dict[str, Any]] = [
|
|
{"key": "title", "label": "Title", "required": True, "type": "text"},
|
|
{"key": "subtitle", "label": "Subtitle", "required": False, "type": "text"},
|
|
{"key": "author", "label": "Author", "required": True, "type": "text"},
|
|
{"key": "narrator", "label": "Narrator (read by)", "required": True, "type": "text"},
|
|
{"key": "synopsis", "label": "Synopsis / Description", "required": True, "type": "textarea"},
|
|
{"key": "language", "label": "Language", "required": False, "type": "text"},
|
|
{"key": "publisher", "label": "Publisher", "required": False, "type": "text"},
|
|
{"key": "copyright_holder", "label": "Copyright holder", "required": False, "type": "text"},
|
|
{"key": "copyright_year", "label": "Publication / copyright year", "required": False, "type": "text"},
|
|
{"key": "release_date", "label": "Release date (YYYY-MM-DD)", "required": False, "type": "text"},
|
|
{"key": "series", "label": "Series name", "required": False, "type": "text"},
|
|
{"key": "series_number", "label": "Series number / book position", "required": False, "type": "text"},
|
|
{"key": "genre", "label": "Genre(s) (comma-separated)", "required": False, "type": "text"},
|
|
{"key": "keywords", "label": "Keywords / tags (comma-separated)", "required": False, "type": "text"},
|
|
{"key": "isbn", "label": "ISBN / ASIN", "required": False, "type": "text"},
|
|
{"key": "explicit", "label": "Explicit / adult rating", "required": False, "type": "select"},
|
|
{"key": "cover_path", "label": "Cover image path (.jpg/.png)", "required": True, "type": "text"},
|
|
{"key": "output_name", "label": "Output filename (e.g. book.m4b)", "required": True, "type": "text"},
|
|
]
|
|
|
|
|
|
def meta_path(data_dir: Path) -> Path:
|
|
return data_dir / META_FILE
|
|
|
|
|
|
def default_meta() -> dict[str, str]:
|
|
return {f["key"]: "" for f in META_FIELDS}
|
|
|
|
|
|
def load_meta(data_dir: Path) -> dict[str, str]:
|
|
"""Load saved metadata, filling any missing keys with empty strings."""
|
|
meta = default_meta()
|
|
path = meta_path(data_dir)
|
|
if path.exists():
|
|
try:
|
|
saved = json.loads(path.read_text())
|
|
if isinstance(saved, dict):
|
|
for k in saved:
|
|
if k in meta:
|
|
meta[k] = str(saved[k])
|
|
except Exception as e:
|
|
logger.warning("Failed to read %s: %s", path, e)
|
|
return meta
|
|
|
|
|
|
def save_meta(data_dir: Path, values: dict[str, Any] | None = None) -> Path:
|
|
meta = default_meta()
|
|
if values:
|
|
for k, v in values.items():
|
|
if k in meta:
|
|
meta[k] = str(v or "")
|
|
path = meta_path(data_dir)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(meta, indent=2))
|
|
logger.info("Saved m4b metadata -> %s", path)
|
|
return path
|
|
|
|
|
|
def completeness(meta: dict[str, str]) -> dict[str, Any]:
|
|
"""Return which required fields are filled and a readiness summary."""
|
|
missing = [f["key"] for f in META_FIELDS if f["required"] and not str(meta.get(f["key"], "")).strip()]
|
|
filled = [f["key"] for f in META_FIELDS if f["required"] and f["key"] not in missing]
|
|
return {
|
|
"ready": len(missing) == 0,
|
|
"missing": missing,
|
|
"filled": filled,
|
|
"required_total": sum(1 for f in META_FIELDS if f["required"]),
|
|
"required_filled": len(filled),
|
|
}
|
|
|
|
|
|
# --- EPUB publishing-metadata extraction ------------------------------------
|
|
# When a book is uploaded as an EPUB we can lift its package (OPF) metadata and
|
|
# cover directly, instead of asking the author to retype everything. Epubs do
|
|
# NOT carry narrator info, so that field (and any others the author already
|
|
# filled in) is left untouched.
|
|
|
|
|
|
def find_project_epub(data_dir: Path, source_path: str = "") -> Path | None:
|
|
"""Locate the project's stored EPUB upload, if any.
|
|
|
|
Prefers ``project.source_path`` (pristine copy) then falls back to the most
|
|
recent ``*.epub`` in ``<data_dir>/uploads``.
|
|
"""
|
|
candidates: list[Path] = []
|
|
if source_path and Path(source_path).suffix.lower() == ".epub":
|
|
candidates.append(Path(source_path))
|
|
upload_dir = data_dir / "uploads"
|
|
if upload_dir.is_dir():
|
|
candidates.extend(
|
|
sorted(
|
|
upload_dir.glob("*.epub"),
|
|
key=lambda p: p.stat().st_mtime if p.exists() else 0,
|
|
reverse=True,
|
|
)
|
|
)
|
|
for c in candidates:
|
|
try:
|
|
if c.is_file():
|
|
return c
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _collect_epub_metadata(book: Any) -> dict[str, list[tuple[str, dict[str, Any]]]]:
|
|
"""Normalize ``book.metadata`` ({uri: {tag: [(value, attrs), ...]}}) into a
|
|
flat {tag_tail: [(value, attrs), ...]} mapping, lowercasing tag tails and
|
|
collecting OPF ``<meta name=... content=...>`` pairs under the key ``_meta``.
|
|
"""
|
|
import ebooklib as _ebl
|
|
|
|
out: dict[str, list[tuple[str, dict[str, Any]]]] = {}
|
|
metas: dict[str, list[str]] = {}
|
|
|
|
for _uri, tag_map in book.metadata.items():
|
|
for tag, entries in (tag_map or {}).items():
|
|
tail = str(tag).split(":")[-1].strip().lower()
|
|
if not tail:
|
|
continue
|
|
if tail == "meta":
|
|
for e in entries or []:
|
|
attrs = (
|
|
e[1]
|
|
if isinstance(e, (list, tuple)) and len(e) > 1 and isinstance(e[1], dict)
|
|
else (dict(e) if isinstance(e, dict) else {})
|
|
)
|
|
name = str(attrs.get("name") or "").strip()
|
|
content = str(attrs.get("content") or "").strip()
|
|
if name and content:
|
|
metas.setdefault(name.replace("calibre:", "").lower(), []).append(content)
|
|
continue
|
|
bucket = out.setdefault(tail, [])
|
|
for e in entries or []:
|
|
if isinstance(e, dict):
|
|
v = e.get("content") or e.get("#text")
|
|
attrs = dict(e)
|
|
elif isinstance(e, (list, tuple)) and len(e) >= 1:
|
|
v = e[0]
|
|
attrs = e[1] if len(e) > 1 and isinstance(e[1], dict) else {}
|
|
else:
|
|
v = e
|
|
attrs = {}
|
|
if v is None:
|
|
continue
|
|
text = str(v).strip()
|
|
if text:
|
|
bucket.append((text, dict(attrs) if isinstance(attrs, dict) else {}))
|
|
if metas:
|
|
out["_meta"] = [(k, v) for k, v in metas.items()]
|
|
return out
|
|
|
|
|
|
def extract_epub_metadata(epub_path: Path) -> dict[str, Any]:
|
|
"""Read an EPUB's package metadata + cover.
|
|
|
|
Returns a dict keyed by metadata tag (``title``, ``creator``, ``publisher``,
|
|
``description``, ``subject``, ``identifier``, ``date``, ``language``,
|
|
``rights``, ...) mapping to lists of ``(value, attrs)`` tuples, plus a
|
|
``_meta`` key for OPF ``<meta>`` pairs (e.g. series info) and, when a cover
|
|
is present, ``_cover`` = ``(bytes, extension)``.
|
|
"""
|
|
from ebooklib import epub as _epub
|
|
|
|
book = _epub.read_epub(str(epub_path))
|
|
data = _collect_epub_metadata(book)
|
|
|
|
cover = _extract_epub_cover(book)
|
|
if cover is not None:
|
|
data["_cover"] = cover
|
|
data["_title_attr"] = [(str(getattr(book, "title", "") or "").strip(), {})]
|
|
return data
|
|
|
|
|
|
def _extract_epub_cover(book: Any) -> tuple[bytes, str] | None:
|
|
"""Return ``(bytes, ext)`` for the best candidate cover image, or None."""
|
|
import ebooklib as _ebl
|
|
|
|
item = None
|
|
for e in book.get_metadata("OPF", "cover") or []:
|
|
attrs = e[1] if isinstance(e, (list, tuple)) and len(e) > 1 and isinstance(e[1], dict) else {}
|
|
cover_id = str(attrs.get("content") or "").strip()
|
|
if cover_id:
|
|
try:
|
|
item = book.get_item_with_id(cover_id)
|
|
except Exception:
|
|
item = None
|
|
if item is not None:
|
|
break
|
|
if item is None:
|
|
for it in book.get_items_of_type(_ebl.ITEM_COVER):
|
|
item = it
|
|
break
|
|
if item is None:
|
|
for it in book.get_items():
|
|
if it.get_type() in (_ebl.ITEM_IMAGE, _ebl.ITEM_DOCUMENT) and "cover" in it.get_name().lower():
|
|
item = it
|
|
break
|
|
if item is None:
|
|
return None
|
|
content = item.get_content()
|
|
if not content:
|
|
return None
|
|
ext = Path(item.get_name()).suffix.lower() or ".jpg"
|
|
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
|
ext = ".jpg"
|
|
return content, ext
|
|
|
|
|
|
def _first(data: dict[str, list[tuple[str, Any]]], *tags: str) -> str:
|
|
for tag in tags:
|
|
vals = data.get(tag, [])
|
|
for v, _a in vals:
|
|
s = str(v).strip()
|
|
if s:
|
|
return s
|
|
return ""
|
|
|
|
|
|
def _join(data: dict[str, list[tuple[str, Any]]], tag: str) -> str:
|
|
return ", ".join(
|
|
dict.fromkeys(str(v).strip() for v, _a in data.get(tag, []) if str(v).strip())
|
|
)
|
|
|
|
|
|
def _meta_val(data: dict[str, list[tuple[str, Any]]], name: str) -> str:
|
|
for n, vals in data.get("_meta", []):
|
|
if n.replace("calibre:", "").lower() == name.lower() and vals:
|
|
return str(vals[0])
|
|
return ""
|
|
|
|
|
|
def epub_meta_to_fields(data: dict[str, Any]) -> dict[str, str]:
|
|
"""Map normalized EPUB metadata onto META_FIELDS keys (empty values only)."""
|
|
fields: dict[str, str] = {}
|
|
|
|
title = _first(data, "title", "_title_attr")
|
|
fields["title"] = title
|
|
fields["subtitle"] = _first(data, "subtitle")
|
|
fields["author"] = _join(data, "creator")
|
|
fields["narrator"] = ""
|
|
fields["synopsis"] = _first(data, "description", "abstract", "long_description")
|
|
fields["language"] = _first(data, "language")
|
|
fields["publisher"] = _first(data, "publisher")
|
|
|
|
rights = _first(data, "rights", "license")
|
|
fields["copyright_holder"] = rights if rights and not rights.lower().startswith("public") else ""
|
|
|
|
date_val = _first(data, "date", "created", "issued")
|
|
m = re.match(r"\s*(\d{4})-(\d{1,2})-(\d{1,2})", date_val)
|
|
if m:
|
|
fields["release_date"] = f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
|
|
ym = re.match(r"\s*(\d{4})", date_val)
|
|
if ym:
|
|
fields["copyright_year"] = ym.group(1)
|
|
|
|
fields["series"] = _first(data, "series") or _meta_val(data, "series")
|
|
series_num = _first(data, "series_pos", "series_index") or _meta_val(data, "series_index") or _meta_val(data, "series_pos")
|
|
if series_num:
|
|
fields["series_number"] = series_num
|
|
fields["genre"] = _join(data, "subject")
|
|
|
|
isbn = ""
|
|
for v, attrs in data.get("identifier", []):
|
|
scheme = " ".join(str(x) for x in attrs.get("scheme", [])) if isinstance(attrs, dict) else ""
|
|
if "isbn" in str(v).lower() or "isbn" in scheme.lower():
|
|
isbn = str(v).strip()
|
|
break
|
|
if not isbn:
|
|
for v, attrs in data.get("identifier", []):
|
|
scheme = " ".join(str(x) for x in attrs.get("scheme", [])) if isinstance(attrs, dict) else ""
|
|
text = str(v).strip()
|
|
if not text:
|
|
continue
|
|
if "uuid" in text.lower() or "uuid" in scheme.lower():
|
|
continue
|
|
isbn = text
|
|
break
|
|
if not isbn and data.get("identifier"):
|
|
isbn = str(data["identifier"][0][0]).strip()
|
|
fields["isbn"] = isbn
|
|
|
|
return fields
|
|
|
|
|
|
def merge_epub_metadata(data_dir: Path, epub_path: Path | None = None) -> dict[str, Any]:
|
|
"""Populate empty m4b-meta fields + cover from the project's EPUB.
|
|
|
|
Never overwrites values the user has already entered. Returns a summary with
|
|
``filled`` (field -> value) and the stored cover path (relative to the
|
|
project dir, matching how the cover-upload endpoint persists it).
|
|
"""
|
|
cover = None
|
|
if epub_path is None:
|
|
epub_path = find_project_epub(data_dir)
|
|
if epub_path is None:
|
|
return {"ok": False, "error": "No uploaded EPUB found for this project."}
|
|
|
|
data = extract_epub_metadata(epub_path)
|
|
fields = epub_meta_to_fields(data)
|
|
meta = load_meta(data_dir)
|
|
|
|
filled: dict[str, str] = {}
|
|
for key, value in fields.items():
|
|
if key in meta and value and not str(meta.get(key, "")).strip():
|
|
meta[key] = value
|
|
filled[key] = value
|
|
|
|
if not str(meta.get("output_name", "")).strip() and (title := fields.get("title")):
|
|
safe = re.sub(r"[^\w\s.-]", "", title).strip()
|
|
meta["output_name"] = (safe or "audiobook") + ".m4b"
|
|
filled["output_name"] = meta["output_name"]
|
|
|
|
cover = data.get("_cover")
|
|
stored_cover_path = ""
|
|
if cover and not str(meta.get("cover_path") or "").strip():
|
|
ext = cover[1] if isinstance(cover, tuple) and cover[1] else ".jpg"
|
|
assets = data_dir / "m4b_assets"
|
|
assets.mkdir(parents=True, exist_ok=True)
|
|
dest = assets / f"cover{ext}"
|
|
dest.write_bytes(cover[0])
|
|
rel = dest.relative_to(data_dir.parent).as_posix()
|
|
meta["cover_path"] = rel
|
|
filled["cover_path"] = rel
|
|
stored_cover_path = str(dest)
|
|
|
|
if filled:
|
|
save_meta(data_dir, meta)
|
|
return {
|
|
"ok": True,
|
|
"epub": str(epub_path),
|
|
"filled": filled,
|
|
"cover": stored_cover_path,
|
|
"source": data_dir,
|
|
}
|