diff options
Diffstat (limited to 'app/converter/converter.py')
| -rw-r--r-- | app/converter/converter.py | 94 |
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 |
