feat: Implement chapter embedding in m4b files using mutagen and update dependencies

This commit is contained in:
JB
2025-10-07 08:47:18 -07:00
parent 0dd74412d1
commit 02da72434b
2 changed files with 73 additions and 2 deletions
+72 -2
View File
@@ -272,6 +272,70 @@ def _write_ffmetadata_file(
return Path(handle.name) return Path(handle.name)
def _apply_m4b_chapters_with_mutagen(
audio_path: Path,
chapters: List[Dict[str, Any]],
job: Job,
) -> bool:
if not chapters:
return False
try:
from fractions import Fraction
from mutagen.mp4 import MP4, MP4Chapter # type: ignore[import]
except ImportError:
job.add_log(
"Unable to write MP4 chapter atoms because mutagen is not installed.",
level="warning",
)
return False
try:
mp4 = MP4(str(audio_path))
except Exception as exc: # pragma: no cover - defensive
job.add_log(f"Failed to open m4b for chapter embedding: {exc}", level="warning")
return False
chapter_objects: List[MP4Chapter] = []
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
start_raw = entry.get("start")
if start_raw is None:
continue
try:
start_seconds = max(0.0, float(start_raw))
except (TypeError, ValueError):
continue
title_value = entry.get("title")
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
chapter_atom = MP4Chapter(start_fraction, title_text)
end_raw = entry.get("end")
if end_raw is not None:
try:
end_seconds = float(end_raw)
except (TypeError, ValueError):
end_seconds = None
if end_seconds is not None and end_seconds > start_seconds:
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
chapter_objects.append(chapter_atom)
if not chapter_objects:
return False
try:
mp4.chapters = chapter_objects
mp4.save()
except Exception as exc: # pragma: no cover - defensive
job.add_log(f"Failed to persist MP4 chapter atoms: {exc}", level="warning")
return False
return True
def _embed_m4b_metadata( def _embed_m4b_metadata(
audio_path: Path, audio_path: Path,
metadata_payload: Dict[str, Any], metadata_payload: Dict[str, Any],
@@ -299,7 +363,7 @@ def _embed_m4b_metadata(
next_index = 1 next_index = 1
if ffmetadata_path: if ffmetadata_path:
command += ["-i", str(ffmetadata_path)] command += ["-f", "ffmetadata", "-i", str(ffmetadata_path)]
metadata_index = next_index metadata_index = next_index
next_index += 1 next_index += 1
@@ -351,6 +415,12 @@ def _embed_m4b_metadata(
temp_output.replace(audio_path) temp_output.replace(audio_path)
job.add_log("Embedded metadata and chapters into m4b output", level="info") job.add_log("Embedded metadata and chapters into m4b output", level="info")
mutagen_applied = _apply_m4b_chapters_with_mutagen(audio_path, chapter_entries, job)
if mutagen_applied:
job.add_log(
f"Applied {len(chapter_entries)} chapter markers via mutagen", level="info"
)
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")
@@ -780,7 +850,7 @@ def _open_audio_sink(
def _write(data: np.ndarray) -> None: def _write(data: np.ndarray) -> None:
if job.cancel_requested or process.stdin is None: if job.cancel_requested or process.stdin is None:
return return
process.stdin.write(data.tobytes()) process.stdin.write(data.tobytes()) # type: ignore[arg-type]
return AudioSink(write=_write) return AudioSink(write=_write)
+1
View File
@@ -21,6 +21,7 @@ dependencies = [
"PyMuPDF>=1.25.5", "PyMuPDF>=1.25.5",
"platformdirs>=4.3.7", "platformdirs>=4.3.7",
"soundfile>=0.13.1", "soundfile>=0.13.1",
"mutagen>=1.47.0",
"pygame>=2.6.1", "pygame>=2.6.1",
"charset_normalizer>=3.4.1", "charset_normalizer>=3.4.1",
"chardet>=5.2.0", "chardet>=5.2.0",