From 7ca030d67dac377d962d37927a3d91664eb1d473 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 14 Oct 2025 06:24:15 -0700 Subject: [PATCH] feat: Add auto-prefix option for chapter titles and enhance reader functionality - Introduced `auto_prefix_chapter_titles` setting in Job and PendingJob classes to control prefixing of chapter titles with "Chapter". - Updated job detail and settings templates to display and configure the new option. - Enhanced reader.js to manage playback controls, including chapter navigation and playback speed adjustments. - Implemented a new prepare_chapters.html template for chapter selection and configuration during job preparation. - Added tests for chapter title formatting and heading equivalence to ensure correct behavior of the new feature. --- abogen/web/conversion_runner.py | 123 ++++- abogen/web/routes.py | 112 ++++- abogen/web/service.py | 8 + abogen/web/static/reader.js | 12 +- abogen/web/templates/job_detail.html | 1 + abogen/web/templates/partials/jobs.html | 6 +- abogen/web/templates/prepare_chapters.html | 187 ++++++++ abogen/web/templates/prepare_job.html | 3 + abogen/web/templates/reader_embed.html | 518 ++++++++++++++++++++- abogen/web/templates/settings.html | 7 + tests/test_conversion_chapter_titles.py | 27 ++ 11 files changed, 960 insertions(+), 44 deletions(-) create mode 100644 abogen/web/templates/prepare_chapters.html create mode 100644 abogen/web/templates/prepare_job.html create mode 100644 tests/test_conversion_chapter_titles.py diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 6f4d630..97e0100 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -66,6 +66,67 @@ def _coerce_truthy(value: Any, default: bool = True) -> bool: return bool(value) +_HEADING_SANITIZE_RE = re.compile(r"[^a-z0-9]+") +_HEADING_NUMBER_PREFIX_RE = re.compile(r"^\s*(?P(?:\d+|[ivxlcdm]+))(?P(?:[\s.:;-].*)?)$", re.IGNORECASE) + + +def _simplify_heading_text(text: str) -> str: + raw = str(text or "").strip().lower() + if not raw: + return "" + simplified = _HEADING_SANITIZE_RE.sub("", raw) + if simplified.startswith("chapter"): + simplified = simplified[7:] + return simplified + + +def _headings_equivalent(left: str, right: str) -> bool: + simple_left = _simplify_heading_text(left) + simple_right = _simplify_heading_text(right) + return bool(simple_left and simple_left == simple_right) + + +def _format_spoken_chapter_title(title: str, index: int, apply_prefix: bool) -> str: + base = str(title or "").strip() + if not base: + return f"Chapter {index}" if apply_prefix else "" + if not apply_prefix: + return base + lowered = base.lower() + if lowered.startswith("chapter") and (len(lowered) == 7 or not lowered[7].isalpha()): + return base + match = _HEADING_NUMBER_PREFIX_RE.match(base) + if match: + number = match.group("number") or "" + suffix = match.group("suffix") or "" + return f"Chapter {number}{suffix}" + return base + + +def _strip_duplicate_heading_line(text: str, heading: str) -> tuple[str, bool]: + source_text = str(text or "") + if not source_text: + return source_text, False + normalized_heading = _simplify_heading_text(heading) + if not normalized_heading: + return source_text, False + lines = source_text.splitlines() + new_lines: List[str] = [] + removed = False + for line in lines: + stripped = line.strip() + if not removed and stripped: + if _headings_equivalent(stripped, heading): + removed = True + continue + new_lines.append(line) + if not removed: + return source_text, False + while new_lines and not new_lines[0].strip(): + new_lines.pop(0) + return "\n".join(new_lines), True + + def _spec_to_voice_ids(spec: Any) -> Set[str]: text = str(spec or "").strip() if not text: @@ -909,6 +970,7 @@ def run_conversion_job(job: Job) -> None: idx: items for idx, items in chunk_groups.items() if 0 <= idx < total_chapters } job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") + auto_prefix_titles = getattr(job, "auto_prefix_chapter_titles", True) def emit_text( text: str, @@ -992,7 +1054,11 @@ def run_conversion_job(job: Job) -> None: for idx, chapter in enumerate(extraction.chapters, start=1): canceller() - job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}") + raw_title = str(getattr(chapter, "title", "") or "").strip() + spoken_title = _format_spoken_chapter_title(raw_title, idx, auto_prefix_titles) + heading_text = spoken_title or raw_title + chapter_display_title = heading_text or f"Chapter {idx}" + job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter_display_title}") chapter_start_time = current_time chapter_override = ( @@ -1016,7 +1082,7 @@ def run_conversion_job(job: Job) -> None: if chapter_dir is not None: chapter_audio_path = _build_output_path( chapter_dir, - f"{Path(job.original_filename).stem}_{_slugify(chapter.title, idx)}", + f"{Path(job.original_filename).stem}_{_slugify(chapter_display_title, idx)}", job.separate_chapters_format, ) chapter_sink = _open_audio_sink( @@ -1026,17 +1092,18 @@ def run_conversion_job(job: Job) -> None: fmt=job.separate_chapters_format, ) - speak_heading = bool(chapter.title.strip()) - if speak_heading: - stripped_title = chapter.title.strip() - if stripped_title: - first_line = next((line.strip() for line in chapter.text.splitlines() if line.strip()), "") - if first_line and first_line.casefold() == stripped_title.casefold(): - speak_heading = False + speak_heading = bool(heading_text) + first_line = "" + if chapter.text: + first_line = next((line.strip() for line in chapter.text.splitlines() if line.strip()), "") + remove_heading_from_body = False + if speak_heading and first_line: + if _headings_equivalent(first_line, heading_text) or (raw_title and _headings_equivalent(first_line, raw_title)): + remove_heading_from_body = True if speak_heading: heading_segments = emit_text( - chapter.title, + heading_text, voice_choice=voice_choice, chapter_sink=chapter_sink, preview_prefix=f"Chapter {idx} title", @@ -1052,6 +1119,7 @@ def run_conversion_job(job: Job) -> None: chunks_for_chapter = chunk_groups.get(idx - 1, []) if chunk_groups else [] body_segments = 0 + pending_heading_strip = remove_heading_from_body if chunks_for_chapter: job.add_log( f"Emitting {len(chunks_for_chapter)} {job.chunk_level} chunks for chapter {idx}.", @@ -1066,6 +1134,15 @@ def run_conversion_job(job: Job) -> None: if not chunk_text: continue + if pending_heading_strip and heading_text: + chunk_text, removed_heading = _strip_duplicate_heading_line(chunk_text, heading_text) + if removed_heading: + pending_heading_strip = False + chunk_entry = dict(chunk_entry) + chunk_entry["normalized_text"] = chunk_text + if not chunk_text.strip(): + continue + chunk_voice_spec = _chunk_voice_spec( job, chunk_entry, @@ -1116,8 +1193,13 @@ def run_conversion_job(job: Job) -> None: if body_segments == 0: chapter_body_start = current_time + chapter_text = chapter.text + if pending_heading_strip and heading_text: + chapter_text, removed_heading = _strip_duplicate_heading_line(chapter_text, heading_text) + if removed_heading: + pending_heading_strip = False emitted = emit_text( - chapter.text, + chapter_text, voice_choice=voice_choice, chapter_sink=chapter_sink, ) @@ -1169,15 +1251,16 @@ def run_conversion_job(job: Job) -> None: ) chapter_end_time = current_time - chapter_markers.append( - { - "index": idx, - "title": chapter.title, - "start": chapter_start_time, - "end": chapter_end_time, - "voice": chapter_voice_spec, - } - ) + marker = { + "index": idx, + "title": chapter_display_title, + "start": chapter_start_time, + "end": chapter_end_time, + "voice": chapter_voice_spec, + } + if raw_title and raw_title != chapter_display_title: + marker["original_title"] = raw_title + chapter_markers.append(marker) if not audio_path and chapter_paths: job.result.audio_path = chapter_paths[0] diff --git a/abogen/web/routes.py b/abogen/web/routes.py index f4e9ace..6e1349e 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -2,6 +2,7 @@ from __future__ import annotations import io import json +import math import mimetypes import os import posixpath @@ -196,6 +197,51 @@ def _decode_text(payload: bytes) -> str: return payload.decode("utf-8", "ignore") +def _coerce_positive_time(value: Any) -> Optional[float]: + try: + numeric = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(numeric) or numeric < 0: + return None + return numeric + + +def _load_job_metadata(job: Job) -> Dict[str, Any]: + result = getattr(job, "result", None) + artifacts = getattr(result, "artifacts", None) + if not isinstance(artifacts, Mapping): + return {} + metadata_ref = artifacts.get("metadata") + if isinstance(metadata_ref, Path): + metadata_path = metadata_ref + elif isinstance(metadata_ref, str): + metadata_path = Path(metadata_ref) + else: + return {} + if not metadata_path.exists(): + return {} + try: + return json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return {} + + +def _resolve_book_title(job: Job, *metadata_sources: Mapping[str, Any]) -> str: + for source in metadata_sources: + if not isinstance(source, Mapping): + continue + for key in ("title", "book_title", "name", "album", "album_title"): + value = source.get(key) + if isinstance(value, str): + candidate = value.strip() + if candidate: + return candidate + filename = job.original_filename or "" + stem = Path(filename).stem if filename else "" + return stem or filename + + class _NavMapParser(HTMLParser): def __init__(self, base_dir: str) -> None: super().__init__() @@ -1815,6 +1861,7 @@ BOOLEAN_SETTINGS = { "save_as_project", "generate_epub3", "enable_entity_recognition", + "auto_prefix_chapter_titles", } FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"} @@ -1841,8 +1888,9 @@ def _settings_defaults() -> Dict[str, Any]: "chapter_intro_delay": 0.5, "max_subtitle_words": 50, "chunk_level": "paragraph", - "enable_entity_recognition": True, + "enable_entity_recognition": True, "generate_epub3": False, + "auto_prefix_chapter_titles": True, "speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD, "speaker_pronunciation_sentence": "This is {{name}} speaking.", "speaker_random_languages": [], @@ -2946,6 +2994,7 @@ def enqueue_job() -> ResponseReturnValue: silence_between_chapters = settings["silence_between_chapters"] chapter_intro_delay = settings["chapter_intro_delay"] max_subtitle_words = settings["max_subtitle_words"] + auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"] chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower() raw_chunk_level = (request.form.get("chunk_level") or chunk_level_default).strip().lower() @@ -3011,6 +3060,7 @@ def enqueue_job() -> ResponseReturnValue: cover_image_path=cover_path, cover_image_mime=cover_mime, chapter_intro_delay=chapter_intro_delay, + auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), chunk_level=chunk_level_value, speaker_mode=speaker_mode_value, generate_epub3=generate_epub3, @@ -3290,6 +3340,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: cover_image_path=pending.cover_image_path, cover_image_mime=pending.cover_image_mime, chapter_intro_delay=pending.chapter_intro_delay, + auto_prefix_chapter_titles=getattr(pending, "auto_prefix_chapter_titles", True), chunk_level=pending.chunk_level, chunks=processed_chunks, speakers=roster, @@ -3581,6 +3632,63 @@ def job_reader(job_id: str) -> ResponseReturnValue: asset_base = url_for("web.job_reader_asset", job_id=job.id, asset_path="").rstrip("/") + "/" audio_url = url_for("web.job_audio_stream", job_id=job.id) if audio_path else "" epub_url = url_for("web.job_epub", job_id=job.id) + metadata_payload = _load_job_metadata(job) + metadata_section_raw = metadata_payload.get("metadata") if isinstance(metadata_payload, Mapping) else {} + metadata_section = metadata_section_raw if isinstance(metadata_section_raw, Mapping) else {} + job_metadata = job.metadata_tags if isinstance(job.metadata_tags, Mapping) else {} + display_title = _resolve_book_title(job, metadata_section, job_metadata) + + timing_map: Dict[int, Dict[str, Any]] = {} + chapter_entries = metadata_payload.get("chapters") if isinstance(metadata_payload, Mapping) else [] + for entry in chapter_entries or []: + if not isinstance(entry, Mapping): + continue + index_raw = entry.get("index") + index_value: Optional[int] + if isinstance(index_raw, (int, float)) and not isinstance(index_raw, bool): + index_value = int(index_raw) - 1 + elif isinstance(index_raw, str): + stripped = index_raw.strip() + if not stripped: + continue + try: + index_value = int(stripped) - 1 + except ValueError: + continue + else: + continue + if index_value < 0: + continue + start_value = _coerce_positive_time(entry.get("start")) + end_value = _coerce_positive_time(entry.get("end")) + title_value: Optional[str] = None + for key in ("title", "display_title", "spoken_title", "original_title"): + value = entry.get(key) + if isinstance(value, str) and value.strip(): + title_value = value.strip() + break + timing_map[index_value] = { + "start": start_value, + "end": end_value, + "title": title_value, + } + + chapter_timings: List[Dict[str, Any]] = [] + for idx, chapter in enumerate(chapters): + marker = timing_map.get(idx) + if marker and marker.get("title") and isinstance(chapter, dict): + chapter_title = marker["title"] + if isinstance(chapter_title, str) and chapter_title.strip(): + chapter["title"] = chapter_title + chapter_timings.append( + { + "index": idx, + "start": marker.get("start") if marker else None, + "end": marker.get("end") if marker else None, + "title": marker.get("title") if marker else None, + } + ) + return render_template( "reader_embed.html", job=job, @@ -3589,6 +3697,8 @@ def job_reader(job_id: str) -> ResponseReturnValue: chapters=chapters, chapter_url=chapter_url, asset_base=asset_base, + chapter_timings=chapter_timings, + display_title=display_title, ) diff --git a/abogen/web/service.py b/abogen/web/service.py index d303204..2bb907b 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -105,6 +105,7 @@ class Job: metadata_tags: Dict[str, str] = field(default_factory=dict) max_subtitle_words: int = 50 chapter_intro_delay: float = 0.5 + auto_prefix_chapter_titles: bool = True status: JobStatus = JobStatus.PENDING started_at: Optional[float] = None finished_at: Optional[float] = None @@ -171,6 +172,7 @@ class Job: "voice_profile": self.voice_profile, "max_subtitle_words": self.max_subtitle_words, "chapter_intro_delay": self.chapter_intro_delay, + "auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True), }, "metadata_tags": dict(self.metadata_tags), "chapters": [ @@ -233,6 +235,7 @@ class PendingJob: cover_image_path: Optional[Path] = None cover_image_mime: Optional[str] = None chapter_intro_delay: float = 0.5 + auto_prefix_chapter_titles: bool = True chunk_level: str = "paragraph" chunks: List[Dict[str, Any]] = field(default_factory=list) speakers: Dict[str, Any] = field(default_factory=dict) @@ -312,6 +315,7 @@ class ConversionService: cover_image_path: Optional[Path] = None, cover_image_mime: Optional[str] = None, chapter_intro_delay: float = 0.5, + auto_prefix_chapter_titles: bool = True, chunk_level: str = "paragraph", chunks: Optional[Iterable[Any]] = None, speakers: Optional[Mapping[str, Any]] = None, @@ -358,6 +362,7 @@ class ConversionService: cover_image_path=cover_image_path, cover_image_mime=cover_image_mime, chapter_intro_delay=chapter_intro_delay, + auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), chunk_level=chunk_level, chunks=normalized_chunks, speakers=dict(speakers or {}), @@ -512,6 +517,7 @@ class ConversionService: cover_image_path=job.cover_image_path, cover_image_mime=job.cover_image_mime, chapter_intro_delay=job.chapter_intro_delay, + auto_prefix_chapter_titles=job.auto_prefix_chapter_titles, chunk_level=job.chunk_level, chunks=job.chunks, speakers=job.speakers, @@ -737,6 +743,7 @@ class ConversionService: "cover_image_path": str(job.cover_image_path) if job.cover_image_path else None, "cover_image_mime": job.cover_image_mime, "chapter_intro_delay": job.chapter_intro_delay, + "auto_prefix_chapter_titles": job.auto_prefix_chapter_titles, "chunk_level": job.chunk_level, "chunks": [dict(entry) for entry in job.chunks], "speakers": dict(job.speakers), @@ -828,6 +835,7 @@ class ConversionService: metadata_tags=payload.get("metadata_tags", {}), max_subtitle_words=int(payload.get("max_subtitle_words", 50)), chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)), + auto_prefix_chapter_titles=bool(payload.get("auto_prefix_chapter_titles", True)), ) job.status = JobStatus(payload.get("status", job.status.value)) job.started_at = payload.get("started_at") diff --git a/abogen/web/static/reader.js b/abogen/web/static/reader.js index 5f10c66..fd26820 100644 --- a/abogen/web/static/reader.js +++ b/abogen/web/static/reader.js @@ -41,7 +41,17 @@ const closeReaderModal = () => { readerModal.removeAttribute("data-open"); document.body.classList.remove("modal-open"); if (readerFrame) { - readerFrame.src = "about:blank"; + const frameWindow = readerFrame.contentWindow; + if (frameWindow) { + try { + frameWindow.postMessage({ type: "abogen:reader:pause", currentTime: 0 }, window.location.origin); + } catch (error) { + // Ignore cross-origin messaging errors. + } + } + window.setTimeout(() => { + readerFrame.src = "about:blank"; + }, 75); } if (readerHint && defaultReaderHint) { readerHint.textContent = defaultReaderHint; diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index 3202e99..6e84146 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -24,6 +24,7 @@
  • Separate chapter format: {{ job.separate_chapters_format|upper }}
  • Silence between chapters: {{ '%.1f'|format(job.silence_between_chapters) }}s
  • Chapter intro delay: {{ '%.1f'|format(job.chapter_intro_delay) }}s
  • +
  • Prefix chapter titles: {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}
  • Max words per subtitle: {{ job.max_subtitle_words }}
  • Project folder: {{ 'Yes' if job.save_as_project else 'No' }}
  • Chunk granularity: {{ job.chunk_level|replace('_', ' ')|title }}
  • diff --git a/abogen/web/templates/partials/jobs.html b/abogen/web/templates/partials/jobs.html index 2cc857b..4cb25e5 100644 --- a/abogen/web/templates/partials/jobs.html +++ b/abogen/web/templates/partials/jobs.html @@ -92,13 +92,15 @@ {% endif %} {% endif %} {% if reader_source %} + {% set metadata = job.metadata_tags if job.metadata_tags is mapping else {} %} + {% set job_display_title = metadata.get('title') or metadata.get('book_title') or metadata.get('name') or job.original_filename %} {% endif %} diff --git a/abogen/web/templates/prepare_chapters.html b/abogen/web/templates/prepare_chapters.html new file mode 100644 index 0000000..b545dfa --- /dev/null +++ b/abogen/web/templates/prepare_chapters.html @@ -0,0 +1,187 @@ +{% extends "base.html" %} + +{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} + +{% set is_multi_speaker = pending.speaker_mode == 'multi' %} +{% set total_steps = 3 if is_multi_speaker else 2 %} + +{% block content %} +
    + +
    +{% with pending=pending, readonly=True, active_step='chapters' %} + {% include "partials/upload_modal.html" %} +{% endwith %} +{% endblock %} + +{% block scripts %} + {{ super() }} + + + + + + +{% endblock %} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html new file mode 100644 index 0000000..a349ad4 --- /dev/null +++ b/abogen/web/templates/prepare_job.html @@ -0,0 +1,3 @@ +{# This template is intentionally left empty. + Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`. + The file is kept as a placeholder to avoid breaking documentation references. #} diff --git a/abogen/web/templates/reader_embed.html b/abogen/web/templates/reader_embed.html index a80c459..2ca430f 100644 --- a/abogen/web/templates/reader_embed.html +++ b/abogen/web/templates/reader_embed.html @@ -3,7 +3,7 @@ - {{ job.original_filename }} · Reader + {{ display_title or job.original_filename }} · Reader