92 lines
3.4 KiB
Python
92 lines
3.4 KiB
Python
"""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
|