Files
UmbraAudio/umbraaudio/assembly/metadata.py
2026-10-18 19:01:04 -07:00

217 lines
6.2 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
import subprocess
from dataclasses import asdict, dataclass, field
from pathlib import Path
@dataclass
class ChapterMeta:
title: str
start_time_sec: float = 0.0
end_time_sec: float = 0.0
index: int = 0
@dataclass
class BookMetadata:
title: str = ""
subtitle: str = ""
author: str = ""
narrator: str = ""
description: str = ""
language: str = "eng"
publisher: str = ""
copyright_holder: str = ""
copyright_year: str = ""
year: str = ""
release_date: str = ""
series: str = ""
series_number: str = ""
genre: str = "Audiobook"
keywords: str = ""
isbn: str = ""
explicit: str = ""
chapters: list[ChapterMeta] = field(default_factory=list)
cover_art: Path | None = None
def write_metadata_json(meta: BookMetadata, output_path: Path) -> Path:
data = asdict(meta)
data["chapters"] = [asdict(ch) for ch in meta.chapters]
if meta.cover_art:
data["cover_art"] = str(meta.cover_art)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(data, f, indent=2)
return output_path
def read_metadata_json(path: Path) -> BookMetadata:
with open(path) as f:
data = json.load(f)
chapters = [ChapterMeta(**ch) for ch in data.pop("chapters", [])]
cover_raw = data.pop("cover_art", None)
cover = Path(cover_raw) if cover_raw else None
meta = BookMetadata(**data, chapters=chapters, cover_art=cover)
return meta
def build_chapter_metadata(
chapter_titles: list[str],
chapter_durations_sec: list[float],
book_title: str = "",
author: str = "",
) -> BookMetadata:
chapters = []
offset = 0.0
for i, (title, dur) in enumerate(zip(chapter_titles, chapter_durations_sec, strict=False)):
chapters.append(
ChapterMeta(
title=title or f"Chapter {i + 1}",
start_time_sec=offset,
end_time_sec=offset + dur,
index=i,
)
)
offset += dur
return BookMetadata(title=book_title, author=author, chapters=chapters)
def write_ffmpeg_metadata(meta: BookMetadata, output_path: Path) -> Path:
lines = ["; FFmpeg metadata file", ""]
if meta.title:
lines.append(f"title={meta.title}")
if meta.subtitle:
lines.append(f"album={meta.subtitle}")
if meta.author:
lines.append(f"artist={meta.author}")
lines.append(f"album_artist={meta.author}")
lines.append(f"author={meta.author}")
if meta.narrator:
lines.append(f"composer={meta.narrator}")
lines.append(f"narrated_by={meta.narrator}")
lines.append(f"performer={meta.narrator}")
if meta.description:
lines.append(f"comment={meta.description}")
lines.append(f"description={meta.description}")
lines.append(f"synopsis={_flatten(meta.description)}")
if meta.language:
lines.append(f"language={meta.language}")
if meta.publisher:
lines.append(f"publisher={meta.publisher}")
if meta.copyright_holder:
lines.append(f"copyright={meta.copyright_holder}")
if meta.copyright_year:
lines.append(f"copyright_year={meta.copyright_year}")
year = meta.year or (meta.copyright_year or "")
if year:
lines.append(f"date={year}")
lines.append(f"year={year}")
if meta.release_date:
lines.append(f"creation_time={meta.release_date}")
if meta.genre:
lines.append(f"genre={meta.genre}")
if meta.keywords:
lines.append(f"keywords={_flatten(meta.keywords)}")
if meta.series:
lines.append(f"series={meta.series}")
if meta.series_number:
lines.append(f"series_part={meta.series_number}")
if meta.isbn:
lines.append(f"isbn={meta.isbn}")
if meta.explicit:
lines.append(f"content_rating={meta.explicit}")
lines.append("encoder=Lavf")
lines.append("")
for ch in meta.chapters:
start_ms = int(ch.start_time_sec * 1000)
end_ms = int(ch.end_time_sec * 1000)
lines.append("[CHAPTER]")
lines.append("TIMEBASE=1/1000")
lines.append(f"START={start_ms}")
lines.append(f"END={end_ms}")
lines.append(f"title={ch.title}")
lines.append("")
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
f.write("\n".join(lines))
return output_path
def _flatten(text: str) -> str:
return " ".join(text.split())
def apply_ffmpeg_metadata(
input_audio: Path,
metadata_file: Path,
output_path: Path,
ffmpeg_path: str = "ffmpeg",
) -> Path:
output_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
ffmpeg_path,
"-i",
str(input_audio),
"-f",
"ffmetadata",
"-i",
str(metadata_file),
"-map",
"0",
"-map_metadata",
"1",
"-codec",
"copy",
str(output_path),
]
subprocess.run(cmd, check=True, capture_output=True)
return output_path
def apply_ffmpeg_cover(
input_audio: Path,
cover_path: Path,
output_path: Path,
ffmpeg_path: str = "ffmpeg",
) -> Path:
"""Embed a cover image as attached_pic art in the M4B container.
Re-muxes the audio, adding the still cover image as a video/attached-pic
stream (the standard layout audiobook players use for the book cover)."""
output_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
ffmpeg_path,
"-y",
"-i",
str(input_audio),
"-i",
str(cover_path),
"-map",
"0:a:0",
"-map",
"1:0",
"-map_metadata",
"0",
"-c",
"copy",
"-disposition:v:0",
"attached_pic",
"-metadata:s:v",
"title=Cover",
"-metadata:s:v",
"comment=Cover (front)",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Cover embedding failed: {result.stderr}")
return output_path