diff options
Diffstat (limited to 'app/converter/converter.py')
| -rw-r--r-- | app/converter/converter.py | 67 |
1 files changed, 49 insertions, 18 deletions
diff --git a/app/converter/converter.py b/app/converter/converter.py index a10a57d..32cd342 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -66,6 +66,13 @@ DEBUG_FOLDER = APP_DIR / "debug" # --debug dumps, kept across runs AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac") SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"] +# Device names Windows cannot use as a file name (with or without an +# extension); sanitized output names matching these get a prefix. +_WINDOWS_RESERVED_NAMES = frozenset( + {"CON", "PRN", "AUX", "NUL"} + | {f"COM{i}" for i in range(1, 10)} + | {f"LPT{i}" for i in range(1, 10)}) + def _console_log_filter(record: logging.LogRecord) -> bool: """Keep httpx/httpcore request logs and file-only traceback dumps out @@ -258,7 +265,7 @@ class AudiobookConverter: # configured on the server, so no local reference audio is needed. self.tts = FasterTTSClient(chunks_dir=CHUNKS_FOLDER, voice=voice, api_url=api_url, - quiet=quiet) + quiet=quiet, cancel=cancel) elif backend == BACKEND_AUDIOCPP: # --voice picks the voice: a built-in speaker name on the # CustomVoice entry, or a server-side preset (cloning) @@ -273,7 +280,8 @@ class AudiobookConverter: instructions=instructions, request_options=self.request_options, api_url=api_url, quiet=quiet, - unload_models=unload_models) + unload_models=unload_models, + cancel=cancel) else: # Qwen: the voice mode picks the request shape (built-in # speaker, clone from a reference .wav, or a designed voice); @@ -290,9 +298,12 @@ class AudiobookConverter: api_url=api_url, quiet=quiet, voice=voice, + cancel=cancel, ) self._progress = progress - self.tts.cancel = cancel + # The converter's own handle on the run's cancel event (also passed + # to the client, so a cancel during connect-time work is honored). + self._cancel = cancel def _emit(self, event: dict) -> None: """Send one progress event (a no-op without a progress callback).""" @@ -306,7 +317,11 @@ class AudiobookConverter: def _check_cancelled(self) -> None: """Raise ConversionCancelled when the run's cancel event is set.""" - cancel = getattr(getattr(self, "tts", None), "cancel", None) + cancel = getattr(self, "_cancel", None) + if not isinstance(cancel, threading.Event): + # Converters built without __init__ (tests): fall back to the + # client's event, the pre-constructor-arg wiring. + cancel = getattr(getattr(self, "tts", None), "cancel", None) if isinstance(cancel, threading.Event) and cancel.is_set(): raise ConversionCancelled("Cancelled by user") @@ -344,10 +359,17 @@ class AudiobookConverter: @staticmethod def _sanitize_filename(name: str, fallback: str = "chapter") -> str: - """Make a chapter title safe to use as part of a file name.""" + """Make a chapter title safe to use as part of a file name. + + Reserved Windows device names (CON, NUL, COM1, ...) are suffixed + so the resulting name is writable on every platform. + """ cleaned = re.sub(r'[\\/:*?"<>|]', " ", name) cleaned = re.sub(r"\s+", " ", cleaned).strip().strip(".") - return cleaned[:80] or fallback + cleaned = cleaned[:80] or fallback + if cleaned.upper() in _WINDOWS_RESERVED_NAMES: + return f"{fallback}_{cleaned}" + return cleaned def _narrator_tag(self) -> str: """Narrator name used in output file names (see compute_narrator_tag).""" @@ -480,15 +502,20 @@ class AudiobookConverter: stem = output_name or f"{file_path.stem}_{self._narrator_tag()}" # The output files this book will produce (single final file, - # or one per chapter). Reported on the book_done/book_failed - # events so the run view can list them in its summary. + # or one per chapter — each with its speed-adjusted copy when + # SPEED != 1.0, see audio.combine_chunks). Reported on the + # book_done/book_failed events so the run view can list them + # in its summary. + speed_tag = ("" if abs(self.speed - 1.0) < audio.SPEED_EPSILON + else f"_{self.speed:g}") if self.output_format == "m4b" or self.single_file \ or len(sections) == 1: - self.current_outputs = [f"{stem}.{self.output_format}"] + self.current_outputs = [f"{stem}{speed_tag}." + f"{self.output_format}"] else: self.current_outputs = [ f"{stem}_{index:02d}_" - f"{self._sanitize_filename(section.title)}." + f"{self._sanitize_filename(section.title)}{speed_tag}." f"{self.output_format}" for index, section in enumerate(sections, 1)] @@ -930,7 +957,11 @@ class AudiobookConverter: self._say(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)") - results = {} + # Per-book outcome ({book file name: ok}), published on the + # instance so multi-book orchestrators (the "All" run) can count + # partial success after run() returns False (a failed book aborts + # the rest, but earlier books still count). + self.results = {} cancelled = False for index, (book_file, output_name) in enumerate(planned, 1): self._check_cancelled() @@ -938,7 +969,7 @@ class AudiobookConverter: "name": book_file.name}) try: success = self.convert_book(book_file, output_name=output_name) - results[book_file.name] = success + self.results[book_file.name] = success self._emit({"kind": "book_done", "name": book_file.name, "ok": bool(success), "files": list(getattr(self, "current_outputs", []))}) @@ -949,21 +980,21 @@ class AudiobookConverter: break except KeyboardInterrupt: self._say("\n[WARNING] Conversion interrupted by user") - results[book_file.name] = False + self.results[book_file.name] = False break except Exception as exc: logger.error("Unexpected error: %s", exc) - results[book_file.name] = False + self.results[book_file.name] = False self._emit({"kind": "book_failed", "name": book_file.name, "error": str(exc), "files": list(getattr(self, "current_outputs", []))}) - if not results.get(book_file.name): + if not self.results.get(book_file.name): logger.error("Conversion of %s failed; aborting the remaining books", book_file.name) break - successful = sum(results.values()) - total = len(results) + successful = sum(self.results.values()) + total = len(self.results) # A cancelled run is not a successful run on either path (the TUI # event consumer and the console summary report it consistently). ok = not cancelled and total > 0 and successful == total @@ -979,7 +1010,7 @@ class AudiobookConverter: print(f"Total: {total} | Success: {successful} | Failed: {total - successful}") print("=" * 70) - for filename, success in results.items(): + for filename, success in self.results.items(): status = "[OK]" if success else "[FAIL]" print(f"{status} {filename}") |
