Add blog and article narration import
Deploy production / deploy (push) Successful in 4s

This commit is contained in:
2026-08-13 15:12:33 -07:00
parent a2e405cb96
commit 2fe6b689b0
8 changed files with 2672 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
"""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