refactor: add build_metadata_payload() to domain, unify metadata assembly in both UIs

This commit is contained in:
Artem Akymenko
2026-07-24 21:26:21 +03:00
parent cfc7de7abf
commit 7ed2addb11
4 changed files with 126 additions and 52 deletions
+37
View File
@@ -403,3 +403,40 @@ def load_audiobookshelf_chapters(
if title and start is not None and end is not None: if title and start is not None and end is not None:
cleaned.append({"title": str(title), "start": start, "end": end}) cleaned.append({"title": str(title), "start": start, "end": end})
return cleaned or None return cleaned or None
def build_metadata_payload(
metadata: Optional[Dict[str, Any]] = None,
chapter_markers: Optional[List[Dict[str, Any]]] = None,
chunk_markers: Optional[List[Dict[str, Any]]] = None,
chunk_level: Optional[str] = None,
speaker_mode: Optional[str] = None,
speakers: Optional[Dict[str, Any]] = None,
generate_epub3: bool = False,
) -> Dict[str, Any]:
"""Build the canonical metadata payload dict for persistence and downstream use.
This is the single source of truth for metadata assembly. Both PyQt and WebUI
runners should call this instead of building the dict manually.
Args:
metadata: Normalized metadata tags dict.
chapter_markers: List of chapter marker dicts with title/start/end.
chunk_markers: List of chunk marker dicts.
chunk_level: Chunk granularity level (e.g. 'chapter', 'chunk').
speaker_mode: Speaker mode ('single', 'multi', etc.).
speakers: Speaker profile mapping.
generate_epub3: Whether EPUB3 generation is enabled.
Returns:
Complete metadata payload dict.
"""
return {
"metadata": dict(metadata or {}),
"chapters": chapter_markers or [],
"chunks": chunk_markers or [],
"chunk_level": chunk_level,
"speaker_mode": speaker_mode,
"speakers": dict(speakers or {}),
"generate_epub3": generate_epub3,
}
+25 -43
View File
@@ -46,16 +46,8 @@ from abogen.domain.audio_buffer import (
) )
from abogen.domain.subtitle_generation import process_subtitle_tokens from abogen.domain.subtitle_generation import process_subtitle_tokens
from abogen.domain.voice_loader import VoiceCache, load_voice_cached, resolve_voice from abogen.domain.voice_loader import VoiceCache, load_voice_cached, resolve_voice
from abogen.domain.progress import calc_etr_str
from abogen.domain.normalization import TTSContext
from abogen.domain.pronunciation import (
compile_pronunciation_rules,
compile_heteronym_sentence_rules,
merge_pronunciation_overrides,
)
from abogen.domain.metadata_extraction import ( from abogen.domain.metadata_extraction import (
extract_metadata_and_build_args, extract_metadata_from_text,
extract_metadata_for_file,
) )
from abogen.domain.text_chapters import parse_chapters_from_text from abogen.domain.text_chapters import parse_chapters_from_text
from abogen.infrastructure.exporters import ExportService from abogen.infrastructure.exporters import ExportService
@@ -520,6 +512,9 @@ class ConversionThread(QThread):
) as file: ) as file:
text = file.read() text = file.read()
# Extract metadata BEFORE clean_text strips the tags
self._extracted_metadata = extract_metadata_from_text(text)
# Clean up text using utility function # Clean up text using utility function
text = clean_text(text) text = clean_text(text)
@@ -545,18 +540,15 @@ class ConversionThread(QThread):
) )
# --- Compile normalization rules (heteronym + pronunciation) --- # --- Compile normalization rules (heteronym + pronunciation) ---
from abogen.domain.normalization import TTSContext from abogen.domain.normalization import build_tts_context
pronunciation_overrides = merge_pronunciation_overrides( self._tts_context = build_tts_context(
getattr(self, "pronunciation_overrides", None), language=self.lang_code,
getattr(self, "manual_overrides", None), subtitle_mode=self.subtitle_mode,
) pronunciation_overrides=getattr(self, "pronunciation_overrides", None),
self._tts_context = TTSContext( manual_overrides=getattr(self, "manual_overrides", None),
split_pattern=self.split_pattern, heteronym_overrides=getattr(self, "heteronym_overrides", None),
pronunciation_rules=compile_pronunciation_rules(pronunciation_overrides),
heteronym_rules=compile_heteronym_sentence_rules(
getattr(self, "heteronym_overrides", None)
),
normalization_overrides=getattr(self, "normalization_overrides", None), normalization_overrides=getattr(self, "normalization_overrides", None),
log_callback=lambda level, msg: self.log_updated.emit((msg, "grey" if level == "info" else "orange")),
) )
# --- Chapter splitting logic --- # --- Chapter splitting logic ---
@@ -755,7 +747,7 @@ class ConversionThread(QThread):
intro_emitted = False intro_emitted = False
if merge_chapters_at_end: if merge_chapters_at_end:
intro_spec = resolve_intro( intro_spec = resolve_intro(
extract_metadata_for_file(self.file_name, self.is_direct_text), self._extracted_metadata,
os.path.basename(self.file_name) if self.file_name else "", os.path.basename(self.file_name) if self.file_name else "",
getattr(self, "read_title_intro", False), getattr(self, "read_title_intro", False),
self.voice, self.voice, list(self.voice_cache._cache.keys()), self.voice, self.voice, list(self.voice_cache._cache.keys()),
@@ -1091,7 +1083,7 @@ class ConversionThread(QThread):
# --- Outro synthesis --- # --- Outro synthesis ---
if merge_chapters_at_end: if merge_chapters_at_end:
outro_spec = resolve_outro( outro_spec = resolve_outro(
extract_metadata_for_file(self.file_name, self.is_direct_text), self._extracted_metadata,
os.path.basename(self.file_name) if self.file_name else "", os.path.basename(self.file_name) if self.file_name else "",
getattr(self, "read_closing_outro", True), getattr(self, "read_closing_outro", True),
self.voice, self.voice, list(self.voice_cache._cache.keys()), self.voice, self.voice, list(self.voice_cache._cache.keys()),
@@ -1132,12 +1124,7 @@ class ConversionThread(QThread):
# Add chapters via ExportService (unified with WebUI) # Add chapters via ExportService (unified with WebUI)
if total_chapters > 1: if total_chapters > 1:
export_svc = ExportService() export_svc = ExportService()
metadata_text = read_text_for_metadata( metadata = dict(getattr(self, "_extracted_metadata", {}))
file_path=self.file_name,
is_direct_text=self.is_direct_text,
direct_text=self.file_name if self.is_direct_text else None,
)
metadata = extract_metadata_from_text(metadata_text) if metadata_text else {}
# Convert cover_path from metadata to Path if present # Convert cover_path from metadata to Path if present
cover_path_raw = metadata.pop("cover_path", None) cover_path_raw = metadata.pop("cover_path", None)
cover_path = Path(cover_path_raw) if cover_path_raw and os.path.exists(cover_path_raw) else None cover_path = Path(cover_path_raw) if cover_path_raw and os.path.exists(cover_path_raw) else None
@@ -1376,33 +1363,28 @@ class ConversionThread(QThread):
raise ValueError(f"Unsupported output format: {self.output_format}") raise ValueError(f"Unsupported output format: {self.output_format}")
def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self): def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self):
"""Extract metadata tags from text content and add them to ffmpeg command""" """Build ffmpeg metadata args from previously extracted metadata."""
# Read text for metadata extraction metadata = getattr(self, "_extracted_metadata", None)
text = read_text_for_metadata( if not metadata or not any(metadata.values()):
file_path=self.file_name,
is_direct_text=self.is_direct_text,
direct_text=self.file_name if self.is_direct_text else None,
)
if not text:
self.log_updated.emit( self.log_updated.emit(
("Warning: Could not read file for metadata extraction", "orange") ("Warning: No metadata tags found in text", "orange")
) )
return [], None return [], None
# Extract metadata and build ffmpeg args
filename = self.file_name if self.is_direct_text else ( filename = self.file_name if self.is_direct_text else (
self.display_path if self.display_path else self.file_name self.display_path if self.display_path else self.file_name
) )
try: try:
metadata_options, cover_path = extract_metadata_and_build_args( from abogen.domain.metadata_extraction import build_ffmpeg_metadata_args, get_filename_from_path
text=text, actual_filename = get_filename_from_path(
filename=filename, file_path=filename,
display_path=getattr(self, "display_path", None), display_path=getattr(self, "display_path", None),
from_queue=getattr(self, "from_queue", False), from_queue=getattr(self, "from_queue", False),
) )
return metadata_options, cover_path args = build_ffmpeg_metadata_args(metadata, actual_filename)
cover_path = metadata.get("cover_path")
return args, cover_path
except Exception as e: except Exception as e:
self.log_updated.emit( self.log_updated.emit(
(f"Warning: Metadata extraction error: {e}", "orange") (f"Warning: Metadata extraction error: {e}", "orange")
+10 -9
View File
@@ -50,6 +50,7 @@ from abogen.domain.metadata_helpers import (
normalize_series_number as _normalize_series_number, normalize_series_number as _normalize_series_number,
extract_series_metadata as _extract_series_metadata, extract_series_metadata as _extract_series_metadata,
format_series_sentence as _format_series_sentence, format_series_sentence as _format_series_sentence,
build_metadata_payload as _build_metadata_payload,
) )
from abogen.domain.intro_outro import resolve_intro, resolve_outro from abogen.domain.intro_outro import resolve_intro, resolve_outro
from abogen.domain.title_builder import ( from abogen.domain.title_builder import (
@@ -852,15 +853,15 @@ def run_conversion_job(job: Job) -> None:
if not audio_path and chapter_paths: if not audio_path and chapter_paths:
job.result.audio_path = chapter_paths[0] job.result.audio_path = chapter_paths[0]
metadata_payload = { metadata_payload = _build_metadata_payload(
"metadata": dict(job.metadata_tags or {}), metadata=job.metadata_tags,
"chapters": chapter_markers, chapter_markers=chapter_markers,
"chunks": chunk_markers, chunk_markers=chunk_markers,
"chunk_level": job.chunk_level, chunk_level=job.chunk_level,
"speaker_mode": job.speaker_mode, speaker_mode=job.speaker_mode,
"speakers": dict(getattr(job, "speakers", {}) or {}), speakers=getattr(job, "speakers", None),
"generate_epub3": job.generate_epub3, generate_epub3=job.generate_epub3,
} )
if tts_context.usage_counter: if tts_context.usage_counter:
_record_override_usage(job, tts_context.usage_counter, override_token_map) _record_override_usage(job, tts_context.usage_counter, override_token_map)
+54
View File
@@ -10,6 +10,7 @@ from abogen.domain.metadata_helpers import (
normalize_series_number, normalize_series_number,
extract_series_metadata, extract_series_metadata,
format_series_sentence, format_series_sentence,
build_metadata_payload,
) )
@@ -131,3 +132,56 @@ class TestFormatSeriesSentence:
def test_with_the(self): def test_with_the(self):
assert format_series_sentence("The Lord of the Rings", "1") == "Book 1 of The Lord of the Rings" assert format_series_sentence("The Lord of the Rings", "1") == "Book 1 of The Lord of the Rings"
class TestBuildMetadataPayload:
def test_empty(self):
result = build_metadata_payload()
assert result == {
"metadata": {},
"chapters": [],
"chunks": [],
"chunk_level": None,
"speaker_mode": None,
"speakers": {},
"generate_epub3": False,
}
def test_with_metadata(self):
result = build_metadata_payload(metadata={"title": "Book", "artist": "Author"})
assert result["metadata"] == {"title": "Book", "artist": "Author"}
def test_metadata_is_copy(self):
original = {"title": "Book"}
result = build_metadata_payload(metadata=original)
result["metadata"]["title"] = "Changed"
assert original["title"] == "Book"
def test_with_chapters(self):
chapters = [{"title": "Ch1", "start": 0.0, "end": 10.0}]
result = build_metadata_payload(chapter_markers=chapters)
assert result["chapters"] == chapters
def test_with_all_fields(self):
result = build_metadata_payload(
metadata={"title": "Book"},
chapter_markers=[{"title": "Ch1", "start": 0.0, "end": 10.0}],
chunk_markers=[{"start": 0.0, "end": 5.0}],
chunk_level="chunk",
speaker_mode="multi",
speakers={"narrator": "M1"},
generate_epub3=True,
)
assert result["metadata"] == {"title": "Book"}
assert len(result["chapters"]) == 1
assert len(result["chunks"]) == 1
assert result["chunk_level"] == "chunk"
assert result["speaker_mode"] == "multi"
assert result["speakers"] == {"narrator": "M1"}
assert result["generate_epub3"] is True
def test_speakers_is_copy(self):
original = {"narrator": "M1"}
result = build_metadata_payload(speakers=original)
result["speakers"]["narrator"] = "M2"
assert original["narrator"] == "M1"