aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py240
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"):