diff options
| author | historia <historiavg@proton.me> | 2026-08-24 23:49:34 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 23:49:34 -0400 |
| commit | fe4b2b9eb7fb8aac81f65630720c9079d0a3121a (patch) | |
| tree | 9c5e5f56d0b931d25e6580f10d09453505eddc2f /app/backends/common.py | |
| parent | f4b1de303704e13818259d5057d176cd841b6ed8 (diff) | |
| download | tts-audiobook-generator-fe4b2b9eb7fb8aac81f65630720c9079d0a3121a.tar.gz | |
feat: user-friendly menu gating, clearer install/configure path for backends
Diffstat (limited to 'app/backends/common.py')
| -rw-r--r-- | app/backends/common.py | 182 |
1 files changed, 170 insertions, 12 deletions
diff --git a/app/backends/common.py b/app/backends/common.py index 08c8863..cb573c3 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -11,10 +11,18 @@ run. import os import re +import sys +import time import urllib.parse from pathlib import Path from typing import Dict, List, Optional, Set, Tuple +# Messages queued while the TUI is on screen, printed to the real console +# after the curses session ends (see ui.hub.run). Build/setup steps that +# fail inside the TUI record here so the user gets a copy-pastable command +# and a log path once the TUI exits, instead of losing the output. +_POST_TUI_NOTICES: List[str] = [] + # The tts-audiobook-generator checkout root (where audiobook.py lives). # Everything non-user-facing lives under ./app: the source packages # (backends, converter, ui), the generated dirs (envs, chunks, logs, debug), @@ -24,6 +32,9 @@ TTS_ROOT = Path(__file__).resolve().parent.parent.parent # The single "everything else" directory under TTS_ROOT. APP_DIR = TTS_ROOT / "app" +# app/logs — build/server/conversion logs (already gitignored). +LOG_DIR = APP_DIR / "logs" + # The project's sample-voice directory: .wav files dropped here are offered # as the default source when a setup/configure wizard asks for a wav # directory (both the TUI browser start and the --wavs flag default). @@ -42,6 +53,24 @@ TTS_OUTPUT_DIR = "output" PROMPT_TEXT_FILENAME = "prompt_text" +def record_post_tui_notice(text: str) -> None: + """Queue a message to print to the console after the TUI session ends. + + The TUI runs in a curses session, so ``print`` during it does not reach + the real terminal. Steps that fail inside the TUI (e.g. the audio.cpp + build) record a copy-pastable command and a log path here; ``ui.hub.run`` + drains the queue after the session ends. + """ + _POST_TUI_NOTICES.append(text) + + +def drain_post_tui_notices() -> List[str]: + """Return and clear the queued post-TUI messages.""" + notices = list(_POST_TUI_NOTICES) + _POST_TUI_NOTICES.clear() + return notices + + def normalize_dir_arg(value: str) -> Path: """Normalize a user-supplied path argument. @@ -267,26 +296,155 @@ def write_prompt_text(wav_dir: Path, return prompt_path -def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None) -> int: - """Run a subprocess whose output streams to the plain console. +def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, + *, emit=None, cancel=None, on_cancel=None) -> int: + """Run a subprocess, streaming output to the console or to EMIT. + + With EMIT None the child inherits the real terminal and its output + appears normally (used by the non-interactive CLI paths and the quick + ``tui.suspend`` actions like uninstall). With EMIT given (a + ``callable(str)``) the child's stdout/stderr are merged, read line by + line (splitting on both ``\\n`` and ``\\r`` so carriage-return progress + updates like git's or tqdm's surface as lines), and each line is passed + to EMIT — the in-TUI task view path. - Used inside ``tui.suspend`` for clone/build/pip steps: the caller has - already left curses mode, so the child inherits the real terminal and - its output appears normally. Returns the process exit code. + CANCEL is an optional ``threading.Event``: once set, ON_CANCEL (if given) + is called (e.g. to touch a ``--cancel-file``), then the child's process + group is terminated (SIGTERM, escalating to SIGKILL after a grace + period) and 130 is returned. Returns the process exit code. """ import subprocess + if emit is None: + try: + result = subprocess.run(argv, + cwd=str(cwd) if cwd is not None else None) + except OSError as exc: + print(f"[ERROR] Could not run {' '.join(argv)}: {exc}") + return 1 + return result.returncode + + popen_kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT} + if cwd is not None: + popen_kwargs["cwd"] = str(cwd) + if sys.platform == "win32": + popen_kwargs["creationflags"] = \ + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + else: + popen_kwargs["start_new_session"] = True try: - result = subprocess.run(argv, cwd=str(cwd) if cwd is not None else None) + proc = subprocess.Popen(argv, **popen_kwargs) except OSError as exc: - print(f"[ERROR] Could not run {' '.join(argv)}: {exc}") + emit(f"[ERROR] Could not run {' '.join(argv)}: {exc}") return 1 - return result.returncode + cancelled = False + + def _reader() -> None: + try: + for raw in iter(proc.stdout.readline, b""): + if not raw: + break + text = raw.decode("utf-8", errors="replace") + for line in text.splitlines(): + if line: + emit(line) + except (OSError, ValueError): + pass + + reader = _spawn_reader(_reader) + while True: + if cancel is not None and cancel.is_set(): + cancelled = True + if on_cancel is not None: + try: + on_cancel() + except Exception: + pass + # Give a graceful-cancel hook (e.g. a --cancel-file) a + # moment to let the child exit cleanly before forcing it. + grace_end = time.time() + 3 + while time.time() < grace_end: + if proc.poll() is not None: + break + time.sleep(0.1) + if proc.poll() is None: + _terminate_process_group(proc) + break + if proc.poll() is not None: + break + time.sleep(0.1) + try: + reader.join(timeout=5) + finally: + if reader.is_alive(): + reader.join(timeout=0) + if cancelled: + return 130 + return proc.returncode + + +def _spawn_reader(target): + import threading + thread = threading.Thread(target=target, daemon=True) + thread.start() + return thread + + +def _terminate_process_group(proc) -> None: + """Terminate PROC's process group (SIGTERM, then SIGKILL after a grace). -def git_clone(url: str, target: Path) -> int: - """Clone URL into TARGET, streaming to the console. Returns exit code.""" - print(f"[INFO] Cloning {url} into {target}...") - return run_console_subprocess(["git", "clone", url, str(target)]) + Death is detected with ``proc.poll()`` (which reaps the zombie) rather + than a ``killpg(pgid, 0)`` probe — the latter still succeeds on a + zombie, so it would always wait the full grace period. + """ + import signal + if sys.platform == "win32": + try: + proc.terminate() + except OSError: + pass + deadline = time.time() + 10 + while time.time() < deadline: + if proc.poll() is not None: + return + time.sleep(0.1) + try: + proc.kill() + except OSError: + pass + return + try: + pgid = os.getpgid(proc.pid) + except (ProcessLookupError, OSError): + return + try: + os.killpg(pgid, signal.SIGTERM) + except (ProcessLookupError, OSError): + return + deadline = time.time() + 10 + while time.time() < deadline: + if proc.poll() is not None: + return + time.sleep(0.1) + try: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, OSError): + pass + proc.wait() + + +def git_clone(url: str, target: Path, *, emit=None, cancel=None) -> int: + """Clone URL into TARGET, streaming to the console or to EMIT. Returns + the exit code.""" + if emit is None: + print(f"[INFO] Cloning {url} into {target}...") + return run_console_subprocess(["git", "clone", url, str(target)]) + emit(f"[INFO] Cloning {url} into {target}...") + # --progress makes git report percentage updates even though stderr is + # piped (it normally only does so on a terminal), feeding the task view. + return run_console_subprocess( + ["git", "clone", "--progress", url, str(target)], + emit=emit, cancel=cancel) def pip_install(packages: List[str]) -> int: |
