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:
90
umbraaudio/audiobook_meta.py
Normal file
90
umbraaudio/audiobook_meta.py
Normal 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),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
130
umbraaudio/web/templates/m4b_meta.html
Normal file
130
umbraaudio/web/templates/m4b_meta.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user