Files
UmbraAudio/umbraaudio/audiobook_meta.py
2026-10-18 19:01:04 -07:00

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,
}