aboutsummaryrefslogtreecommitdiff
path: root/app/converter/converter.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-10 00:16:00 -0400
committerhistoria <historiavg@proton.me>2026-09-10 00:16:00 -0400
commite2da233cd1baa859a5721542fc8b80d9f3f880e7 (patch)
tree4e86797cbd7dce0434feef65bc0ad2e97bfbe4c0 /app/converter/converter.py
parent31459b281b6a5368c692b3c42c91e522995ebd57 (diff)
downloadtts-audiobook-generator-e2da233cd1baa859a5721542fc8b80d9f3f880e7.tar.gz
fix: smart chunking, extraction, and process-safety issuesHEADmain
Diffstat (limited to 'app/converter/converter.py')
-rw-r--r--app/converter/converter.py94
1 files changed, 92 insertions, 2 deletions
diff --git a/app/converter/converter.py b/app/converter/converter.py
index f8815d8..88e17a9 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -1,7 +1,9 @@
"""Orchestrates book-to-audiobook conversion."""
+import contextlib
import glob
import logging
+import math
import re
import shutil
import sys
@@ -64,6 +66,50 @@ CHUNKS_FOLDER = APP_DIR / "chunks" # Per-chunk scratch audio, cleaned per book
LOGS_FOLDER = logging_kit.LOG_DIR
DEBUG_FOLDER = APP_DIR / "debug" # --debug dumps, kept across runs
+
+@contextlib.contextmanager
+def _conversion_lock():
+ """An exclusive cross-process lock over the shared scratch folder.
+
+ Every converter run from this checkout synthesizes into the same
+ ``app/chunks`` directory (fixed chunk_NNNN names, cleaned per book), so
+ two overlapping runs would delete or overwrite each other's audio.
+ The lock makes a second run fail fast instead. It is an advisory
+ fcntl/msvcrt lock that dies with its process, so a crashed run never
+ leaves a stale lock wedging later starts; platforms without file
+ locking degrade to the old unlocked behavior.
+ """
+ CHUNKS_FOLDER.mkdir(parents=True, exist_ok=True)
+ handle = open(CHUNKS_FOLDER / ".conversion.lock", "w")
+ handle.write("0")
+ handle.flush()
+ try:
+ busy: Optional[OSError] = None
+ try:
+ import fcntl
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError as exc:
+ busy = exc
+ except ImportError:
+ try:
+ import msvcrt
+ try:
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
+ except OSError as exc:
+ busy = exc
+ except ImportError:
+ logger.warning("No file locking on this platform; the "
+ "conversion lock is not enforced")
+ if busy is not None:
+ raise RuntimeError(
+ "Another conversion is already running from this "
+ "installation: the shared scratch folder (app/chunks) is "
+ "in use. Wait for the run to finish or stop it first.")
+ yield
+ finally:
+ handle.close() # releases the lock
+
# Output containers and supported input formats.
AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"]
@@ -322,8 +368,12 @@ class AudiobookConverter:
unload_models: Optional[bool] = None,
progress: Optional[Callable[[dict], None]] = None,
cancel=None):
- if speed <= 0:
- raise ValueError(f"Speed must be a positive number, got {speed}")
+ # Non-finite speeds pass a bare "> 0" check (and float("inf") is
+ # "positive"): they would loop forever in atempo filter chaining,
+ # so they are rejected here at the boundary.
+ if not math.isfinite(speed) or speed <= 0:
+ raise ValueError(
+ f"Speed must be a finite positive number, got {speed}")
if output_format not in AUDIO_FORMATS:
raise ValueError(f"Unsupported output format: {output_format}")
if backend is None:
@@ -767,6 +817,9 @@ class AudiobookConverter:
return False
output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ # Same rule as _convert_text: honor a cancel that arrived during
+ # the final chapter's synthesis instead of assembling anyway.
+ self._check_cancelled()
if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path,
chunks_dir=CHUNKS_FOLDER,
speed=self.speed,
@@ -910,6 +963,9 @@ class AudiobookConverter:
successful_chunks, total_chunks)
return False
+ # A cancel that arrived while the last chunk was generating
+ # must still stop the run here, before any output is produced.
+ self._check_cancelled()
success = audio.combine_chunks(total_chunks, output_path,
chunk_results=results,
chunks_dir=CHUNKS_FOLDER,
@@ -1087,6 +1143,27 @@ class AudiobookConverter:
tag = f"{name_tag}_{narrator_tag}" if name_tag else narrator_tag
names.append((book_file, f"{name}_{tag}"))
+ # The stem-collision suffix above can itself collide with another
+ # book's natural stem ("book.txt" and "book_txt.txt" both produce
+ # "book_txt_<tag>"), so make every planned name unique across the
+ # batch: two books sharing an output name would silently overwrite
+ # each other (ffmpeg writes with -y and both are approved here,
+ # before any output exists). Later occurrences get a numeric
+ # suffix; the list is sorted, so the result is deterministic.
+ used: set = set()
+ unique: List[Tuple[Path, str]] = []
+ for book_file, name in names:
+ if name in used:
+ suffix = 2
+ while f"{name}_{suffix}" in used:
+ suffix += 1
+ print(f"[INFO] Output name {name} is already planned for "
+ f"another book; {book_file.name} becomes {name}_{suffix}")
+ name = f"{name}_{suffix}"
+ used.add(name)
+ unique.append((book_file, name))
+ names = unique
+
# Ask every overwrite question up front, before any conversion
# starts, so the rest of the run is unattended.
planned: List[Tuple[Path, str]] = []
@@ -1136,6 +1213,19 @@ class AudiobookConverter:
self._emit({"kind": "done", "ok": 0, "total": 0})
return True
+ # The scratch folder is shared by every run from this checkout;
+ # hold the cross-process lock for the whole conversion.
+ with _conversion_lock():
+ return self._convert_planned_books(book_files, planned, run_start)
+
+ def _convert_planned_books(self, book_files, planned,
+ run_start: float) -> bool:
+ """Convert PLANNED books sequentially (the body of ``run``).
+
+ BOOK_FILES/PLANNED come from the pre-flight (outer or caller);
+ RUN_START timestamps the console summary. Returns True when every
+ planned book converted.
+ """
self._say(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)")
# Per-book outcome ({book file name: ok}), published on the