diff options
| author | historia <historiavg@proton.me> | 2026-08-24 16:08:33 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 16:08:33 -0400 |
| commit | 1ff9a635bd9b033b631a6b525891b7eb44e189d3 (patch) | |
| tree | 6dbcd7e682d516770be4c0724db793666c93dd5f /app/ui | |
| parent | afd1c67d92c7f32389d5f652b9fa71530538a16f (diff) | |
| download | tts-audiobook-generator-1ff9a635bd9b033b631a6b525891b7eb44e189d3.tar.gz | |
feat: clearer split between local (managed) and remote URLs and server status
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 320 |
1 files changed, 221 insertions, 99 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index e44c80c..632ff93 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -163,21 +163,27 @@ def _configure_menu(stdscr, statuses) -> Optional[tuple]: def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: """Map a backend's state to (status_text, status_kind, name_kind). - 'running' (green/ok) takes priority — an external server is already up; - otherwise 'installed' (orange/warn) when the backend is present on disk, - or 'unavailable' (red/err). A backend that is neither installed nor - running is unusable, so its name is dimmed (NAME_KIND). - A running server the hub did not start itself (no live pid file for any - of its specs — see ``servers.manages``) is tagged "[remote]"; a - multi-model backend (qwen) also names which models answered in - parentheses, e.g. "running [remote] (Base, CustomVoice)". - CURSES has no true orange, so the theme's yellow 'warn' is used; it - renders amber/orange on most terminals. + A backend is 'running' (green/ok) when it is usable either locally — a + server this tool started (``status.managed``) — or remotely — a server + found by probing its remote URL (``status.remote``); the text names + which, e.g. "running [local]", "running [remote]", or + "running [local, remote]". Otherwise 'installed' (orange/warn) when the + backend is present on disk, or 'unavailable' (red/err); a backend that is + neither installed nor running is unusable, so its name is dimmed + (NAME_KIND). A multi-model backend (qwen) also names which models + answered in parentheses, e.g. "running [local, remote] (Base, + CustomVoice)". CURSES has no true orange, so the theme's yellow 'warn' is + used; it renders amber/orange on most terminals. """ if status is not None and status.running: + tags = [] + if status.managed: + tags.append("local") + if status.remote: + tags.append("remote") text = "running" - if not status.managed: - text += " [remote]" + if tags: + text += " [" + ", ".join(tags) + "]" if status.running_models: text += " (" + ", ".join(status.running_models) + ")" return (text, "ok", "body") @@ -209,44 +215,68 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: The first field is the Backend picker; the remaining fields are that backend's options (audio.cpp: model/voice/instructions; qwen: speaker or clone .wav; faster: voice), plus the shared output - settings. Each available backend's data is prepared up front so the - Backend field lists only backends whose options could be gathered — - a backend whose data is unavailable (e.g. an unreachable remote - audio.cpp server) is dropped here. + settings. A backend appears once as a managed entry ("audio.cpp") when + it is installed+configured here, and once as a remote entry + ("audio.cpp [remote]") when a running server was found at its remote + URL. Managed entries read the local server.json / voices.json; remote + entries query the remote server live. Each available entry's data is + prepared up front so the Backend field lists only backends whose + options could be gathered — an entry whose data is unavailable (e.g. + an unreachable remote audio.cpp server) is dropped here. """ - available = [st for st in statuses if st.ready or st.running] - if not available: + entries = [] + for st in statuses: + if st.ready: + entries.append((st.key, st.label, st, False)) + if st.remote: + entries.append((f"{st.key}-remote", f"{st.label} [remote]", + st, True)) + if not entries: tui.flash(stdscr, "No backend is ready to convert with yet — use " "'Set up a backend' first.") return None builders = {} - for st in available: - if st.key == BACKEND_AUDIOCPP: - built = _audiocpp_fields(stdscr) - elif st.key == BACKEND_QWEN: - built = _qwen_fields() - elif st.key == BACKEND_FASTER: - built = _faster_fields(stdscr) + for key, _label, st, remote in entries: + if remote: + if st.key == BACKEND_AUDIOCPP: + built = _audiocpp_fields( + stdscr, api_url=st.remote_urls.get("audiocpp")) + elif st.key == BACKEND_QWEN: + built = _qwen_fields(remote_modes=st.remote_models, + urls=st.remote_urls) + elif st.key == BACKEND_FASTER: + built = _faster_fields( + stdscr, api_url=st.remote_urls.get("faster")) + else: + continue else: - continue + if st.key == BACKEND_AUDIOCPP: + built = _audiocpp_fields(stdscr) + elif st.key == BACKEND_QWEN: + built = _qwen_fields() + elif st.key == BACKEND_FASTER: + built = _faster_fields(stdscr) + else: + continue if built is not None: - builders[st.key] = built + builders[key] = built if not builders: return None - by_key = {st.key: st for st in available} + choices = [(label, key) for key, label, _st, _remote in entries + if key in builders] default = config.BACKEND if config.BACKEND in builders \ - else next(iter(builders)) + else choices[0][1] fields = [{ "key": "backend", "label": "Backend", "kind": "choice", - "value": default, - "choices": [(by_key[key].label, key) for key in builders], + "value": default, "choices": choices, }] - for key in (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER): - if key in builders: - backend_fields, _ = builders[key] - for field in backend_fields: - field["visible"] = _gate_backend(field, key) - fields += backend_fields + for key, _label, _st, _remote in entries: + if key not in builders: + continue + backend_fields, _ = builders[key] + for field in backend_fields: + field["visible"] = _gate_backend(field, key) + fields += backend_fields fields += _common_fields() result = _show_convert_form(stdscr, "Convert books", fields) @@ -337,7 +367,7 @@ def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]: return result -def _audiocpp_fields(stdscr) -> Optional[tuple]: +def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: """audio.cpp-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the audio.cpp options @@ -346,19 +376,19 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]: the model list cannot be gathered (a flash explains why), so the caller drops audio.cpp from the Backend choices. - With a local checkout configured (its server.json), the model list is - fed from that file — the config of the server this tool manages. - Without one, the running server is external and nothing is known about - it locally, so its models and voices are queried live instead (the - same GET /v1/models and GET /v1/audio/voices endpoints the converter - resolves at run time). + With API_URL None (the managed entry) the model list is fed from the + local checkout's server.json — the config of the server this tool + manages. With API_URL set (the "[remote]" entry) the models and voices + are queried live from that server instead (the same GET /v1/models and + GET /v1/audio/voices endpoints the converter resolves at run time). """ - checkout = audiocpp_backend.find_local_checkout() - server_json = checkout / "server.json" if checkout else None - local = bool(server_json and server_json.exists()) - url = config.AUDIOCPP_API_URL - - if local: + if api_url is None: + checkout = audiocpp_backend.find_local_checkout() + server_json = checkout / "server.json" if checkout else None + if not (server_json and server_json.exists()): + tui.flash(stdscr, "No audio.cpp server.json found — run " + "'Set up a backend' first.") + return None try: data = json.loads(server_json.read_text(encoding="utf-8")) except (OSError, ValueError): @@ -369,10 +399,15 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]: tui.flash(stdscr, "No model entries in server.json. Reconfigure " "audio.cpp first.") return None + local = True + url = config.AUDIOCPP_API_URL else: # Remote flow: the backend only reaches the convert menu while a # server is running, so query it — the local config says nothing # about an external server. + url = api_url + local = False + data = {} models = audiocpp_backend.fetch_server_models(url) if models is None: tui.flash(stdscr, f"Could not list models from the audio.cpp " @@ -383,7 +418,6 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]: tui.flash(stdscr, f"The audio.cpp server at {url} hosts no " "model entries.") return None - data = {} # Normalize each entry so the form logic sees a family/task always. models = [dict(m) for m in models] @@ -479,31 +513,48 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]: if entry.get("task") == "vdes": voice = None instructions = (result["instructions"] or "").strip() or None - return ("convert", BACKEND_AUDIOCPP, { + kwargs = { "model_id": model_id, "voice": voice, "instructions": instructions, **_common_kwargs(result), - }) + } + if api_url is not None: + kwargs["api_url"] = api_url + return ("convert", BACKEND_AUDIOCPP, kwargs) return fields, mapper -def _qwen_fields() -> Optional[tuple]: +def _qwen_fields(remote_modes: Optional[list] = None, + urls: Optional[dict] = None) -> Optional[tuple]: """qwen-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the qwen options (Voice mode / Speaker / Clone .wav path) and MAPPER turns a submitted form values dict into the qwen converter kwargs. qwen always has options to offer, so it never signals unavailability. + + For the managed entry REMOTE_MODES/URLS are None and the mode picker + offers both modes, targeting the configured local URLs. For a + "[remote]" entry REMOTE_MODES names which demos answered remotely + ("CustomVoice" and/or "Base") and URLS maps "qwen-custom"/"qwen-clone" + to their URLs: the mode picker is limited to the available demos, and + the mapper passes the matching remote URL as ``api_url``. """ + remote_modes = list(remote_modes or []) + urls = dict(urls or {}) + mode_choices = [] + if urls.get("qwen-custom") or not remote_modes: + mode_choices.append(("Built-in speaker", "custom")) + if urls.get("qwen-clone") or not remote_modes: + mode_choices.append(("Clone from a .wav file", "clone")) + default_mode = mode_choices[0][1] if mode_choices else "custom" speakers = list(qwen_backend.QWEN_SPEAKERS) default_speaker = config.SPEAKER if config.SPEAKER in speakers \ else speakers[0] fields = [ {"key": "mode", "label": "Voice mode", "kind": "choice", - "value": "custom", - "choices": [("Built-in speaker", "custom"), - ("Clone from a .wav file", "clone")]}, + "value": default_mode, "choices": mode_choices}, {"key": "speaker", "label": "Speaker", "kind": "choice", "value": default_speaker, "choices": speakers, "visible": lambda fs: _field_value(fs, "mode") == "custom"}, @@ -524,13 +575,18 @@ def _qwen_fields() -> Optional[tuple]: # request time. common.update_config_value("SPEAKER", speaker) config.SPEAKER = speaker - return ("convert", BACKEND_QWEN, {"clone": clone, - **_common_kwargs(result)}) + kwargs = {"clone": clone, **_common_kwargs(result)} + if urls: + api_url = urls.get("qwen-clone") if result["mode"] == "clone" \ + else urls.get("qwen-custom") + if api_url: + kwargs["api_url"] = api_url + return ("convert", BACKEND_QWEN, kwargs) return fields, mapper -def _faster_fields(stdscr) -> Optional[tuple]: +def _faster_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: """faster-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the faster options @@ -540,24 +596,25 @@ def _faster_fields(stdscr) -> Optional[tuple]: exists but cannot be read/used (a flash explains why), so the caller drops faster from the Backend choices. - With a local checkout's voices.json the picker lists it (the config of - the server this tool manages). Without one, the running server was - configured elsewhere and its voice names are unknown here, so the name - is typed instead — safe for any value, since the server falls back to - its first configured voice when the name is not defined. + With API_URL None (the managed entry) a local checkout's voices.json + drives the picker. With API_URL set (the "[remote]" entry) the running + server was configured elsewhere and its voice names are unknown here, + so the name is typed instead — safe for any value, since the server + falls back to its first configured voice when the name is not defined. """ - checkout = faster_backend._checkout() - voices_json = checkout / "voices.json" voices = None - if voices_json.exists(): - try: - voices = json.loads(voices_json.read_text(encoding="utf-8")) - except (OSError, ValueError): - tui.flash(stdscr, f"Could not read {voices_json}.") - return None - if not voices: - tui.flash(stdscr, "voices.json has no voices. Reconfigure faster.") - return None + if api_url is None: + checkout = faster_backend._checkout() + voices_json = checkout / "voices.json" + if voices_json.exists(): + try: + voices = json.loads(voices_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + tui.flash(stdscr, f"Could not read {voices_json}.") + return None + if not voices: + tui.flash(stdscr, "voices.json has no voices. Reconfigure faster.") + return None if voices is None: # No local voices.json: prompt for a server-side voice name. fields = [ @@ -577,10 +634,13 @@ def _faster_fields(stdscr) -> Optional[tuple]: voice = result["faster_voice"].strip() \ if isinstance(result["faster_voice"], str) \ else result["faster_voice"] - return ("convert", BACKEND_FASTER, { + kwargs = { "voice": voice or None, **_common_kwargs(result), - }) + } + if api_url is not None: + kwargs["api_url"] = api_url + return ("convert", BACKEND_FASTER, kwargs) return fields, mapper @@ -605,8 +665,7 @@ def _settings_menu(stdscr) -> None: "kind": "text", "value": str(_port_from_url(config.AUDIOCPP_API_URL, 8080)), "validate": _validate_port, - "note": "Ports apply to servers this tool starts and detecting " - "local servers"}, + "note": "Ports apply to servers this tool starts (local instances)"}, {"key": "faster_port", "label": "faster-qwen3-tts port", "kind": "text", "value": str(_port_from_url(config.FASTER_API_URL, 8000)), @@ -619,6 +678,25 @@ def _settings_menu(stdscr) -> None: "kind": "text", "value": str(_port_from_url(config.CLONE_API_URL, 7861)), "validate": _validate_port}, + {"key": "audiocpp_remote_url", "label": "audio.cpp remote URL", + "kind": "text", + "value": config.AUDIOCPP_REMOTE_URL, + "validate": _validate_remote_url, + "note": "Remote (externally-run) servers. The hub probes each URL and " + "offers a \"[remote]\" backend entry when one answers. " + "Empty disables probing."}, + {"key": "faster_remote_url", "label": "faster-qwen3-tts remote URL", + "kind": "text", + "value": config.FASTER_REMOTE_URL, + "validate": _validate_remote_url}, + {"key": "qwen_custom_remote_url", "label": "qwen-tts CustomVoice remote URL", + "kind": "text", + "value": config.QWEN_REMOTE_URL, + "validate": _validate_remote_url}, + {"key": "qwen_clone_remote_url", "label": "qwen-tts Base remote URL", + "kind": "text", + "value": config.CLONE_REMOTE_URL, + "validate": _validate_remote_url}, ] result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK) if result is None or result is _GO_BACK: @@ -669,6 +747,15 @@ def _validate_port(value: str) -> Optional[str]: return None +def _validate_remote_url(value: str) -> Optional[str]: + """Error message for an invalid remote URL, or None to accept it.""" + try: + common.normalize_remote_url(value) + return None + except ValueError as exc: + return str(exc) + + def _port_from_url(url: str, default: int) -> int: """Return the port in URL, or DEFAULT when it has none/unparsable.""" try: @@ -694,6 +781,16 @@ def _apply_settings(values: dict) -> None: "faster_port": _read_port(values, "faster_port"), "audiocpp_port": _read_port(values, "audiocpp_port"), } + remote_urls = { + "QWEN_REMOTE_URL": common.normalize_remote_url( + values.get("qwen_custom_remote_url", "")), + "CLONE_REMOTE_URL": common.normalize_remote_url( + values.get("qwen_clone_remote_url", "")), + "FASTER_REMOTE_URL": common.normalize_remote_url( + values.get("faster_remote_url", "")), + "AUDIOCPP_REMOTE_URL": common.normalize_remote_url( + values.get("audiocpp_remote_url", "")), + } updates = { "AUDIO_FORMAT": values["audio_format"], "AUDIO_BITRATE": bitrate, @@ -707,6 +804,7 @@ def _apply_settings(values: dict) -> None: config.FASTER_API_URL, ports["faster_port"]), "AUDIOCPP_API_URL": common.url_with_port( config.AUDIOCPP_API_URL, ports["audiocpp_port"]), + **remote_urls, } _write_config(updates) for name, value in updates.items(): @@ -771,27 +869,47 @@ def _write_config(updates: dict) -> None: def _run_conversion(backend: str, kwargs: dict) -> None: """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. + 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. """ 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 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:") + 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}") - 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}") try: audiobook.convert(backend=backend, **kwargs) finally: @@ -815,9 +933,13 @@ def _add_autostart(cmd: tuple, statuses) -> None: 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). + 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. """ _, key, kwargs = cmd + if kwargs.get("api_url"): + return status = next((s for s in statuses if s.key == key), None) if status is None or not status.servers: return |
