diff options
| author | historia <historiavg@proton.me> | 2026-08-18 02:17:03 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-18 02:17:03 -0400 |
| commit | 8760626f744d79a05904e692119ffa72fc0c4e54 (patch) | |
| tree | 1679faa3af8a1dd2ff1a94504168831ccf82f0ad /converter/audio.py | |
| parent | d72d274bd9895bdf97d31d56690b7df07fa74ad4 (diff) | |
| download | tts-audiobook-generator-8760626f744d79a05904e692119ffa72fc0c4e54.tar.gz | |
fix(converter): escape ffmetadata titles, thread exact chunk paths
Diffstat (limited to 'converter/audio.py')
| -rw-r--r-- | converter/audio.py | 117 |
1 files changed, 83 insertions, 34 deletions
diff --git a/converter/audio.py b/converter/audio.py index 9912004..f1c508c 100644 --- a/converter/audio.py +++ b/converter/audio.py @@ -1,11 +1,12 @@ """Audio assembly: combining chunks, speed adjustment, cleanup.""" import logging +import re import shutil import subprocess import traceback from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from . import config @@ -101,6 +102,8 @@ def build_concat_command(concat_list: Path, output_path: Path, output_format: st container = _m4b_container_args() if output_format == "m4b" else [] filters = atempo_filters(speed) if filters: + if speed_path is None: + raise ValueError("speed_path is required when speed is not 1.0") return [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-filter_complex", @@ -177,33 +180,50 @@ def verify_output_duration(path: Path, expected_ms: int) -> bool: return True -def combine_chunks(total_chunks: int, output_path: Path, - 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. +def _collect_chunk_files(total_chunks: int, + chunk_results: Optional[Dict[int, Optional[Path]]] = None + ) -> Tuple[List[Path], List[int]]: + """Resolve chunk audio files in book order. - ``results`` maps chunk numbers to success flags; failed chunks are - skipped. When ``speed`` differs from 1.0, an additional speed-adjusted - copy is written next to the normal-speed file. Chunks are streamed by - ffmpeg, so the whole book is never held in memory. + When ``chunk_results`` is provided (chunk number -> path written, or None + for a failed chunk), the recorded paths are used exactly as-is so stale + files from a previous chapter can never leak in. Without it, the chunks + folder is globbed per index (legacy discovery). """ - if shutil.which("ffmpeg") is None: - logger.error("ffmpeg is required to combine audio chunks (install ffmpeg)") - return False - - chunk_files = [] - missing_chunks = [] + chunk_files: List[Path] = [] + missing: List[int] = [] for i in range(1, total_chunks + 1): - # Skip chunks that failed if we have results tracking - if results is not None and not results.get(i, False): - missing_chunks.append(i) + if chunk_results is not None: + recorded = chunk_results.get(i) + if recorded is not None and Path(recorded).exists(): + chunk_files.append(Path(recorded)) + else: + missing.append(i) continue - matches = sorted(config.CHUNKS_FOLDER.glob(f"chunk_{i:04d}.*")) if matches: chunk_files.append(matches[0]) else: - missing_chunks.append(i) + missing.append(i) + return chunk_files, missing + + +def combine_chunks(total_chunks: int, output_path: Path, + chunk_results: Optional[Dict[int, Optional[Path]]] = None, + speed: float = 1.0, output_format: str = "mp3") -> bool: + """Combine audio chunks into the final audiobook using ffmpeg's concat demuxer. + + ``chunk_results`` maps chunk numbers to the audio file each chunk produced + (None for failed chunks); failed and missing chunks are skipped. When + ``speed`` differs from 1.0, an additional speed-adjusted copy is written + next to the normal-speed file. Chunks are streamed by ffmpeg, so the whole + book is never held in memory. + """ + if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: + logger.error("ffmpeg and ffprobe are required to combine audio chunks (install ffmpeg)") + return False + + chunk_files, missing_chunks = _collect_chunk_files(total_chunks, chunk_results) if not chunk_files: logger.error("No valid chunks found") @@ -231,10 +251,17 @@ def combine_chunks(total_chunks: int, output_path: Path, # Verify the assembled duration against the sum of chunk durations # so corrupt output is caught before it reaches audiobook players. - expected_ms = sum(probe_duration_ms(chunk_file) for chunk_file in chunk_files) - duration_ok = verify_output_duration(output_path, expected_ms) - if speed_path is not None: - duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok + # A failed probe returns 0, which would deflate the expected total + # and falsely fail the check, so unverifiable sums skip it. + durations = [probe_duration_ms(chunk_file) for chunk_file in chunk_files] + if any(duration <= 0 for duration in durations): + logger.warning("Could not probe every chunk duration; skipping duration verification") + duration_ok = True + else: + expected_ms = sum(durations) + duration_ok = verify_output_duration(output_path, expected_ms) + if speed_path is not None: + duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok if not duration_ok: return False @@ -286,11 +313,15 @@ def cleanup_chunks() -> None: 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, - ) + try: + result = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", str(path)], + capture_output=True, text=True, timeout=30, + ) + except subprocess.TimeoutExpired: + logger.warning("ffprobe timed out for %s", path) + return 0 if result.returncode != 0: logger.warning("ffprobe failed for %s: %s", path, result.stderr[-200:]) return 0 @@ -301,6 +332,18 @@ def probe_duration_ms(path: Path) -> int: return 0 +def _escape_ffmetadata_value(value: str) -> str: + """Escape a metadata value for ffmpeg's FFMETADATA format. + + Backslash and the structural characters ``=``, ``;`` and ``#`` must be + backslash-escaped; line breaks would corrupt the file and are collapsed + to spaces. + """ + value = value.replace("\\", "\\\\") + value = re.sub(r"[\r\n]+", " ", value) + return re.sub(r"[=;#]", r"\\\g<0>", value) + + def build_ffmetadata(chapters: List[tuple], path: Path) -> None: """Write an ffmpeg FFMETADATA file with ``[CHAPTER]`` entries. @@ -313,7 +356,7 @@ def build_ffmetadata(chapters: List[tuple], path: Path) -> None: 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") + meta_file.write(f"title={_escape_ffmetadata_value(title)}\n") def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], @@ -364,11 +407,17 @@ def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], logger.error("ffmpeg failed: %s", proc.stderr[-2000:]) return False - # The last chapter's end time is the expected total duration. + # The last chapter's end time is the expected total duration; skip + # verification when any chapter duration probe failed (returned 0) + # so an unprobed chapter can't falsely fail the whole output. expected_ms = chapters[-1][1] - duration_ok = verify_output_duration(output_path, expected_ms) - if speed_path is not None: - duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok + if any(end_ms - start_ms <= 0 for start_ms, end_ms, _ in chapters): + logger.warning("Could not probe every chapter duration; skipping duration verification") + duration_ok = True + else: + duration_ok = verify_output_duration(output_path, expected_ms) + if speed_path is not None: + duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok if not duration_ok: return False |
