aboutsummaryrefslogtreecommitdiff
path: root/app/backends/managed.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-28 17:16:54 -0400
committerhistoria <historiavg@proton.me>2026-08-28 17:16:54 -0400
commite66eb0e7d4342ae1c58e9bbd341843753be548f0 (patch)
tree0ade3b71c86b59511d7653e450377a9366a30482 /app/backends/managed.py
parent270fa60c01866c4431d540be960b6cd2bc2b9c44 (diff)
downloadtts-audiobook-generator-e66eb0e7d4342ae1c58e9bbd341843753be548f0.tar.gz
feat: cli auto-starts and stops locally-managed servers if no --api-url is passed
Diffstat (limited to 'app/backends/managed.py')
-rw-r--r--app/backends/managed.py162
1 files changed, 162 insertions, 0 deletions
diff --git a/app/backends/managed.py b/app/backends/managed.py
new file mode 100644
index 0000000..3b98aea
--- /dev/null
+++ b/app/backends/managed.py
@@ -0,0 +1,162 @@
+"""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
+
+
+@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) -> 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; 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: 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)
+ return _boot(spec, backend, voice_mode)
+
+
+def _spec_for(status, backend: str, voice_mode: str) -> ServerSpec:
+ """The server spec this run needs, from STATUS's detected servers."""
+ 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) -> ManagedServer:
+ """Start or reuse the server SPEC describes, per the run's needs."""
+ wanted_model = None
+ if backend == BACKEND_QWEN:
+ from backends import qwen
+ wanted_model = qwen.model_for_voice_mode(voice_mode)
+
+ if common.server_running(spec.url):
+ if wanted_model is None:
+ print(f"[INFO] using the {spec.name} server already running "
+ f"at {spec.url}")
+ return ManagedServer(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)