infra/modules/kubernetes/ebook2audiobook/audiblez-web/backend/services/chapter_embedder.py
Viktor Barzin bcad200a23 chore: add untracked stacks, scripts, and agent configs
- New stacks: beads-server, hermes-agent
- Terragrunt tiers.tf for infra, phpipam, status-page
- Secrets symlinks for vault, phpipam, hermes-agent
- Scripts: cluster_manager, image_pull, containerd pullthrough setup
- Frigate config, audiblez-web app source, n8n workflows dir
- Claude agent: service-upgrade, reference: upgrade-config.json
- Removed: claudeception skill, excalidraw empty submodule, temp listings

[ci skip]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:33:06 +00:00

156 lines
4.8 KiB
Python

"""M4B chapter metadata embedding service."""
import re
import subprocess
import tempfile
from pathlib import Path
from pydub import AudioSegment
from .epub_parser import Chapter
def get_chapter_audio_durations(output_dir: Path) -> list[int]:
"""Calculate duration of each chapter WAV file in milliseconds.
audiblez produces files like: {bookname}_chapter_{N}.wav
e.g., mybook_chapter_1.wav, mybook_chapter_2.wav
Args:
output_dir: Directory containing the WAV files
Returns:
List of durations in milliseconds, ordered by chapter number
"""
durations = []
# Find all chapter WAV files - audiblez uses {name}_chapter_{N}.wav
wav_files = list(output_dir.glob("*_chapter_*.wav"))
if not wav_files:
# Fallback: try any WAV files
wav_files = list(output_dir.glob("*.wav"))
if not wav_files:
print(f"No WAV files found in {output_dir}")
return durations
# Sort by extracting chapter number from filename using regex
# Pattern: look for _chapter_N or chapter_N in filename
def extract_chapter_num(path: Path) -> int:
name = path.stem
# Try to find chapter number with regex - handles various patterns
# e.g., "book_chapter_1", "mybook_chapter_12", "chapter_3_voice"
match = re.search(r'chapter[_-]?(\d+)', name, re.IGNORECASE)
if match:
return int(match.group(1))
# Fallback: find any number in the filename
match = re.search(r'(\d+)', name)
if match:
return int(match.group(1))
return 0
wav_files.sort(key=extract_chapter_num)
print(f"Found {len(wav_files)} WAV files to process for durations")
for wav_file in wav_files:
try:
audio = AudioSegment.from_file(str(wav_file))
durations.append(len(audio)) # duration in ms
print(f" Chapter WAV: {wav_file.name} - {len(audio)}ms ({len(audio)/1000:.1f}s)")
except Exception as e:
print(f" Error reading {wav_file}: {e}")
continue
return durations
def generate_ffmpeg_metadata(chapters: list[Chapter], durations: list[int]) -> str:
"""Generate FFmpeg FFMETADATA1 format string with chapter markers.
Args:
chapters: List of Chapter objects with titles
durations: List of durations in milliseconds for each chapter
Returns:
FFMETADATA1 formatted string
"""
metadata = ";FFMETADATA1\n"
current_time_ms = 0
# Match chapters with durations
num_chapters = min(len(chapters), len(durations))
for i in range(num_chapters):
chapter = chapters[i]
duration = durations[i]
chapter.start_ms = current_time_ms
chapter.end_ms = current_time_ms + duration
chapter.duration_ms = duration
metadata += f"\n[CHAPTER]\n"
metadata += f"TIMEBASE=1/1000\n"
metadata += f"START={chapter.start_ms}\n"
metadata += f"END={chapter.end_ms}\n"
metadata += f"title={chapter.title}\n"
current_time_ms = chapter.end_ms
return metadata
def embed_chapters_in_m4b(input_m4b: Path, metadata_content: str) -> Path:
"""Re-mux M4B with chapter metadata using FFmpeg.
Args:
input_m4b: Path to the input M4B file
metadata_content: FFMETADATA1 formatted string
Returns:
Path to the output M4B with chapters (same as input, replaced)
"""
output_m4b = input_m4b.with_suffix('.chaptered.m4b')
# Write metadata to temporary file
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(metadata_content)
metadata_file = Path(f.name)
try:
cmd = [
'ffmpeg', '-y',
'-i', str(input_m4b),
'-f', 'ffmetadata', '-i', str(metadata_file),
'-map', '0:a',
'-map_metadata', '1',
'-c:a', 'copy', # Copy audio without re-encoding
'-movflags', '+faststart+use_metadata_tags',
str(output_m4b)
]
print(f"Running FFmpeg: {' '.join(cmd)}")
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
if result.returncode != 0:
print(f"FFmpeg stderr: {result.stderr}")
raise RuntimeError(f"FFmpeg failed: {result.stderr}")
# Replace original with chaptered version
input_m4b.unlink()
output_m4b.rename(input_m4b)
print(f"Successfully embedded chapters in {input_m4b}")
return input_m4b
except subprocess.CalledProcessError as e:
print(f"FFmpeg error: {e.stderr}")
# Clean up temp file
if output_m4b.exists():
output_m4b.unlink()
raise
finally:
# Clean up metadata file
if metadata_file.exists():
metadata_file.unlink()