"""Narrow WAV-to-MP3 narration attachment support for Labyricorn editors.""" from __future__ import annotations import os from pathlib import Path import shutil import subprocess import tempfile NARRATION_FILENAME = "narration.mp3" class NarrationImportError(Exception): """Raised when a narration attachment cannot be safely changed.""" def narration_path(entry_directory: Path) -> Path: """Return the one supported narration attachment path for an entry.""" return entry_directory / NARRATION_FILENAME def import_wav( entry_directory: Path, source: Path, *, ffmpeg: str | Path | None = None, fallback_ffmpeg: Path | None = None, ) -> Path: """Convert *source* WAV to an atomically replaced narration attachment.""" source = source.expanduser() if source.suffix.lower() != ".wav": raise NarrationImportError("choose a WAV (.wav) file for narration") if not source.is_file(): raise NarrationImportError(f"narration source is not a regular file: {source}") if not entry_directory.is_dir(): raise NarrationImportError(f"entry directory does not exist: {entry_directory}") executable = str(ffmpeg) if ffmpeg else shutil.which("ffmpeg") if not executable and fallback_ffmpeg is not None and fallback_ffmpeg.is_file(): executable = str(fallback_ffmpeg) if not executable: raise NarrationImportError( "ffmpeg is required to convert WAV narration to narration.mp3; add it to PATH " "or provide support/ffmpeg.exe" ) destination = narration_path(entry_directory) descriptor, temporary_name = tempfile.mkstemp( prefix=".narration-", suffix=".mp3", dir=entry_directory ) os.close(descriptor) temporary = Path(temporary_name) try: command = [ executable, "-y", "-i", str(source), "-vn", "-ar", "44100", "-ac", "1", "-c:a", "libmp3lame", "-b:a", "96k", str(temporary), ] run_options: dict[str, object] = {"capture_output": True, "text": True} if os.name == "nt": run_options["creationflags"] = subprocess.CREATE_NO_WINDOW completed = subprocess.run(command, **run_options) if completed.returncode: detail = (completed.stderr or completed.stdout or "ffmpeg returned an error").strip() raise NarrationImportError(f"ffmpeg could not convert the narration WAV: {detail}") if not temporary.is_file() or temporary.stat().st_size == 0: raise NarrationImportError("ffmpeg completed without producing a usable MP3 file") os.replace(temporary, destination) except OSError as exc: raise NarrationImportError(f"cannot convert narration WAV: {exc}") from exc finally: try: temporary.unlink(missing_ok=True) except OSError: pass return destination def remove_narration(entry_directory: Path) -> bool: """Remove only the canonical narration attachment, if it exists.""" destination = narration_path(entry_directory) if not destination.exists(): return False if not destination.is_file(): raise NarrationImportError(f"narration attachment is not a regular file: {destination}") try: destination.unlink() except OSError as exc: raise NarrationImportError(f"cannot remove narration attachment: {exc}") from exc return True