diff --git a/abogen/domain/title_builder.py b/abogen/domain/title_builder.py new file mode 100644 index 0000000..d3e5ca5 --- /dev/null +++ b/abogen/domain/title_builder.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional + +from .metadata_helpers import ( + ensure_sentence, + extract_series_metadata, + format_author_sentence, + format_series_sentence, + normalize_metadata_map, +) + + +def build_title_intro_text( + metadata: Optional[Mapping[str, Any]], + fallback_basename: str, +) -> str: + """Build the title introduction text from metadata.""" + normalized = normalize_metadata_map(metadata) + fallback_title = Path(fallback_basename).stem if fallback_basename else "" + title = ( + normalized.get("title") + or normalized.get("book_title") + or normalized.get("album") + or fallback_title + ) + if not title: + title = fallback_title + subtitle = normalized.get("subtitle") or normalized.get("sub_title") + if subtitle and title and subtitle.casefold() == title.casefold(): + subtitle = "" + + author_value = "" + for candidate in ("artist", "album_artist", "author", "authors", "writer", "composer"): + value = normalized.get(candidate) + if value: + author_value = value + break + + series_name, series_number = extract_series_metadata(normalized) + series_sentence = format_series_sentence(series_name, series_number) + + sentences: List[str] = [] + if series_sentence: + sentences.append(ensure_sentence(series_sentence)) + if title: + sentences.append(ensure_sentence(title)) + if subtitle: + sentences.append(ensure_sentence(subtitle)) + author_sentence = format_author_sentence(author_value) + if author_sentence: + sentences.append(ensure_sentence(author_sentence)) + return " ".join(sentences).strip() + + +def build_outro_text( + metadata: Optional[Mapping[str, Any]], + fallback_basename: str, +) -> str: + """Build the outro/closing text from metadata.""" + normalized = normalize_metadata_map(metadata) + fallback_title = Path(fallback_basename).stem if fallback_basename else "" + title = ( + normalized.get("title") + or normalized.get("book_title") + or normalized.get("album") + or fallback_title + ) + author_value = "" + for candidate in ("authors", "author", "album_artist", "artist", "writer", "composer"): + value = normalized.get(candidate) + if value: + author_value = value + break + author_sentence = format_author_sentence(author_value) + authors_fragment = ( + author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip() + ) + + if title and authors_fragment: + closing_line = f"The end of {title} from {authors_fragment}" + elif title: + closing_line = f"The end of {title}" + elif authors_fragment: + closing_line = f"The end from {authors_fragment}" + else: + closing_line = "The end" + + series_name, series_number = extract_series_metadata(normalized) + series_sentence = format_series_sentence(series_name, series_number) + + sentences: List[str] = [ensure_sentence(closing_line)] + if series_sentence: + sentences.append(ensure_sentence(series_sentence)) + + return " ".join(sentence for sentence in sentences if sentence).strip() \ No newline at end of file diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index 35e41b2..3a345b3 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -62,6 +62,10 @@ from abogen.domain.metadata_helpers import ( extract_series_metadata as _extract_series_metadata, format_series_sentence as _format_series_sentence, ) +from abogen.domain.title_builder import ( + build_title_intro_text as _build_title_intro_text, + build_outro_text as _build_outro_text, +) from .service import Job, JobStatus @@ -167,81 +171,6 @@ def _coerce_truthy(value: Any, default: bool = True) -> bool: _OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+") -def _build_title_intro_text( - metadata: Optional[Mapping[str, Any]], - fallback_basename: str, -) -> str: - normalized = _normalize_metadata_map(metadata) - fallback_title = Path(fallback_basename).stem if fallback_basename else "" - title = normalized.get("title") or normalized.get("book_title") or normalized.get("album") or fallback_title - if not title: - title = fallback_title - subtitle = normalized.get("subtitle") or normalized.get("sub_title") - if subtitle and title and subtitle.casefold() == title.casefold(): - subtitle = "" - author_value = "" - for candidate in ("artist", "album_artist", "author", "authors", "writer", "composer"): - value = normalized.get(candidate) - if value: - author_value = value - break - - series_name, series_number = _extract_series_metadata(normalized) - series_sentence = _format_series_sentence(series_name, series_number) - - sentences: List[str] = [] - if series_sentence: - sentences.append(_ensure_sentence(series_sentence)) - if title: - sentences.append(_ensure_sentence(title)) - if subtitle: - sentences.append(_ensure_sentence(subtitle)) - author_sentence = _format_author_sentence(author_value) - if author_sentence: - sentences.append(_ensure_sentence(author_sentence)) - return " ".join(sentences).strip() - - -def _build_outro_text( - metadata: Optional[Mapping[str, Any]], - fallback_basename: str, -) -> str: - normalized = _normalize_metadata_map(metadata) - fallback_title = Path(fallback_basename).stem if fallback_basename else "" - title = ( - normalized.get("title") - or normalized.get("book_title") - or normalized.get("album") - or fallback_title - ) - author_value = "" - for candidate in ("authors", "author", "album_artist", "artist", "writer", "composer"): - value = normalized.get(candidate) - if value: - author_value = value - break - author_sentence = _format_author_sentence(author_value) - authors_fragment = author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip() - - if title and authors_fragment: - closing_line = f"The end of {title} from {authors_fragment}" - elif title: - closing_line = f"The end of {title}" - elif authors_fragment: - closing_line = f"The end from {authors_fragment}" - else: - closing_line = "The end" - - series_name, series_number = _extract_series_metadata(normalized) - series_sentence = _format_series_sentence(series_name, series_number) - - sentences: List[str] = [_ensure_sentence(closing_line)] - if series_sentence: - sentences.append(_ensure_sentence(series_sentence)) - - return " ".join(sentence for sentence in sentences if sentence).strip() - - def _spec_to_voice_ids(spec: Any) -> Set[str]: text = str(spec or "").strip() if not text: diff --git a/tests/test_title_builder.py b/tests/test_title_builder.py new file mode 100644 index 0000000..af6dcd0 --- /dev/null +++ b/tests/test_title_builder.py @@ -0,0 +1,59 @@ +"""Tests for domain/title_builder.py.""" +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from abogen.domain.title_builder import build_title_intro_text, build_outro_text + + +class TestBuildTitleIntroText: + def test_empty_metadata(self): + result = build_title_intro_text({}, "book.epub") + assert result == "book." + + def test_title_from_metadata(self): + result = build_title_intro_text({"title": "My Book"}, "book.epub") + assert result == "My Book." + + def test_title_fallback_basename(self): + result = build_title_intro_text({}, "my_book.epub") + assert result == "my_book." + + def test_with_author(self): + result = build_title_intro_text({"title": "My Book", "author": "John Doe"}, "book.epub") + assert result == "My Book. By John Doe." + + def test_with_subtitle(self): + result = build_title_intro_text({"title": "My Book", "subtitle": "A Tale"}, "book.epub") + assert result == "My Book. A Tale." + + def test_duplicate_title_subtitle(self): + result = build_title_intro_text({"title": "My Book", "subtitle": "My Book"}, "book.epub") + assert result == "My Book." + + def test_with_series(self): + result = build_title_intro_text({"title": "My Book", "series": "Series", "series_index": "3"}, "book.epub") + assert result == "Book 3 of the Series. My Book." + + +class TestBuildOutroText: + def test_empty(self): + result = build_outro_text({}, "book.epub") + assert result == "The end of book." + + def test_title_only(self): + result = build_outro_text({"title": "My Book"}, "book.epub") + assert result == "The end of My Book." + + def test_author_only(self): + result = build_outro_text({"author": "John Doe"}, "book.epub") + assert result == "The end of book from John Doe." + + def test_title_and_author(self): + result = build_outro_text({"title": "My Book", "author": "John Doe"}, "book.epub") + assert result == "The end of My Book from John Doe." + + def test_with_series(self): + result = build_outro_text({"title": "My Book", "series": "Series", "series_index": "3"}, "book.epub") + assert "The end of My Book." in result + assert "Book 3 of the Series." in result \ No newline at end of file