feat: Add ffmetadata rendering and writing functions with tests for chapter inclusion

This commit is contained in:
JB
2025-10-07 06:01:29 -07:00
parent 718a3fa1c0
commit ea1c7bd93e
2 changed files with 198 additions and 5 deletions
+158 -5
View File
@@ -6,6 +6,7 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import tempfile
from contextlib import ExitStack from contextlib import ExitStack
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -197,6 +198,142 @@ def _chapter_voice_spec(job: Job, override: Optional[Dict[str, Any]]) -> str:
return job.voice or "" return job.voice or ""
def _escape_ffmetadata_value(value: str) -> str:
escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n")
escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#")
return escaped
def _render_ffmetadata(metadata: Dict[str, Any], chapters: List[Dict[str, Any]]) -> str:
lines: List[str] = [";FFMETADATA1"]
for key, value in (metadata or {}).items():
if value is None:
continue
key_str = str(key).strip()
if not key_str:
continue
lines.append(f"{key_str}={_escape_ffmetadata_value(value)}")
for chapter in chapters or []:
start = chapter.get("start")
end = chapter.get("end")
if start is None or end is None:
continue
try:
start_ms = max(0, int(round(float(start) * 1000)))
end_ms = int(round(float(end) * 1000))
except (TypeError, ValueError):
continue
if end_ms <= start_ms:
end_ms = start_ms + 1
lines.append("[CHAPTER]")
lines.append("TIMEBASE=1/1000")
lines.append(f"START={start_ms}")
lines.append(f"END={end_ms}")
title = chapter.get("title")
if title:
lines.append(f"title={_escape_ffmetadata_value(title)}")
voice = chapter.get("voice")
if voice:
lines.append(f"voice={_escape_ffmetadata_value(voice)}")
return "\n".join(lines) + "\n"
def _write_ffmetadata_file(
audio_path: Path,
metadata: Dict[str, Any],
chapters: List[Dict[str, Any]],
) -> Optional[Path]:
content = _render_ffmetadata(metadata, chapters)
if content.strip() == ";FFMETADATA1":
return None
directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir())
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
suffix=".ffmeta",
delete=False,
dir=str(directory),
) as handle:
handle.write(content)
return Path(handle.name)
def _embed_m4b_metadata(
audio_path: Path,
metadata_payload: Dict[str, Any],
job: Job,
) -> None:
metadata_map = dict(metadata_payload.get("metadata") or {})
chapter_entries = list(metadata_payload.get("chapters") or [])
ffmetadata_path = _write_ffmetadata_file(audio_path, metadata_map, chapter_entries)
cover_path: Optional[Path] = None
if job.cover_image_path:
candidate = Path(job.cover_image_path)
if candidate.exists():
cover_path = candidate
if not ffmetadata_path and not cover_path:
return
job.add_log("Embedding chapters and cover metadata into m4b output")
command: List[str] = ["ffmpeg", "-y", "-i", str(audio_path)]
metadata_index: Optional[int] = None
cover_index: Optional[int] = None
next_index = 1
if ffmetadata_path:
command += ["-i", str(ffmetadata_path)]
metadata_index = next_index
next_index += 1
if cover_path:
command += ["-i", str(cover_path)]
cover_index = next_index
next_index += 1
command += ["-map", "0:a"]
command += ["-c:a", "copy"]
if cover_index is not None:
command += ["-map", f"{cover_index}:v:0"]
command += ["-c:v:0", "mjpeg"]
command += ["-disposition:v:0", "attached_pic"]
command += ["-metadata:s:v:0", "title=Cover Art"]
if job.cover_image_mime:
command += ["-metadata:s:v:0", f"mimetype={job.cover_image_mime}"]
if metadata_index is not None:
command += ["-map_metadata", str(metadata_index)]
command += ["-map_chapters", str(metadata_index)]
else:
command += ["-map_metadata", "0"]
command += ["-movflags", "+faststart+use_metadata_tags"]
temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp")
command.append(str(temp_output))
process = create_process(command, text=True)
try:
return_code = process.wait()
finally:
if ffmetadata_path and ffmetadata_path.exists():
try:
ffmetadata_path.unlink()
except OSError:
pass
if return_code != 0:
if temp_output.exists():
temp_output.unlink(missing_ok=True)
raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})")
temp_output.replace(audio_path)
def run_conversion_job(job: Job) -> None: def run_conversion_job(job: Job) -> None:
job.add_log("Preparing conversion pipeline") job.add_log("Preparing conversion pipeline")
canceller = _make_canceller(job) canceller = _make_canceller(job)
@@ -204,6 +341,9 @@ def run_conversion_job(job: Job) -> None:
sink_stack = ExitStack() sink_stack = ExitStack()
subtitle_writer: Optional[SubtitleWriter] = None subtitle_writer: Optional[SubtitleWriter] = None
chapter_paths: list[Path] = [] chapter_paths: list[Path] = []
chapter_markers: List[Dict[str, Any]] = []
metadata_payload: Dict[str, Any] = {"metadata": {}, "chapters": []}
audio_output_path: Optional[Path] = None
try: try:
pipeline = _load_pipeline(job) pipeline = _load_pipeline(job)
extraction = extract_from_path(job.stored_path) extraction = extract_from_path(job.stored_path)
@@ -266,7 +406,6 @@ def run_conversion_job(job: Job) -> None:
subtitle_index = 1 subtitle_index = 1
current_time = 0.0 current_time = 0.0
total_chapters = len(extraction.chapters) total_chapters = len(extraction.chapters)
chapter_markers: List[Dict[str, Any]] = []
job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}")
for idx, chapter in enumerate(extraction.chapters, start=1): for idx, chapter in enumerate(extraction.chapters, start=1):
@@ -392,13 +531,14 @@ def run_conversion_job(job: Job) -> None:
if not audio_path and chapter_paths: if not audio_path and chapter_paths:
job.result.audio_path = chapter_paths[0] job.result.audio_path = chapter_paths[0]
metadata_payload = {
"metadata": dict(job.metadata_tags or {}),
"chapters": chapter_markers,
}
if metadata_dir: if metadata_dir:
metadata_dir.mkdir(parents=True, exist_ok=True) metadata_dir.mkdir(parents=True, exist_ok=True)
metadata_file = metadata_dir / "metadata.json" metadata_file = metadata_dir / "metadata.json"
metadata_payload = {
"metadata": job.metadata_tags,
"chapters": chapter_markers,
}
metadata_file.write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8") metadata_file.write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8")
job.result.artifacts["metadata"] = metadata_file job.result.artifacts["metadata"] = metadata_file
@@ -408,6 +548,8 @@ def run_conversion_job(job: Job) -> None:
if job.status != JobStatus.CANCELLED: if job.status != JobStatus.CANCELLED:
job.progress = 1.0 job.progress = 1.0
audio_output_path = job.result.audio_path
except _JobCancelled: except _JobCancelled:
job.status = JobStatus.CANCELLED job.status = JobStatus.CANCELLED
job.add_log("Job cancelled", level="warning") job.add_log("Job cancelled", level="warning")
@@ -420,6 +562,17 @@ def run_conversion_job(job: Job) -> None:
if subtitle_writer: if subtitle_writer:
subtitle_writer.close() subtitle_writer.close()
if (
audio_output_path
and job.output_format.lower() == "m4b"
and not job.cancel_requested
and job.status not in {JobStatus.FAILED, JobStatus.CANCELLED}
):
try:
_embed_m4b_metadata(audio_output_path, metadata_payload, job)
except Exception as exc: # pragma: no cover - best effort
job.add_log(f"Unable to embed metadata into m4b output: {exc}", level="warning")
def _load_pipeline(job: Job): def _load_pipeline(job: Job):
cfg = load_config() cfg = load_config()
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from pathlib import Path
from abogen.web.conversion_runner import _render_ffmetadata, _write_ffmetadata_file
def test_render_ffmetadata_includes_chapters(tmp_path):
metadata = {
"title": "Sample Book",
"artist": "Author Name",
"comment": "Line one\nLine two",
"publisher": "ACME=Corp",
}
chapters = [
{"start": 0.0, "end": 5.0, "title": "Intro", "voice": "voice_a"},
{"start": 5.0, "end": 12.345, "title": "Chapter 2"},
]
rendered = _render_ffmetadata(metadata, chapters)
assert ";FFMETADATA1" in rendered
assert "title=Sample Book" in rendered
assert "artist=Author Name" in rendered
assert "comment=Line one\\nLine two" in rendered
assert "publisher=ACME\\=Corp" in rendered
assert rendered.count("[CHAPTER]") == 2
assert "START=0" in rendered
assert "END=5000" in rendered
assert "voice=voice_a" in rendered
audio_path = tmp_path / "book.m4b"
metadata_path = _write_ffmetadata_file(audio_path, metadata, chapters)
assert metadata_path is not None
assert metadata_path.exists()
content = metadata_path.read_text(encoding="utf-8")
assert "END=12345" in content
metadata_path.unlink()