aboutsummaryrefslogtreecommitdiff
path: root/app/backends/managed.py
blob: 3b98aea3b4a47678ee0ac42485a106de93eb6479 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
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)