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 <project>/.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
This commit is contained in:
B.D. Murphy
2026-09-02 17:53:37 -07:00
parent a172610b0e
commit 655bc0aaf8
4 changed files with 313 additions and 0 deletions

View File

@@ -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 (``<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
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),
}

View File

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

View File

@@ -71,6 +71,7 @@
<a href="/quality">Quality</a>
<a href="/registry">Voice Registry</a>
<a href="/library">Lib</a>
<a href="/m4b-meta">m4b Meta</a>
<a href="/projects" style="margin-left: auto; border-color: #a78bfa;">Projects</a>
</nav>
{% block content %}{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<h2 style="margin:0">m4b Meta</h2>
<p style="color:#94a3b8; margin:0.25rem 0 0">
Publishing metadata for <strong>{{ project_name }}</strong>. Required fields
are marked <span class="badge badge-red">required</span> and must be filled before
an .m4b can be produced.
</p>
<div style="margin-top:0.75rem">
{% if ready.ready %}
<span class="badge badge-green" style="font-size:0.8rem">Ready to publish ✓ ({{ ready.required_filled }}/{{ ready.required_total }} required filled)</span>
{% else %}
<span class="badge badge-yellow" style="font-size:0.8rem">Incomplete — {{ ready.required_filled }}/{{ ready.required_total }} required filled</span>
{% if ready.missing %}
<div style="margin-top:0.4rem; font-size:0.8rem; color:#fde68a">
Missing: {% for k in ready.missing %}{% for f in fields %}{% if f.key == k %}<strong>{{ f.label }}</strong>{% if not loop.last %}{% endif %}{% endif %}{% endfor %}{% if not loop.last %}, {% endif %}{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
</div>
<form id="m4b-form" onsubmit="event.preventDefault(); saveMeta();">
<div class="card">
<h3 style="margin-top:0">Required</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem">
{% for f in fields if f.required %}
<div style="grid-column: span 1">
<label style="color:#a78bfa; font-size:0.85rem; display:block; margin-bottom:0.25rem">
{{ f.label }} <span class="badge badge-red">required</span>
</label>
{% if f.type == 'textarea' %}
<textarea name="{{ f.key }}" rows="3" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">{{ meta[f.key] }}</textarea>
{% elif f.type == 'select' %}
<select name="{{ f.key }}" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
{% set opts = ['', 'No', 'Yes'] %}
{% for o in opts %}
<option value="{{ o }}" {% if meta[f.key] == o %}selected{% endif %}>{{ o if o else '— select —' }}</option>
{% endfor %}
</select>
{% elif f.key == 'synopsis' %}
<textarea name="{{ f.key }}" rows="4" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">{{ meta[f.key] }}</textarea>
{% else %}
<input type="text" name="{{ f.key }}" value="{{ meta[f.key] }}" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top:1rem">
<label style="color:#a78bfa; font-size:0.85rem; display:block; margin-bottom:0.25rem">
Cover image <span class="badge badge-red">required</span>
</label>
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap">
{% if meta.cover_path %}
<img src="/m4b-assets/{{ meta.cover_path.rsplit('/', 1)[-1] }}" alt="cover"
style="height:90px; border-radius:6px; border:1px solid #333"
onerror="this.style.display='none'">
<input type="text" name="cover_path" value="{{ meta.cover_path }}" style="flex:1; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
{% else %}
<input type="text" name="cover_path" placeholder="path/to/cover.jpg" style="flex:1; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
{% endif %}
</div>
<div style="margin-top:0.5rem; display:flex; gap:0.5rem; align-items:center">
<input type="file" id="cover-upload" accept="image/*" style="color:#94a3b8; font-size:0.85rem">
<button type="button" class="btn btn-generate" onclick="uploadCover()" style="font-size:0.8rem">Upload Cover</button>
</div>
</div>
</div>
<div class="card">
<h3 style="margin-top:0">Optional</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem">
{% for f in fields if not f.required %}
<div>
<label style="color:#a78bfa; font-size:0.85rem; display:block; margin-bottom:0.25rem">{{ f.label }}</label>
<input type="text" name="{{ f.key }}" value="{{ meta[f.key] }}" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
</div>
{% endfor %}
</div>
</div>
<div style="display:flex; gap:0.5rem; align-items:center">
<button type="submit" class="btn btn-approve" style="font-size:0.95rem">Save Metadata</button>
<span id="save-status" style="color:#94a3b8; font-size:0.85rem"></span>
</div>
</form>
<script>
async function saveMeta() {
var form = document.getElementById('m4b-form');
var data = {};
form.querySelectorAll('input[name], textarea[name], select[name]').forEach(function(el) {
data[el.name] = el.value;
});
var resp = await fetch('/api/m4b-meta', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({meta: data})
});
var result = await resp.json();
var status = document.getElementById('save-status');
if (result.ok) {
status.textContent = result.ready.ready
? 'Saved ✓ — Ready to publish (' + result.ready.required_filled + '/' + result.ready.required_total + ')'
: 'Saved ✓ — still missing ' + (result.ready.required_total - result.ready.required_filled) + ' required: ' + result.ready.missing.join(', ');
} else {
status.textContent = 'Error saving: ' + (result.error || 'unknown');
}
setTimeout(function(){ location.reload(); }, 900);
}
async function uploadCover() {
var fileInput = document.getElementById('cover-upload');
if (!fileInput.files.length) { alert('Choose a cover image first.'); return; }
var fd = new FormData();
fd.append('cover', fileInput.files[0]);
var resp = await fetch('/api/m4b-meta/cover', { method: 'POST', body: fd });
var result = await resp.json();
if (result.ok) {
location.reload();
} else {
alert('Upload failed: ' + (result.error || 'unknown'));
}
}
</script>
{% endblock %}