Files
abogen/abogen/webui/conversion_runner.py
T
Artem Akymenko 953bef1e71 refactor: group ConversionRequest fields into config objects
Domain config types (domain/config_types.py):
- PronunciationConfig: pronunciation/heteronym/normalization overrides
- SubtitleConfig: mode, format, max_words
- CoverConfig: path, mime

Domain functions now accept config objects:
- build_tts_context(subtitle=, pronunciation=) instead of 9 individual params
- make_subtitle_writer(subtitle=) instead of 3 params
- process_and_write_subtitles(subtitle=) instead of 2 params
- embed_m4b_metadata(cover=) instead of 2 params
- build_epub3_package(cover=) instead of 2 params

ConversionRequest: 18 flat fields + 8 config objects
Application/config.py re-exports domain types
All tests updated to new API
2026-07-28 13:41:45 +03:00

203 lines
6.7 KiB
Python

"""WebUI conversion runner — thin adapter between Job and shared layer.
This module is the WebUI's adapter for the conversion system. It:
1. Builds a ConversionRequest from a Job
2. Creates an Events adapter
3. Calls run_conversion() from the shared application layer
4. Maps the ConversionResult back to Job state
All conversion logic (chapter loop, voice resolution, TTS, metadata,
subtitle writing, m4b/epub3 finalization) lives in the application layer.
Language: Job.language is Language enum. Frontend must send ISO codes.
Engine converts Language → its own format internally.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.application.conversion_config import (
ChapterChunkConfig,
CoverConfig,
Epub3ExportConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
)
from abogen.application.conversion_ports import ConversionCancelled
from abogen.application.conversion_request import ConversionRequest
from abogen.application.conversion_service import run_conversion
from abogen.domain.enums import (
OutputFormat,
SaveMode,
SubtitleFormat,
SubtitleMode,
)
from .service import Job, JobStatus
# ---------------------------------------------------------------------------
# Build ConversionRequest from Job
# ---------------------------------------------------------------------------
def _build_request(job: Job) -> ConversionRequest:
"""Build a ConversionRequest from a WebUI Job."""
source_path = Path(job.stored_path) if job.stored_path else None
return ConversionRequest(
# Source
source_path=source_path,
original_filename=job.original_filename,
# TTS Settings
language=job.language,
tts_provider=job.tts_provider,
voice=job.voice,
speed=job.speed,
use_gpu=job.use_gpu,
supertonic_total_steps=job.supertonic_total_steps,
# Output Format
output_format=_resolve_output_format(job.output_format),
# Timing
silence_between_chapters=job.silence_between_chapters,
chapter_intro_delay=job.chapter_intro_delay,
# Content Processing
replace_single_newlines=job.replace_single_newlines,
read_title_intro=job.read_title_intro,
read_closing_outro=job.read_closing_outro,
auto_prefix_chapter_titles=job.auto_prefix_chapter_titles,
normalize_chapter_opening_caps=job.normalize_chapter_opening_caps,
# Metadata
metadata_tags=job.metadata_tags or {},
# Grouped configs
subtitle=SubtitleConfig(
mode=_resolve_subtitle_mode(job.subtitle_mode),
format=_resolve_subtitle_format(job.subtitle_format),
max_words=job.max_subtitle_words,
),
save=SaveConfig(
mode=_resolve_save_mode(job.save_mode),
output_folder=job.output_folder,
save_chapters_separately=job.save_chapters_separately,
merge_chapters_at_end=job.merge_chapters_at_end,
separate_chapters_format=_resolve_output_format(job.separate_chapters_format),
save_as_project=job.save_as_project,
),
cover=CoverConfig(
path=job.cover_image_path,
mime=job.cover_image_mime,
),
pronunciation=PronunciationConfig(
pronunciation_overrides=job.pronunciation_overrides or [],
manual_overrides=job.manual_overrides or [],
heteronym_overrides=job.heteronym_overrides or [],
normalization_overrides=job.normalization_overrides or None,
),
# Feature configs
epub3_export=Epub3ExportConfig(book_id=job.id) if job.generate_epub3 else None,
chapter_chunk=ChapterChunkConfig(
chapter_overrides=job.chapters or [],
chunks=job.chunks or [],
chunk_level=job.chunk_level,
speaker_mode=job.speaker_mode,
speakers=job.speakers or {},
),
)
def _resolve_output_format(fmt: str) -> OutputFormat:
try:
return OutputFormat.from_str(fmt)
except ValueError:
return OutputFormat.WAV
def _resolve_subtitle_mode(mode: str) -> SubtitleMode:
try:
return SubtitleMode.from_str(mode)
except ValueError:
return SubtitleMode.DISABLED
def _resolve_subtitle_format(fmt: str) -> SubtitleFormat:
try:
return SubtitleFormat.from_str(fmt)
except ValueError:
return SubtitleFormat.SRT
def _resolve_save_mode(mode: str) -> SaveMode:
normalized = mode.strip().lower()
for m in SaveMode:
if m.value == normalized:
return m
return SaveMode.SAVE_NEXT_TO_INPUT
# ---------------------------------------------------------------------------
# Apply ConversionResult back to Job
# ---------------------------------------------------------------------------
def _apply_result(job: Job, result: Any) -> None:
"""Map ConversionResult fields back to Job result and state."""
job.result.audio_path = result.audio_path
job.result.subtitle_paths = list(result.subtitle_paths)
job.result.artifacts = dict(result.artifacts)
job.result.epub_path = result.epub_path
job.progress = 1.0
# ---------------------------------------------------------------------------
# Events adapter
# ---------------------------------------------------------------------------
class WebUIEventsAdapter:
"""Adapts Job to ConversionEvents protocol."""
def __init__(self, job: Job):
self._job = job
def log(self, message: str, level: str = "info") -> None:
self._job.add_log(message, level=level)
def progress(self, pct: int, etr: str) -> None:
self._job.progress = min(pct / 100.0, 0.999)
self._job.etr_str = etr
def check_cancelled(self) -> None:
if self._job.cancel_requested:
raise ConversionCancelled("Job cancelled")
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def run_conversion_job(job: Job) -> None:
"""Run a conversion job using the shared application layer."""
job.add_log("Preparing conversion pipeline")
request = _build_request(job)
events = WebUIEventsAdapter(job)
try:
result = run_conversion(request, events)
_apply_result(job, result)
if job.status != JobStatus.CANCELLED:
job.progress = 1.0
except ConversionCancelled:
job.status = JobStatus.CANCELLED
job.add_log("Job cancelled", level="warning")
except Exception as exc:
job.error = str(exc)
job.status = JobStatus.FAILED
job.add_log(f"Job failed: {exc}", level="error")