From 655bc0aaf8cb626895c381db7d68df2b487a27ae Mon Sep 17 00:00:00 2001 From: "B.D. Murphy" <161863146+bdmurphy73@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:53:37 -0700 Subject: [PATCH] Add m4b Meta tab for audiobook publishing metadata - New /m4b-meta page with input fields for all required + optional audiobook metadata (title, author, narrator, synopsis, cover, series, ISBN, genre, etc.) - Persist metadata to /.umbra/m4b_meta.json - Required-field completeness check with ready-to-publish indicator - Cover image upload endpoint + asset serving - GET/POST JSON API for metadata --- umbraaudio/audiobook_meta.py | 90 +++++++++++++++++ umbraaudio/web/routes.py | 92 +++++++++++++++++ umbraaudio/web/templates/base.html | 1 + umbraaudio/web/templates/m4b_meta.html | 130 +++++++++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 umbraaudio/audiobook_meta.py create mode 100644 umbraaudio/web/templates/m4b_meta.html diff --git a/umbraaudio/audiobook_meta.py b/umbraaudio/audiobook_meta.py new file mode 100644 index 0000000..9f027d0 --- /dev/null +++ b/umbraaudio/audiobook_meta.py @@ -0,0 +1,90 @@ +"""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 (``/.umbra/m4b_meta.json``), and +computes whether the required fields are complete enough to publish. +""" +from __future__ import annotations + +import json +import logging +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), + } diff --git a/umbraaudio/web/routes.py b/umbraaudio/web/routes.py index 467b09a..c7ac047 100644 --- a/umbraaudio/web/routes.py +++ b/umbraaudio/web/routes.py @@ -4548,3 +4548,95 @@ async def serve_library_audio(request: Request, speaker_id: int, filename: str): if not audio_path or Path(audio_path).name != filename or not Path(audio_path).exists(): return JSONResponse({"error": "not found"}, status_code=404) return FileResponse(str(audio_path), media_type="audio/wav") + + +# --------------------------------------------------------------------------- +# m4b Meta +# --------------------------------------------------------------------------- + +def _m4b_meta_view(request: Request) -> dict[str, Any]: + from umbraaudio.audiobook_meta import ( + META_FIELDS, + completeness, + load_meta, + ) + + config = _get_active_config(request) + meta = load_meta(config.data_dir) + ready = completeness(meta) + return { + "fields": META_FIELDS, + "meta": meta, + "ready": ready, + "project_name": config.project_dir.name, + } + + +@router.get("/m4b-meta", response_class=HTMLResponse) +async def m4b_meta_page(request: Request): + return templates.TemplateResponse(request, "m4b_meta.html", _m4b_meta_view(request)) + + +@router.get("/api/m4b-meta", response_class=JSONResponse) +async def m4b_meta_get(request: Request): + view = _m4b_meta_view(request) + return JSONResponse({"meta": view["meta"], "ready": view["ready"]}) + + +@router.post("/api/m4b-meta", response_class=JSONResponse) +async def m4b_meta_save(request: Request): + from umbraaudio.audiobook_meta import completeness, load_meta, save_meta + + config = _get_active_config(request) + body = await request.json() + values = body.get("meta") or body + path = save_meta(config.data_dir, values) + meta = load_meta(config.data_dir) + return JSONResponse({"ok": True, "path": str(path), "ready": completeness(meta)}) + + +@router.post("/api/m4b-meta/cover", response_class=JSONResponse) +async def m4b_meta_upload_cover(request: Request): + """Accept a cover image file and write it into the project's .umbra dir.""" + import shutil as _shutil + + from umbraaudio.audiobook_meta import load_meta, meta_path, save_meta + + config = _get_active_config(request) + meta = load_meta(config.data_dir) + + form = await request.form() + cover = form.get("cover") + if cover is None or not getattr(cover, "filename", None): + return JSONResponse({"error": "cover file required"}, status_code=400) + + uploads = config.data_dir / "m4b_assets" + uploads.mkdir(parents=True, exist_ok=True) + dest = uploads / Path(str(getattr(cover, "filename"))).name + with open(dest, "wb") as fh: + _shutil.copyfileobj(cover.file, fh) + + rel = dest.relative_to(config.project_dir) + meta["cover_path"] = str(rel) + save_meta(config.data_dir, meta) + return JSONResponse({"ok": True, "path": str(rel), "stored": str(dest)}) + + +@router.get("/m4b-assets/{filename}") +async def serve_m4b_asset(request: Request, filename: str): + from umbraaudio.audiobook_meta import load_meta + + config = _get_active_config(request) + meta = load_meta(config.data_dir) + cover_field = str(meta.get("cover_path") or "").strip() + if not cover_field: + return JSONResponse({"error": "no cover set"}, status_code=404) + cover_field = cover_field.replace("\\", "/") + if cover_field.rsplit("/", 1)[-1] != filename: + return JSONResponse({"error": "not found"}, status_code=404) + path = config.data_dir / "m4b_assets" / filename + if not path.exists(): + return JSONResponse({"error": "not found"}, status_code=404) + import mimetypes + media = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + return FileResponse(str(path), media_type=media) diff --git a/umbraaudio/web/templates/base.html b/umbraaudio/web/templates/base.html index bf14f23..d1de0eb 100644 --- a/umbraaudio/web/templates/base.html +++ b/umbraaudio/web/templates/base.html @@ -71,6 +71,7 @@ Quality Voice Registry Lib + m4b Meta Projects {% block content %}{% endblock %} diff --git a/umbraaudio/web/templates/m4b_meta.html b/umbraaudio/web/templates/m4b_meta.html new file mode 100644 index 0000000..c0ea76c --- /dev/null +++ b/umbraaudio/web/templates/m4b_meta.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} +{% block content %} + +
+

m4b Meta

+

+ Publishing metadata for {{ project_name }}. Required fields + are marked required and must be filled before + an .m4b can be produced. +

+
+ {% if ready.ready %} + Ready to publish ✓ ({{ ready.required_filled }}/{{ ready.required_total }} required filled) + {% else %} + Incomplete — {{ ready.required_filled }}/{{ ready.required_total }} required filled + {% if ready.missing %} +
+ Missing: {% for k in ready.missing %}{% for f in fields %}{% if f.key == k %}{{ f.label }}{% if not loop.last %}{% endif %}{% endif %}{% endfor %}{% if not loop.last %}, {% endif %}{% endfor %} +
+ {% endif %} + {% endif %} +
+
+ +
+
+

Required

+
+ {% for f in fields if f.required %} +
+ + {% if f.type == 'textarea' %} + + {% elif f.type == 'select' %} + + {% elif f.key == 'synopsis' %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+ +
+ +
+ {% if meta.cover_path %} + cover + + {% else %} + + {% endif %} +
+
+ + +
+
+
+ +
+

Optional

+
+ {% for f in fields if not f.required %} +
+ + +
+ {% endfor %} +
+
+ +
+ + +
+
+ + + +{% endblock %}