From b80fa9db6bab6cdb2856874b606a93149cfc1af2 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 17 Aug 2026 19:19:40 -0400 Subject: feat(converter): add per-chapter and m4b output --- README.md | 30 +++++++++- audiobook_converter.py | 16 +++++ converter/audio.py | 152 +++++++++++++++++++++++++++++++++++++++++++---- converter/config.py | 7 ++- converter/converter.py | 90 ++++++++++++++++++++++++---- converter/extractors.py | 130 ++++++++++++++++++++++++++++++++++++++++ tests/test_audio.py | 46 +++++++++++++- tests/test_converter.py | 21 +++++++ tests/test_extractors.py | 34 +++++++++++ 9 files changed, 496 insertions(+), 30 deletions(-) create mode 100644 tests/test_converter.py diff --git a/README.md b/README.md index 44422b9..17568eb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Original project: [https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter](ht The converter sends text extracted from your books to a locally running Qwen3-TTS server and assembles the returned audio into a single audiobook file. - Supported input: `.txt`, `.pdf`, `.epub` -- Output: `.mp3` +- Output: `.mp3` or `.m4b` - Two voice modes: - Custom voice: pre-built speakers - Voice clone: clone a voice from a `.wav` reference audio file @@ -67,7 +67,7 @@ qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-Base --ip 127.0.0.1 --port 7861 ## Converting books -Put your book files (epub, txt, etc.) in the `book_to_convert/` folder. Then run the script. The output mp3 goes to `audiobooks/`. +Put your book files (epub, txt, etc.) in the `input/` folder. Then run the script. The output goes to `output/`. ### Custom voice @@ -105,6 +105,32 @@ Adjust the speed of the final audiobook without changing pitch (uses ffmpeg `ate python audiobook_converter.py --speed 0.9 ``` +### Output format (mp3 / m4b) + +Use `--format` to choose the output container. The default is `mp3`; `m4b` uses AAC audio (ffmpeg `aac`). + +```bash +python audiobook_converter.py --format m4b +``` + +### Chapters (EPUB) + +Books with chapters (e.g. EPUB) are converted to **one file per chapter** by default. Files are named `output/_01_.mp3`, `output/_02_.mp3`, and so on. + +To merge all chapters into a single file instead, pass `--single-file`: + +```bash +python audiobook_converter.py --single-file +``` + +When the source has chapters and the output is a single `m4b`, chapter markers are embedded so listeners can skip between chapters: + +```bash +python audiobook_converter.py --format m4b --single-file +``` + +TXT and PDF files have no chapter structure and always produce a single file. + The `chunks/` folder is scratch space for the current book only — it is emptied before and after every conversion, so an interrupted run never affects the next one. ## Running tests diff --git a/audiobook_converter.py b/audiobook_converter.py index d2e7f41..783a3ce 100644 --- a/audiobook_converter.py +++ b/audiobook_converter.py @@ -76,6 +76,20 @@ Examples: help="Playback speed factor for the final audiobook (1.0 = normal). Pitch-preserving." ) + parser.add_argument( + "--format", + choices=["mp3", "m4b"], + default="mp3", + help="Output container format (default: mp3). m4b uses AAC audio." + ) + + parser.add_argument( + "--single-file", + action="store_true", + help=("Combine all chapters into a single output file. By default books with " + "chapters (e.g. EPUB) are converted to one file per chapter.") + ) + args = parser.parse_args() if args.speed <= 0: @@ -100,6 +114,8 @@ Examples: voice_clone_ref_text=args.voice_sample_text if args.voice_clone else None, skip_transcription=args.no_transcription, speed=args.speed, + single_file=args.single_file, + output_format=args.format, ) ok = converter.run() except KeyboardInterrupt: diff --git a/converter/audio.py b/converter/audio.py index e7fa0bb..2f40777 100644 --- a/converter/audio.py +++ b/converter/audio.py @@ -47,8 +47,16 @@ def _concat_escape(path: str) -> str: return path.replace("'", "'\\''") +def _encode_args(output_format: str) -> List[str]: + """Return ffmpeg output codec/bitrate args for the requested container.""" + if output_format == "m4b": + return ["-c:a", "aac", "-b:a", config.AUDIO_BITRATE] + return ["-b:a", config.AUDIO_BITRATE] + + def combine_chunks(total_chunks: int, output_path: Path, - results: Optional[Dict[int, bool]] = None, speed: float = 1.0) -> bool: + results: Optional[Dict[int, bool]] = None, speed: float = 1.0, + output_format: str = "mp3") -> bool: """Combine audio chunks into the final audiobook using ffmpeg's concat demuxer. ``results`` maps chunk numbers to success flags; failed chunks are @@ -88,20 +96,21 @@ def combine_chunks(total_chunks: int, output_path: Path, list_file.write(f"file '{_concat_escape(str(chunk_file))}'\n") filters = atempo_filters(speed) + encode = _encode_args(output_format) if filters: speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}") cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-filter_complex", f"[0:a]split=2[base][spd];[spd]{filters}[spdout]", - "-map", "[base]", "-b:a", config.AUDIO_BITRATE, str(output_path), - "-map", "[spdout]", "-b:a", config.AUDIO_BITRATE, str(speed_path), + "-map", "[base]", *encode, str(output_path), + "-map", "[spdout]", *encode, str(speed_path), ] else: speed_path = None cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), - "-b:a", config.AUDIO_BITRATE, str(output_path), + *encode, str(output_path), ] proc = subprocess.run(cmd, capture_output=True, text=True) @@ -136,19 +145,138 @@ def combine_chunks(total_chunks: int, output_path: Path, def cleanup_chunks() -> None: - """Remove temporary chunk files from the scratch folder.""" + """Remove temporary chunk and chapter files from the scratch folder.""" try: chunk_count = 0 - for chunk_file in config.CHUNKS_FOLDER.glob("chunk_*"): - try: - if chunk_file.is_file(): - chunk_file.unlink() - chunk_count += 1 - except Exception as exc: - logger.warning("Failed to delete %s: %s", chunk_file, exc) + for pattern in ("chunk_*", "chapter_*"): + for chunk_file in config.CHUNKS_FOLDER.glob(pattern): + try: + if chunk_file.is_file(): + chunk_file.unlink() + chunk_count += 1 + except Exception as exc: + logger.warning("Failed to delete %s: %s", chunk_file, exc) if chunk_count > 0: logger.info("Cleaned up %d chunk files", chunk_count) print(f"[INFO] Cleaned up {chunk_count} chunk files") except Exception as exc: logger.warning("Cleanup failed: %s", exc) + + +def probe_duration_ms(path: Path) -> int: + """Return audio duration in milliseconds using ffprobe.""" + result = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", str(path)], + capture_output=True, text=True, + ) + if result.returncode != 0: + logger.warning("ffprobe failed for %s: %s", path, result.stderr[-200:]) + return 0 + try: + return max(0, int(round(float(result.stdout.strip()) * 1000.0))) + except ValueError: + logger.warning("Could not parse ffprobe duration for %s", path) + return 0 + + +def build_ffmetadata(chapters: List[tuple], path: Path) -> None: + """Write an ffmpeg FFMETADATA file with ``[CHAPTER]`` entries. + + ``chapters`` is a list of ``(start_ms, end_ms, title)`` tuples. + """ + with open(path, "w", encoding="utf-8") as meta_file: + meta_file.write(";FFMETADATA1\n") + for start_ms, end_ms, title in chapters: + meta_file.write("[CHAPTER]\n") + meta_file.write("TIMEBASE=1/1000\n") + meta_file.write(f"START={int(start_ms)}\n") + meta_file.write(f"END={int(end_ms)}\n") + meta_file.write(f"title={title}\n") + + +def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], + output_path: Path, speed: float = 1.0) -> bool: + """Concatenate per-chapter audio into a single m4b with embedded chapter markers. + + Chapter start/end times are derived from each chapter file's duration and + written as ffmpeg chapter metadata. When ``speed`` differs from 1.0, a + speed-adjusted copy (with rescaled chapter markers) is written alongside + the normal-speed file. + """ + if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: + logger.error("ffmpeg and ffprobe are required to build an m4b with chapters") + return False + + if not chapter_files: + logger.error("No chapter files provided") + return False + + concat_list = config.CHUNKS_FOLDER / "_concat_list.txt" + metadata_file = config.CHUNKS_FOLDER / "_chapters.txt" + speed_metadata_file = config.CHUNKS_FOLDER / "_chapters_speed.txt" + try: + chapters = [] + start_ms = 0 + with open(concat_list, "w", encoding="utf-8") as list_file: + for chapter_file, title in zip(chapter_files, titles): + list_file.write(f"file '{_concat_escape(str(chapter_file))}'\n") + duration_ms = probe_duration_ms(chapter_file) + end_ms = start_ms + duration_ms + chapters.append((start_ms, end_ms, title or "Chapter")) + start_ms = end_ms + + build_ffmetadata(chapters, metadata_file) + + filters = atempo_filters(speed) + encode = _encode_args("m4b") + if filters: + speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}") + scaled = [(int(s / speed), int(e / speed), t) for s, e, t in chapters] + build_ffmetadata(scaled, speed_metadata_file) + # The speed-adjusted stream needs rescaled chapter markers, so the + # rescaled metadata is passed as a third input. + cmd = [ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), + "-i", str(metadata_file), "-i", str(speed_metadata_file), + "-filter_complex", + f"[0:a]split=2[base][spd];[spd]{filters}[spdout]", + "-map", "[base]", "-map_metadata", "1", *encode, str(output_path), + "-map", "[spdout]", "-map_metadata", "2", *encode, str(speed_path), + ] + else: + speed_path = None + cmd = [ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), + "-i", str(metadata_file), + "-map", "0:a", "-map_metadata", "1", *encode, str(output_path), + ] + + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + logger.error("ffmpeg failed: %s", proc.stderr[-2000:]) + return False + + logger.info("Audiobook saved: %s (%d chapters)", output_path, len(chapter_files)) + print(f"[INFO] Saved audiobook: {output_path.name} ({len(chapter_files)} chapters)") + + if speed_path is not None: + logger.info("Saved speed-adjusted audiobook (%gx): %s", speed, speed_path) + print(f"[INFO] Saved speed-adjusted audiobook: {speed_path.name} ({speed:g}x)") + + return True + + except FileNotFoundError: + logger.error("ffmpeg/ffprobe not found on PATH (install ffmpeg and try again)") + return False + except Exception as exc: + logger.error("Failed to combine chapters: %s", exc) + logger.error(traceback.format_exc()) + return False + finally: + for scratch in (concat_list, metadata_file, speed_metadata_file): + try: + scratch.unlink(missing_ok=True) + except OSError: + pass diff --git a/converter/config.py b/converter/config.py index 1b203d2..5542bae 100644 --- a/converter/config.py +++ b/converter/config.py @@ -61,8 +61,8 @@ VOICE_CLONE_API_URL = "http://127.0.0.1:7861" # PROCESSING SETTINGS # ============================================================================= -BOOKS_FOLDER = BASE_DIR / "book_to_convert" # Input folder -AUDIOBOOKS_FOLDER = BASE_DIR / "audiobooks" # Output folder +BOOKS_FOLDER = BASE_DIR / "input" # Input folder +AUDIOBOOKS_FOLDER = BASE_DIR / "output" # Output folder CHUNKS_FOLDER = BASE_DIR / "chunks" # Scratch space for per-chunk audio (cleaned per book) LOGS_FOLDER = BASE_DIR / "logs" @@ -74,7 +74,8 @@ HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" this often during a chu # AUDIO OUTPUT SETTINGS # ============================================================================= -AUDIO_FORMAT = "mp3" +AUDIO_FORMAT = "mp3" # Default output container ("mp3" or "m4b") +AUDIO_FORMATS = ("mp3", "m4b") AUDIO_BITRATE = "128k" SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"] diff --git a/converter/converter.py b/converter/converter.py index 65efb68..ce2c351 100644 --- a/converter/converter.py +++ b/converter/converter.py @@ -1,6 +1,7 @@ """Orchestrates book-to-audiobook conversion.""" import logging +import re import sys import time import traceback @@ -42,12 +43,16 @@ class AudiobookConverter: def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, - speed: float = 1.0): + speed: float = 1.0, single_file: bool = False, output_format: str = "mp3"): if speed <= 0: raise ValueError(f"Speed must be a positive number, got {speed}") + if output_format not in config.AUDIO_FORMATS: + raise ValueError(f"Unsupported output format: {output_format}") self.voice_mode = voice_mode self.voice_clone_ref_audio = voice_clone_ref_audio self.speed = speed + self.single_file = single_file + self.output_format = output_format self._validate_configuration() self.tts = QwenTTSClient( voice_mode=voice_mode, @@ -70,8 +75,15 @@ class AudiobookConverter: f"Reference audio file not found: {self.voice_clone_ref_audio}" ) + @staticmethod + def _sanitize_filename(name: str) -> str: + """Make a chapter title safe to use as part of a file name.""" + cleaned = re.sub(r'[\\/:*?"<>|]', " ", name) + cleaned = re.sub(r"\s+", " ", cleaned).strip().strip(".") + return cleaned[:80] or "chapter" + def convert_book(self, file_path: Path, output_name: Optional[str] = None) -> bool: - """Convert a single book to an audiobook.""" + """Convert a single book to one or more audiobook files.""" logger.info("Converting: %s", file_path.name) start_time = time.time() @@ -80,13 +92,70 @@ class AudiobookConverter: # affect this run audio.cleanup_chunks() - # Extract text logger.info("Extracting text...") - text = extractors.extract_text(file_path) - if not text.strip(): + sections = extractors.extract_sections(file_path) + if not sections or all(not s.text.strip() for s in sections): logger.error("No text extracted") return False + stem = output_name or file_path.stem + embed_chapters = (self.output_format == "m4b" and self.single_file + and len(sections) > 1) + if embed_chapters: + return self._convert_single_m4b_with_chapters(sections, stem, start_time) + + if self.single_file or len(sections) == 1: + text = "\n\n".join(section.text for section in sections) + output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}" + return self._convert_text(text, output_path, start_time) + + success = True + for index, section in enumerate(sections, 1): + chapter_name = f"{stem}_{index:02d}_{self._sanitize_filename(section.title)}" + output_path = config.AUDIOBOOKS_FOLDER / f"{chapter_name}.{self.output_format}" + success = self._convert_text(section.text, output_path, start_time) and success + return success + + except Exception as exc: + logger.error("Conversion failed: %s", exc) + logger.error(traceback.format_exc()) + return False + finally: + # Always cleanup, even on failure or interrupt + audio.cleanup_chunks() + + def _convert_single_m4b_with_chapters(self, sections, stem: str, start_time: float) -> bool: + """Convert each chapter to audio, then assemble a single m4b with + embedded chapter markers.""" + chapter_files = [] + titles = [] + for index, section in enumerate(sections, 1): + chapter_path = config.CHUNKS_FOLDER / f"chapter_{index:04d}.{self.output_format}" + if not self._convert_text(section.text, chapter_path, start_time, speed=1.0): + logger.warning("Skipping chapter %d (%s) due to conversion failure", + index, section.title) + continue + chapter_files.append(chapter_path) + titles.append(section.title or f"Chapter {index}") + + if not chapter_files: + logger.error("No chapters were successfully converted") + return False + + output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}" + return audio.combine_chapters_to_m4b(chapter_files, titles, output_path, speed=self.speed) + + def _convert_text(self, text: str, output_path: Path, start_time: float, + speed: Optional[float] = None) -> bool: + """Chunk, synthesize, and assemble ``text`` into ``output_path``.""" + if speed is None: + speed = self.speed + + try: + if not text.strip(): + logger.error("No text to convert for %s", output_path.name) + return False + logger.info("Extracted %d characters (%d words)", len(text), len(text.split())) # Split into chunks @@ -143,8 +212,8 @@ class AudiobookConverter: successful_chunks, total_chunks) # Combine chunks (only the successful ones) - output_path = config.AUDIOBOOKS_FOLDER / f"{output_name or file_path.stem}.{config.AUDIO_FORMAT}" - success = audio.combine_chunks(total_chunks, output_path, results, speed=self.speed) + success = audio.combine_chunks(total_chunks, output_path, results, + speed=speed, output_format=self.output_format) if success: duration = time.time() - start_time @@ -161,9 +230,6 @@ class AudiobookConverter: logger.error("Conversion failed: %s", exc) logger.error(traceback.format_exc()) return False - finally: - # Always cleanup, even on failure or interrupt - audio.cleanup_chunks() def run(self) -> bool: """Main conversion process. Returns True if all books converted.""" @@ -183,7 +249,9 @@ class AudiobookConverter: elif self.voice_mode == "voice_clone": print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") print(f"Language: {config.VOICE_CLONE_LANGUAGE}") - print(f"Output format: {config.AUDIO_FORMAT}") + print(f"Output format: {self.output_format}") + if self.single_file: + print("Chapter mode: single file (--single-file)") if abs(self.speed - 1.0) >= 1e-6: print(f"Playback speed: {self.speed:g}x") print("=" * 70) diff --git a/converter/extractors.py b/converter/extractors.py index e6e27ad..9139626 100644 --- a/converter/extractors.py +++ b/converter/extractors.py @@ -6,6 +6,7 @@ import re import zipfile from html import unescape from pathlib import Path +from typing import List, NamedTuple try: from bs4 import BeautifulSoup @@ -16,6 +17,13 @@ except ImportError: logger = logging.getLogger(__name__) +class Section(NamedTuple): + """A titled chunk of a book (e.g. an EPUB chapter).""" + + title: str + text: str + + def extract_text(file_path: Path) -> str: """Extract text from a book file based on its extension.""" extension = file_path.suffix.lower() @@ -28,6 +36,128 @@ def extract_text(file_path: Path) -> str: raise ValueError(f"Unsupported file format: {extension}") +def extract_sections(file_path: Path) -> List[Section]: + """Extract the book's text as titled sections (chapters). + + EPUB files are split on their spine documents so they can be converted + one chapter at a time. TXT and PDF files have no chapter structure and + always yield a single section. + """ + extension = file_path.suffix.lower() + if extension == ".epub": + chapters = _extract_epub_chapters(file_path) + if len(chapters) > 1: + return chapters + return [Section(file_path.stem, extract_epub(file_path))] + + return [Section(file_path.stem, extract_text(file_path))] + + +def _extract_epub_chapters(file_path: Path) -> List[Section]: + """Return one Section per EPUB spine document (chapter), in reading order.""" + import ebooklib + + book = None + for method in (_read_epub_ebooklib, _read_epub_zipfile, _read_epub_manual): + try: + book = method(file_path) + except Exception as exc: + logger.warning("EPUB chapter method %s failed: %s", method.__name__, exc) + continue + if book: + break + + if book is None: + return [] + + chapters = [] + for title, text in book: + cleaned = clean_html(text) + if cleaned.strip(): + chapters.append(Section(title or file_path.stem, cleaned)) + return chapters + + +def _toc_titles(book) -> dict: + """Flatten an ebooklib TOC into a ``{href: title}`` mapping.""" + titles = {} + + def walk(nodes) -> None: + for node in nodes: + if isinstance(node, (tuple, list)): + walk(node[1] if len(node) > 1 else []) + continue + href = getattr(node, "href", None) + title = getattr(node, "title", None) + if href and title: + titles[href.split("#")[0]] = title + + walk(book.toc) + return titles + + +def _read_epub_ebooklib(file_path: Path): + """Read EPUB spine documents as (title, html) pairs via ebooklib.""" + import ebooklib + from ebooklib import epub + + book = epub.read_epub(str(file_path)) + titles = _toc_titles(book) + items = [] + for entry in book.spine: + item_id = entry[0] if isinstance(entry, (tuple, list)) else entry + try: + item = book.get_item_with_id(item_id) + except Exception as exc: + logger.debug("Skipping EPUB spine item %r: %s", item_id, exc) + continue + if not item or item.get_type() != ebooklib.ITEM_DOCUMENT: + continue + if isinstance(item, epub.EpubNav): + continue + content = item.get_body_content() + if content: + if isinstance(content, bytes): + content = content.decode("utf-8", errors="ignore") + title = (titles.get(item.file_name) + or titles.get(item.get_name()) + or getattr(item, "title", None) + or item.get_name()) + items.append((title, str(content))) + return items + + +def _read_epub_zipfile(file_path: Path): + """Read EPUB HTML members as (title, html) pairs, ordered by filename.""" + items = [] + with zipfile.ZipFile(file_path, "r") as epub_zip: + for file_name in sorted(epub_zip.namelist(), key=_natural_key): + if file_name.lower().endswith((".html", ".xhtml", ".htm")): + try: + content = epub_zip.read(file_name).decode("utf-8", errors="ignore") + items.append((Path(file_name).stem, content)) + except Exception as exc: + logger.debug("Skipping EPUB member %r: %s", file_name, exc) + return items + + +def _read_epub_manual(file_path: Path): + """Last-resort read of any markup-looking EPUB member.""" + skipped_extensions = (".jpg", ".jpeg", ".png", ".gif", ".css", ".js") + items = [] + with zipfile.ZipFile(file_path, "r") as epub_zip: + for file_name in sorted(epub_zip.namelist(), key=_natural_key): + if file_name.lower().endswith(skipped_extensions): + continue + try: + content = epub_zip.read(file_name).decode("utf-8", errors="ignore") + if "<" in content and len(content.strip()) > 100: + items.append((Path(file_name).stem, content)) + except Exception as exc: + logger.debug("Skipping EPUB member %r: %s", file_name, exc) + return items + + def clean_text(text: str) -> str: """Normalize whitespace and strip standalone page numbers. diff --git a/tests/test_audio.py b/tests/test_audio.py index a629224..29bf4ea 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,11 +1,11 @@ -"""Tests for audio helpers: speed parameters and chunk cleanup.""" +"""Tests for audio helpers: speed parameters, chunk cleanup, and encoding.""" import tempfile import unittest from pathlib import Path from converter import config -from converter.audio import cleanup_chunks, speed_export_params +from converter.audio import _encode_args, build_ffmetadata, cleanup_chunks, speed_export_params class SpeedExportParamsTests(unittest.TestCase): @@ -52,6 +52,48 @@ class CleanupChunksTests(unittest.TestCase): self.assertFalse((chunks_dir / "chunk_0002.wav").exists()) self.assertTrue((chunks_dir / "keep.txt").exists()) + def test_removes_chapter_files(self): + with tempfile.TemporaryDirectory() as tmp: + chunks_dir = Path(tmp) + (chunks_dir / "chapter_0001.m4b").write_bytes(b"stale") + (chunks_dir / "chunk_0001.wav").write_bytes(b"stale") + + original = config.CHUNKS_FOLDER + config.CHUNKS_FOLDER = chunks_dir + try: + cleanup_chunks() + finally: + config.CHUNKS_FOLDER = original + + self.assertFalse((chunks_dir / "chapter_0001.m4b").exists()) + self.assertFalse((chunks_dir / "chunk_0001.wav").exists()) + + +class EncodeArgsTests(unittest.TestCase): + def test_mp3_uses_bitrate_only(self): + self.assertEqual(_encode_args("mp3"), ["-b:a", config.AUDIO_BITRATE]) + + def test_m4b_uses_aac(self): + self.assertEqual(_encode_args("m4b"), ["-c:a", "aac", "-b:a", config.AUDIO_BITRATE]) + + +class BuildFFMetadataTests(unittest.TestCase): + def test_writes_chapters(self): + chapters = [(0, 1200, "One"), (1200, 2500, "Two")] + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "meta.txt" + build_ffmetadata(chapters, path) + content = path.read_text(encoding="utf-8") + + self.assertTrue(content.startswith(";FFMETADATA1\n")) + self.assertIn("[CHAPTER]", content) + self.assertIn("TIMEBASE=1/1000", content) + self.assertIn("START=0", content) + self.assertIn("END=1200", content) + self.assertIn("title=One", content) + self.assertIn("START=1200", content) + self.assertIn("title=Two", content) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_converter.py b/tests/test_converter.py new file mode 100644 index 0000000..1235dda --- /dev/null +++ b/tests/test_converter.py @@ -0,0 +1,21 @@ +"""Tests for the audiobook converter orchestration helpers.""" + +import unittest + +from converter.converter import AudiobookConverter + + +class SanitizeFilenameTests(unittest.TestCase): + def test_removes_invalid_characters(self): + self.assertEqual(AudiobookConverter._sanitize_filename('A "bad" name: here'), + "A bad name here") + + def test_collapses_whitespace(self): + self.assertEqual(AudiobookConverter._sanitize_filename(" spaced\tout "), "spaced out") + + def test_empty_falls_back(self): + self.assertEqual(AudiobookConverter._sanitize_filename("///"), "chapter") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_extractors.py b/tests/test_extractors.py index c497267..09b688e 100644 --- a/tests/test_extractors.py +++ b/tests/test_extractors.py @@ -90,5 +90,39 @@ class EpubExtractionTests(unittest.TestCase): text.index("Second chapter text.")) +class ExtractSectionsTests(unittest.TestCase): + def setUp(self): + try: + import ebooklib # noqa: F401 + except ImportError: + self.skipTest("ebooklib not installed") + + def test_epub_sections_split_on_chapters(self): + from converter.extractors import extract_sections + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "book.epub" + _build_test_epub(path) + sections = extract_sections(path) + + self.assertEqual(len(sections), 2) + self.assertEqual(sections[0].title, "One") + self.assertEqual(sections[1].title, "Two") + self.assertIn("First chapter text.", sections[0].text) + self.assertIn("Second chapter text.", sections[1].text) + + def test_txt_is_single_section(self): + from converter.extractors import extract_sections + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "book.txt" + path.write_text("Hello world.", encoding="utf-8") + sections = extract_sections(path) + + self.assertEqual(len(sections), 1) + self.assertEqual(sections[0].title, "book") + self.assertEqual(sections[0].text, "Hello world.") + + if __name__ == "__main__": unittest.main() -- cgit v1.2.3