diff options
| author | historia <historiavg@proton.me> | 2026-08-24 17:37:34 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 17:37:34 -0400 |
| commit | d950fc8e64ee508334e608f6045d687d73a464be (patch) | |
| tree | 87e5539b486c7f15ffba53bbba6ef6bb3a02540e /app/ui/hub.py | |
| parent | 919544c0931d53bb81904b6212ff14f856549da3 (diff) | |
| download | tts-audiobook-generator-d950fc8e64ee508334e608f6045d687d73a464be.tar.gz | |
feat: tui backend server progress and generate script progress
Diffstat (limited to 'app/ui/hub.py')
| -rw-r--r-- | app/ui/hub.py | 240 |
1 files changed, 178 insertions, 62 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index 77d0796..95ac06a 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -12,10 +12,13 @@ Esc on the main menu quits the hub ('q' mirrors Esc on every screen). Esc inside a sub-menu falls back to the main menu. """ +import contextlib +import io import json import re import shutil import urllib.parse +from datetime import datetime from pathlib import Path from typing import Callable, Optional, Tuple @@ -31,9 +34,15 @@ from backends import ( ) from backends import audiocpp as audiocpp_backend from backends import faster as faster_backend +from backends import probe as backend_probe from backends import qwen as qwen_backend from converter import config -from converter.converter import AUDIO_FORMATS +from converter.converter import ( + AUDIO_FORMATS, + AudiobookConverter, + LOGS_FOLDER, + voice_mode_for, +) from converter.tts import ( AUDIOCPP_FAMILY_QWEN3_TTS, BACKEND_AUDIOCPP, @@ -41,7 +50,7 @@ from converter.tts import ( BACKEND_QWEN, normalize_language, ) -from ui import tui +from ui import runview, tui _GO_BACK = object() @@ -70,7 +79,7 @@ def run() -> int: if info is not None and command[2] < len(info.configure_actions): info.configure_actions[command[2]].run() elif kind == "convert": - _run_conversion(command[1], command[2]) + _dispatch_conversion(command[1], command[2]) elif kind == "server": _run_server_action(command[1], command[2]) @@ -188,6 +197,8 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: text += " (" + ", ".join(status.running_models) + ")" return (text, "ok", "body") if status is not None and status.installed: + if status.models_missing and not status.running: + return ("installed (models missing)", "warn", "body") return ("installed", "warn", "body") return ("unavailable", "err", "dim") @@ -287,9 +298,50 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: if cmd is None: return None _add_autostart(cmd, statuses) + if not _preflight(stdscr, cmd): + return None return cmd +def _preflight(stdscr, cmd: tuple) -> bool: + """Run the overwrite checks in the TUI; stash the plan on the command. + + Asks every "output exists — overwrite?" question now (tui.confirm + instead of the console input()) so the run view itself is unattended, + and records the discovered books / accepted plan in the command's + kwargs (``book_files``/``planned``) for ``audiobook.convert``. Returns + False when nothing would be converted (a flash explains why), so the + user stays in the menu instead of entering an empty run. + """ + _kind, backend, kwargs = cmd + voice_mode = voice_mode_for(backend, kwargs.get("voice"), + kwargs.get("clone")) + + def confirm(message: str, default: bool) -> bool: + return tui.confirm(stdscr, message, default=default, + cancel_value=False) + + with contextlib.redirect_stdout(io.StringIO()): + book_files, planned = AudiobookConverter.preflight_overwrites( + backend=backend, voice=kwargs.get("voice"), + voice_mode=voice_mode, + voice_clone_ref_audio=kwargs.get("clone"), + output_format=kwargs.get("output_format") or config.AUDIO_FORMAT, + instructions=kwargs.get("instructions"), + confirm=confirm) + if not book_files: + tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub " + "file to the input folder first.") + return False + if not planned: + tui.flash(stdscr, "Nothing to convert — every existing output was " + "kept.") + return False + kwargs["book_files"] = book_files + kwargs["planned"] = planned + return True + + def _gate_backend(field: dict, key: str) -> Callable: """A visible() that shows FIELD only when the Backend field is KEY. @@ -429,6 +481,24 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: # converter's default: unknown family means qwen3_tts. entry["family"] = AUDIOCPP_FAMILY_QWEN3_TTS + if local: + # Only offer entries whose model files are actually on disk: a + # server.json can reference a package that was never downloaded, + # and picking it would fail the whole run at model-load time. + missing = audiocpp_backend.missing_model_entries(server_json) + if missing: + missing_ids = {item["id"] for item in missing} + models = [entry for entry in models + if entry.get("id") not in missing_ids] + if not models: + hints = audiocpp_backend.model_install_hints(checkout, + missing) + message = hints[0] if hints \ + else "Download the models first." + tui.flash(stdscr, "No model files are downloaded for " + f"audio.cpp. {message}") + return None + local_voices = _list_voices(data.get("voice_dir")) \ if data.get("voice_dir") else [] voice_cache: dict = {} # model id -> voices (local: shared list) @@ -871,76 +941,122 @@ def _write_config(updates: dict) -> None: path.write_text(text, encoding="utf-8") -def _run_conversion(backend: str, kwargs: dict) -> None: - """Run a conversion in the plain console (after the TUI returns). +def _dispatch_conversion(backend: str, kwargs: dict) -> None: + """Run a conversion in the full-screen run view (its own curses session). - A remote conversion (``api_url`` in the kwargs) targets an externally-run - server, so no autostart is attempted and the managed instance's setup - state is irrelevant. Otherwise, when the convert menu recorded an - ``autostart`` server (the user opted to have the hub start it), spawn it - now and abort the conversion if it does not come up; a managed server - whose port is already occupied by a server this tool did not start is - left alone but warned about. After the conversion, offer to stop a - server we started. + ``_prepare_run_config`` turns the accepted form (plus the autostart + decision the convert menu recorded) into everything the run view needs; + the view then boots the server when required, runs the conversion with + progress events, and asks the cancel/stop-server questions itself. A + crash inside the view cancels the worker and returns to the menu + instead of taking the whole hub down. """ - autostart = kwargs.pop("autostart", None) - api_url = kwargs.get("api_url") - if api_url: - print(f"[INFO] Converting against remote server at {api_url}") - else: - status = next((s for s in detect_all() if s.key == backend), None) - if status is not None and not status.ready and not status.running: - print(f"[WARNING] {status.label} is not fully set up.") - if autostart: - spec = _find_spec(autostart) - if spec is None: - print(f"[WARNING] no server named '{autostart}'; continuing") - elif not servers.start(spec): - print("[ERROR] could not start the server; aborting conversion.") - if status is not None and status.launch_hint: - print("Start it manually and run the conversion again:") - print(f" {status.launch_hint}") - return - elif status is not None and status.servers: - # The managed server's port may be held by a server we did not - # start (its pid file is absent); the conversion would silently - # talk to that server, so call it out. - spec = _select_spec(status, kwargs) - if spec is not None and common.server_running(spec.url) \ - and not servers.alive(spec.name): - print(f"[WARNING] A server this tool did not start is already " - f"running at {spec.url}; the conversion will talk to it. " - f"Stop it (or change the port) to use the managed " - f"{status.label} instance.") - elif status.launch_hint: - print("[INFO] Make sure the server is running. Start it with:") - print(f" {status.launch_hint}") - try: - audiobook.convert(backend=backend, **kwargs) - finally: - if autostart: - _maybe_stop_server(autostart) + import curses + run_config = _prepare_run_config(backend, kwargs) + if run_config is None: + return + holder: dict = {} + def main(stdscr) -> None: + view = runview.RunView(stdscr, run_config) + holder["view"] = view + view.run() -def _maybe_stop_server(name: str) -> None: - """Ask (in the plain console) whether to stop a server we auto-started.""" try: - ans = input(f"\n[?] Stop the '{name}' server now? [y/N] ").strip().lower() - except EOFError: - return - if ans in ("y", "yes"): - servers.stop(name) + curses.wrapper(main) + except tui.WizardCancelled: + pass + except KeyboardInterrupt: + pass + except Exception as exc: # noqa: BLE001 - keep the hub alive + view = holder.get("view") + if view is not None: + view._cancel.set() + view._worker.join(timeout=30) + print(f"[ERROR] The run view failed: {exc}") + finally: + try: + curses.curs_set(1) # restore the text cursor hidden by the TUI + except Exception: + pass + + +def _prepare_run_config(backend: str, kwargs: dict + ) -> Optional[runview.RunConfig]: + """Build the run view's config from the accepted conversion kwargs. + + A remote conversion (``api_url``) targets an externally-run server, so + no autostart is attempted and the managed instance's setup state is + irrelevant. Otherwise, when the convert menu recorded an ``autostart`` + server (the server was not running), the run view boots it first; a + managed server whose port is already occupied by a server this tool + did not start is left alone but flagged with a notice. Returns None + when the backend disappeared between the menu and the dispatch. + """ + label = backend + info = get(backend) + if info is not None: + label = info.label + log_path = str(LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log") + autostart = kwargs.pop("autostart", None) + api_url = kwargs.get("api_url") + + if api_url: + identity = _remote_identity(backend, kwargs) + return runview.RunConfig( + backend=backend, backend_label=f"{label} [remote]", + kwargs=kwargs, book_files=kwargs.get("book_files") or [], + planned=kwargs.get("planned") or [], + server_url=api_url, server_identity=identity, + log_path=log_path) + + status = next((s for s in detect_all() if s.key == backend), None) + notice = "" + spec: Optional[ServerSpec] = None + if autostart: + spec = _find_spec(autostart) + elif status is not None: + spec = _select_spec(status, kwargs) + if spec is not None and common.server_running(spec.url) \ + and not servers.alive(spec.name): + notice = (f"a server this tool did not start is running at " + f"{spec.url} — the conversion will talk to it") + if autostart and spec is None: + # The recorded server vanished (backend reconfigured meanwhile): + # converting without it is still meaningful, so continue. + notice = (f"no server named '{autostart}' — starting it was skipped") + return runview.RunConfig( + backend=backend, backend_label=label, kwargs=kwargs, + book_files=kwargs.get("book_files") or [], + planned=kwargs.get("planned") or [], + server_name=spec.name if spec is not None else None, + server_url=spec.url if spec is not None else None, + server_identity=spec.identity if spec is not None else None, + autostart_spec=spec if autostart else None, + log_path=log_path, notice=notice) + + +def _remote_identity(backend: str, kwargs: dict) -> Optional[str]: + """The probe identity of the remote server a conversion targets.""" + if backend == BACKEND_AUDIOCPP: + return backend_probe.IDENTITY_AUDIOCPP + if backend == BACKEND_QWEN: + return backend_probe.IDENTITY_QWEN_CLONE if kwargs.get("clone") \ + else backend_probe.IDENTITY_QWEN_CUSTOM + if backend == BACKEND_FASTER: + return backend_probe.IDENTITY_FASTER + return None def _add_autostart(cmd: tuple, statuses) -> None: """Auto-start the conversion's target server when it isn't running. Records the chosen server spec name as ``kwargs['autostart']`` for - ``_run_conversion`` to act on. The user already accepted the run on the - Generate! screen, so no start-server prompt is asked here — the server - is simply started. Mode-aware for qwen (custom vs clone). Remote - conversions (a ``api_url`` in the kwargs) never autostart: the server - is external to this tool. + ``_prepare_run_config`` to act on. The user already accepted the run on + the Generate! screen, so no start-server prompt is asked here — the + server is simply started. Mode-aware for qwen (custom vs clone). + Remote conversions (an ``api_url`` in the kwargs) never autostart: the + server is external to this tool. """ _, key, kwargs = cmd if kwargs.get("api_url"): |
