aboutsummaryrefslogtreecommitdiff
path: root/converter/audio.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-18 02:17:03 -0400
committerhistoria <historiavg@proton.me>2026-08-18 02:17:03 -0400
commit8760626f744d79a05904e692119ffa72fc0c4e54 (patch)
tree1679faa3af8a1dd2ff1a94504168831ccf82f0ad /converter/audio.py
parentd72d274bd9895bdf97d31d56690b7df07fa74ad4 (diff)
downloadtts-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.py117
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