217 lines
6.2 KiB
Python
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
|