From c02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 01:57:13 -0400 Subject: feat: manage venv for all backends --- ui/hub.py | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 164 insertions(+), 13 deletions(-) (limited to 'ui/hub.py') diff --git a/ui/hub.py b/ui/hub.py index 3115f54..f3e2b7e 100644 --- a/ui/hub.py +++ b/ui/hub.py @@ -16,15 +16,27 @@ import json from pathlib import Path from typing import Optional, Tuple -from ui import tui import audiobook -from backends import REGISTRY, BackendStatus, detect_all, get +from backends import ( + REGISTRY, + BackendStatus, + ServerSpec, + common, + detect_all, + get, + servers, +) from backends import audiocpp as audiocpp_backend from backends import faster as faster_backend from converter import config from converter.converter import AUDIO_FORMATS -from converter.tts import AUDIOCPP_FAMILY_QWEN3_TTS, BACKEND_AUDIOCPP, \ - BACKEND_FASTER, BACKEND_QWEN +from converter.tts import ( + AUDIOCPP_FAMILY_QWEN3_TTS, + BACKEND_AUDIOCPP, + BACKEND_FASTER, + BACKEND_QWEN, +) +from ui import tui _GO_BACK = object() @@ -54,6 +66,8 @@ def run() -> int: info.configure_actions[command[2]].run() elif kind == "convert": _run_conversion(command[1], command[2]) + elif kind == "server": + _run_server_action(command[1], command[2]) def _hub_menu(stdscr) -> Optional[tuple]: @@ -64,6 +78,7 @@ def _hub_menu(stdscr) -> Optional[tuple]: if any(st.installed or st.running for st in statuses): options.insert(0, ("Convert books...", "convert")) options.append(("Configure a backend...", "configure")) + options.append(("Server...", "server")) options.append(("Quit", "quit")) rows = [(st.label, *_status_mark(st)) for st in statuses] choice = tui.menu( @@ -83,6 +98,10 @@ def _hub_menu(stdscr) -> Optional[tuple]: cmd = _configure_menu(stdscr, statuses) if cmd is not None: return cmd + elif choice == "server": + cmd = _server_menu(stdscr, statuses) + if cmd is not None: + return cmd def _setup_menu(stdscr, statuses) -> Optional[tuple]: @@ -162,12 +181,17 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: if key == "__setup__": return _setup_menu(stdscr, statuses) if key == BACKEND_AUDIOCPP: - return _convert_audiocpp(stdscr, statuses) - if key == BACKEND_QWEN: - return _convert_qwen(stdscr) - if key == BACKEND_FASTER: - return _convert_faster(stdscr) - return None + cmd = _convert_audiocpp(stdscr, statuses) + elif key == BACKEND_QWEN: + cmd = _convert_qwen(stdscr) + elif key == BACKEND_FASTER: + cmd = _convert_faster(stdscr) + else: + return None + if cmd is None: + return None + _add_autostart(stdscr, cmd, statuses) + return cmd def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: @@ -350,14 +374,141 @@ def _common_options(stdscr) -> Optional[dict]: def _run_conversion(backend: str, kwargs: dict) -> None: - """Run a conversion in the plain console (after the TUI returns).""" + """Run a conversion in the plain console (after the TUI returns). + + 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. After the conversion, offer to stop a server we started. + """ + autostart = kwargs.pop("autostart", None) 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 status is not None and not status.running and status.launch_hint: + 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 not status.running and status.launch_hint: print("[INFO] Make sure the server is running. Start it with:") print(f" {status.launch_hint}") - audiobook.convert(backend=backend, **kwargs) + try: + audiobook.convert(backend=backend, **kwargs) + finally: + if autostart: + _maybe_stop_server(autostart) + + +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) + + +def _add_autostart(stdscr, cmd: tuple, statuses) -> None: + """Offer to 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. Mode-aware for qwen (custom vs clone). + """ + _, key, kwargs = cmd + status = next((s for s in statuses if s.key == key), None) + if status is None or not status.servers: + return + spec = _select_spec(status, kwargs) + if spec is None: + return + if common.server_running(spec.url): + return + choice = tui.confirm(stdscr, f"The {status.label} server is not running. " + "Start it automatically?", default=True, + cancel_value=False) + if choice is True: + kwargs["autostart"] = spec.name + + +def _select_spec(status, kwargs) -> Optional[ServerSpec]: + """The server spec this conversion needs (mode-aware for qwen).""" + if status.key == BACKEND_QWEN: + wanted = "qwen-clone" if kwargs.get("clone") else "qwen-custom" + return next((s for s in status.servers if s.name == wanted), None) + return status.servers[0] if status.servers else None + + +def _find_spec(name: str) -> Optional[ServerSpec]: + """Look up a server spec by name across every backend's detect().""" + for st in detect_all(): + for spec in st.servers: + if spec.name == name: + return spec + return None + + +def _run_server_action(spec_name: str, action: str) -> None: + """Run a Start/Stop action in the plain console (after the TUI returns).""" + if action == "start": + spec = _find_spec(spec_name) + if spec is None: + print(f"[ERROR] no server named '{spec_name}'") + return + servers.start(spec) + elif action == "stop": + servers.stop(spec_name) + + +def _server_menu(stdscr, statuses) -> Optional[tuple]: + """Pick a backend, then one of its servers and a Start/Stop action.""" + candidates = [st for st in statuses if st.servers or st.running] + if not candidates: + tui.flash(stdscr, "No backend with a server is available. " + "Set one up first.") + return None + options = [(st.label, st.key) for st in candidates] + key = tui.menu(stdscr, "Start / Stop a server", options, + back_value=_GO_BACK) + if key is _GO_BACK or key is None: + return None + status = next((s for s in statuses if s.key == key), None) + if status is None: + return None + return _server_actions(stdscr, status) + + +def _server_actions(stdscr, status) -> Optional[tuple]: + """Pick a server spec (qwen has two) and a Start or Stop action.""" + specs = status.servers + if not specs: + tui.flash(stdscr, f"{status.label} has no server configured. " + "Run 'Set up a backend' first.") + return None + if len(specs) == 1: + spec = specs[0] + else: + options = [(f"{s.name} ({'running' if common.server_running(s.url) else 'stopped'})", + s.name) for s in specs] + name = tui.menu(stdscr, f"{status.label} server", options, + back_value=_GO_BACK) + if name is _GO_BACK or name is None: + return None + spec = next((s for s in specs if s.name == name), None) + if spec is None: + return None + running = common.server_running(spec.url) + action = tui.menu( + stdscr, f"{spec.name} ({'running' if running else 'stopped'})", + [("Start", "start"), ("Stop", "stop")], back_value=_GO_BACK) + if action is _GO_BACK or action is None: + return None + return ("server", spec.name, action) def _list_voices(voice_dir: str) -> list: -- cgit v1.2.3