"""Start and stop a managed TTS server around a CLI conversion run. The TUI owns the server lifecycle through its Generate flow (the hub's autostart plan boots the server in the run view, and the stop-and-exit toggle shuts it down afterwards). This module gives the CLI the same capability: when ``audiobook.py`` runs without ``--api-url`` it calls ``ensure_running`` to boot the selected backend's managed server — the instance installed through the TUI — converts against it, and stops it again afterwards. Only a server this run started is ever stopped: one found already answering at the backend's configured URL is used as-is and left running when the run ends, whoever started it. The qwen backend hosts one model per process, so its "already running" check is model-aware: a managed demo hosting another model than the run needs is stopped and rebooted with the right one (the TUI's Generate form does the same), while a foreign server with the wrong model refuses the run with an actionable message instead of letting the conversion fail against the wrong endpoints. The other backends host all their models in one server process, so any server answering at the configured URL is usable as-is. All server boot/stop console output comes from ``backends.servers`` (the same lines the TUI's console tail prints); this module only adds the decisions around them. Like ``servers`` it is presentation-agnostic enough to run in the plain console — the CLI's only caller. """ from dataclasses import dataclass from typing import Optional from backends import ServerSpec, common, probe, servers from converter.clients import BACKEND_QWEN, BACKEND_SGLOMNI @dataclass class ManagedServer: """The server-lifecycle outcome of one conversion run's boot phase. SPEC is the server spec the conversion targets (its URL is the endpoint the converter uses when no ``--api-url`` overrides it). STARTED is True when this run spawned (or rebooted) the process — only then does ``shutdown`` stop it; a server found already running belongs to whoever launched it and is left alone. OK is False when the boot failed or was refused: the caller must not convert, and ``shutdown`` is a no-op (``servers.start`` owns any pid file left behind, mirroring the TUI's behavior for a failed boot). """ spec: ServerSpec started: bool = False ok: bool = True def shutdown(self) -> None: """Stop the server when this run started it (no-op otherwise).""" if self.started: servers.stop(self.spec.name) def ensure_running(backend: str, voice_mode: str, model: Optional[str] = None) -> Optional[ManagedServer]: """Make the backend's managed server ready for a conversion run. Resolves the server spec for BACKEND (qwen: the demo hosting the model VOICE_MODE needs; sglomni: the server hosting MODEL — one model per process; the others: their single configured spec), then starts it when its port is free — waiting out the boot and streaming ``servers``' console progress — or reuses the server already answering there (qwen/sglomni: restarting a managed server that hosts another model, refusing a foreign one). Returns the run's ``ManagedServer`` (call ``shutdown`` when the conversion is over), or None when the backend is not installed here and nothing can be started: the caller proceeds unmanaged, since a foreign server at the configured endpoint may still answer and otherwise the conversion fails with the converter's own unreachable-server message. Raises KeyboardInterrupt when the boot poll is interrupted (after stopping a server this call spawned, so nothing is left loading). """ from backends import detect as _registry_detect status = _registry_detect(backend) if status is None or not status.servers or not status.installed: label = status.label if status is not None else backend print(f"[WARNING] {label} is not installed — cannot start a server " "automatically; the conversion will use the configured " "endpoint (see the TUI's Configure Backends to install it).") return None spec = _spec_for(status, backend, voice_mode, model) return _boot(spec, backend, voice_mode, model) def _spec_for(status, backend: str, voice_mode: str, model: Optional[str] = None) -> ServerSpec: """The server spec this run needs, from STATUS's detected servers.""" if backend == BACKEND_SGLOMNI: # sglomni hosts one model per process: aim the spec at the model # this run selected (the converter resolves it again; resolving # here too keeps the boot check and the conversion consistent). from backends.sglomni import models as sg_models from backends.sglomni import status as sg_status return sg_status.build_spec(sg_models.resolve_model(model)) if backend != BACKEND_QWEN: return status.servers[0] # qwen hosts one model per process: aim the spec at the model this # run selected rather than the default one detect() reports. from backends import qwen return qwen.build_spec(qwen.model_for_voice_mode(voice_mode)) def _boot(spec: ServerSpec, backend: str, voice_mode: str, model: Optional[str] = None) -> ManagedServer: """Start or reuse the server SPEC describes, per the run's needs.""" wanted_model = None wanted_repo = None if backend == BACKEND_QWEN: from backends import qwen wanted_model = qwen.model_for_voice_mode(voice_mode) if backend == BACKEND_SGLOMNI: from backends.sglomni import models as sg_models entry = sg_models.resolve_model(model) wanted_repo = entry.repo # When this GPU cannot run the model's default FP8 pipeline, the # spec launches the vendored bf16 config — say so before the boot. from backends.sglomni import status as sg_status note = sg_status.gpu_fallback_note(entry) if note: print(f"[WARNING] {note}") if common.server_running(spec.url): if wanted_model is None and wanted_repo is None: print(f"[INFO] using the {spec.name} server already running " f"at {spec.url}") return ManagedServer(spec) if wanted_repo is not None: running_repo = probe.sglomni_served_model(spec.url) if running_repo == wanted_repo: print(f"[INFO] using the {spec.name} server already " f"running at {spec.url} (hosting {wanted_repo})") return ManagedServer(spec) hosted = running_repo or "an unknown model" if not servers.alive(spec.name): print(f"[ERROR] a server this tool did not start is " f"running at {spec.url} hosting {hosted} — this run " f"needs {wanted_repo}. Stop that server first, or " "convert with it by picking that model.") return ManagedServer(spec, ok=False) # Ours: stop it and boot the newly-selected model on the same # port (the TUI's Generate form restarts a managed server the # same way when the run's model selection changes). print(f"[INFO] restarting the {spec.name} server to host " f"{wanted_repo}...") servers.stop(spec.name) return _start(spec) running_model = qwen.model_for_identity( probe.identify_server(spec.url)) if running_model == wanted_model: print(f"[INFO] using the {spec.name} server already running " f"at {spec.url} (hosting {wanted_model})") return ManagedServer(spec) if not servers.alive(spec.name): print(f"[ERROR] a server this tool did not start is running at " f"{spec.url} hosting {running_model or 'an unknown'} — " f"this run needs {wanted_model}. Stop that server first, " "or adjust the voice flags to use the hosted model.") return ManagedServer(spec, ok=False) # Ours: stop it and boot the newly-selected model on the same # port (the TUI's Generate form restarts a managed server the # same way when the run's model selection changes). print(f"[INFO] restarting the {spec.name} server to host " f"{wanted_model}...") servers.stop(spec.name) return _start(spec) def _start(spec: ServerSpec) -> ManagedServer: """Spawn SPEC's server and wait for readiness (``servers.start``). STARTED is recorded only when ``servers.start`` actually spawned the process — its "running" event (a server appeared under us between the caller's port check and the spawn) marks the run as a reuser, so ``shutdown`` never stops a server this run did not start. A failed boot reports False and leaves any pid file in place, exactly like the TUI's boot path. """ spawned = {"yes": True} def _progress(event: dict) -> None: if event.get("kind") == "running": spawned["yes"] = False # Same-package reuse of the console printer: without it a custom # progress callback would silence the CLI's boot output. servers._console_progress(event) try: ok = servers.start(spec, progress=_progress) except KeyboardInterrupt: # Ctrl-C while waiting out the boot: kill what we spawned so no # half-booted server is left loading in the background. Without a # pid file nothing was spawned (or it exited already) — skip the # noisy "not started by this tool" notice. if servers.pid_for(spec.name) is not None: servers.stop(spec.name) raise return ManagedServer(spec, started=spawned["yes"] and ok, ok=ok)