aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--app/backends/__init__.py9
-rwxr-xr-xapp/backends/audiocpp.py11
-rwxr-xr-xapp/backends/faster.py9
-rw-r--r--app/backends/qwen.py22
-rw-r--r--app/backends/servers.py16
-rw-r--r--app/tests/test_backends.py40
-rw-r--r--app/tests/test_backends_servers.py51
-rw-r--r--app/tests/test_hub.py227
-rw-r--r--app/tests/test_tui.py11
-rw-r--r--app/ui/hub.py92
-rw-r--r--app/ui/tui.py12
12 files changed, 417 insertions, 85 deletions
diff --git a/README.md b/README.md
index cf6d4a1..2bdabd8 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@ A full-screen TUI opens and shows each backend's status in a table — **unavail
- **Configure a backend…** — regenerate its config (a new `server.json`, rebuild `voices.json`, change ports/speaker), or
- **Server…** — manually start or stop a configured backend's server (the hub spawns it in the managed venv and polls until it accepts connections).
-**Configure a backend…** and **Server…** only appear once at least one backend is installed or running.
+**Configure a backend…** and **Server…** only appear once at least one backend is installed — a merely-running external server unlocks **Convert books…**, but configuring it or starting/stopping its server needs it on this machine.
Everything the TUI does can also be scripted with flags: `python audiobook.py --backend audiocpp --model higgs --voice narrator`, or `python app/backends/audiocpp.py --families higgs_audio_tts --clone --build-backend cuda`.
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
index ed772d4..9facd13 100644
--- a/app/backends/__init__.py
+++ b/app/backends/__init__.py
@@ -55,6 +55,13 @@ class BackendStatus:
start the server, derived from SERVERS by ``format_launch_hint``.
SERVERS is the machine-usable list of server processes the hub can
start/stop (empty when the backend is not yet configured).
+
+ MANAGED says a running server was started by this tool: ``servers``
+ contains a spec whose pid file still names a live process (see
+ ``servers.manages``); when RUNNING but not MANAGED the hub tags the
+ status "[remote]". RUNNING_MODELS names which of a multi-server
+ backend's models answered (qwen: "Base" and/or "CustomVoice"), shown
+ in parentheses in the hub's status table.
"""
key: str
label: str
@@ -64,6 +71,8 @@ class BackendStatus:
details: List[str] = field(default_factory=list)
launch_hint: str = ""
servers: List[ServerSpec] = field(default_factory=list)
+ managed: bool = False
+ running_models: List[str] = field(default_factory=list)
@property
def ready(self) -> bool:
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py
index 2368ce7..673d344 100755
--- a/app/backends/audiocpp.py
+++ b/app/backends/audiocpp.py
@@ -47,6 +47,7 @@ from backends import (
ServerSpec,
common,
format_launch_hint,
+ servers,
)
from backends.common import (
APP_DIR,
@@ -1712,11 +1713,11 @@ def detect() -> BackendStatus:
details.append("not built — run setup to build audiocpp_server")
server_json = checkout / "server.json"
configured = server_json.exists()
- servers: List[ServerSpec] = []
+ specs: List[ServerSpec] = []
if configured:
details.append(f"config: {server_json}")
if built:
- servers = [ServerSpec(
+ specs = [ServerSpec(
"audiocpp", config.AUDIOCPP_API_URL,
[str(binary), "--config", str(server_json)])]
else:
@@ -1724,12 +1725,12 @@ def detect() -> BackendStatus:
f"audiocpp_server --config {server_json}")
else:
details.append("no server.json — run setup to configure models")
- if servers:
- launch = format_launch_hint(servers)
+ if specs:
+ launch = format_launch_hint(specs)
return BackendStatus("audiocpp", "audio.cpp", installed=built,
configured=configured, running=running,
details=details, launch_hint=launch,
- servers=servers)
+ servers=specs, managed=servers.manages(specs))
configure_actions: List[ConfigureAction] = [
diff --git a/app/backends/faster.py b/app/backends/faster.py
index 0d34a0f..77b9c8a 100755
--- a/app/backends/faster.py
+++ b/app/backends/faster.py
@@ -31,6 +31,7 @@ from backends import (
common,
envs,
format_launch_hint,
+ servers,
)
from backends.common import (
APP_DIR,
@@ -356,18 +357,18 @@ def detect() -> BackendStatus:
details.append(f"voices: {voices_json}" if voices_json.exists() else
"no voices.json — run setup to create one")
launch = ""
- servers: List[ServerSpec] = []
+ specs: List[ServerSpec] = []
if cloned and voices_json.exists():
argv = [str(envs.env_python()),
str(_checkout() / "examples" / "openai_server.py"),
"--voices", str(voices_json), "--port", str(_config_port())]
- servers = [ServerSpec("faster", config.FASTER_API_URL, argv)]
- launch = format_launch_hint(servers)
+ specs = [ServerSpec("faster", config.FASTER_API_URL, argv)]
+ launch = format_launch_hint(specs)
return BackendStatus("faster", "faster-qwen3-tts",
installed=installed and cloned,
configured=configured, running=running,
details=details, launch_hint=launch,
- servers=servers)
+ servers=specs, managed=servers.manages(specs))
def _run_voices_only_tui() -> int:
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index 21280a0..64f3996 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -27,6 +27,7 @@ from backends import (
common,
envs,
format_launch_hint,
+ servers,
)
from converter import config
from ui import tui
@@ -215,10 +216,15 @@ def detect() -> BackendStatus:
installed = _is_installed()
custom_port = _config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT)
clone_port = _config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT)
- # Running when either server is up — CustomVoice (speaker mode) or Base
- # (voice clone) each suffice for a conversion on their own.
- running = (common.server_running(config.QWEN_API_URL)
- or common.server_running(config.CLONE_API_URL))
+ # Probe each port separately: whichever answers names the running
+ # model — CustomVoice (speaker mode) or Base (voice clone); either
+ # suffices for a conversion on its own.
+ custom_up = common.server_running(config.QWEN_API_URL)
+ clone_up = common.server_running(config.CLONE_API_URL)
+ running = custom_up or clone_up
+ running_models = [name for name, up in
+ (("Base", clone_up), ("CustomVoice", custom_up))
+ if up]
details: List[str] = []
details.append("pip: installed" if installed else
"not installed — run setup to pip install qwen-tts")
@@ -226,7 +232,7 @@ def detect() -> BackendStatus:
details.append(f"Base (clone) port: {clone_port}")
details.append(f"speaker: {config.SPEAKER}")
demo = str(envs.env_script("qwen-tts-demo"))
- servers = [
+ specs = [
ServerSpec("qwen-custom", config.QWEN_API_URL,
[demo, QWEN_CUSTOMVOICE_MODEL, "--ip", "127.0.0.1",
"--port", str(custom_port)]),
@@ -237,8 +243,10 @@ def detect() -> BackendStatus:
return BackendStatus("qwen", "qwen-tts",
installed=installed, configured=installed,
running=running, details=details,
- launch_hint=format_launch_hint(servers),
- servers=servers)
+ launch_hint=format_launch_hint(specs),
+ servers=specs,
+ managed=servers.manages(specs),
+ running_models=running_models)
configure_actions: List[ConfigureAction] = [
diff --git a/app/backends/servers.py b/app/backends/servers.py
index a5a6829..63f37ce 100644
--- a/app/backends/servers.py
+++ b/app/backends/servers.py
@@ -233,6 +233,22 @@ def stop(name: str) -> bool:
return killed
+def manages(specs) -> bool:
+ """True when any SPEC in the list was started (and is kept alive) by us.
+
+ A server counts as ours when ``start`` recorded a pid file for it and
+ that pid is still alive — the same ownership rule ``stop`` applies
+ before refusing ("not started by this tool"). Used by the backends'
+ ``detect()`` so the hub's status table can tag an up server as
+ "[remote]" when it was launched outside this tool.
+ """
+ for spec in specs:
+ pid = pid_for(spec.name)
+ if pid is not None and _pid_alive(pid):
+ return True
+ return False
+
+
def pid_for(name: str):
"""Return the recorded pid for NAME, or None when no pid file exists."""
pid_file = _pid_path(name)
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index c0e8d4a..ac4c8ef 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -99,18 +99,56 @@ class DetectAllTests(unittest.TestCase):
self.assertFalse(status.configured)
def test_qwen_running_when_either_port_is_up(self):
- # Either the CustomVoice port or the Base port counts as running.
+ # Either the CustomVoice port or the Base port counts as running,
+ # and the status names which model answered. Probes: CustomVoice
+ # (QWEN_API_URL) first, then Base (CLONE_API_URL).
from backends import qwen
with patch.object(qwen, "_is_installed", return_value=False), \
patch("backends.common.server_running",
side_effect=[True, False]):
status = qwen.detect()
self.assertTrue(status.running)
+ self.assertEqual(status.running_models, ["CustomVoice"])
with patch.object(qwen, "_is_installed", return_value=False), \
patch("backends.common.server_running",
side_effect=[False, True]):
status = qwen.detect()
self.assertTrue(status.running)
+ self.assertEqual(status.running_models, ["Base"])
+
+ def test_qwen_running_models_names_both_ports(self):
+ # Both ports up → both models, Base first (the hub renders
+ # "running (Base, CustomVoice)").
+ from backends import qwen
+ with patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running",
+ side_effect=[True, True]):
+ status = qwen.detect()
+ self.assertTrue(status.running)
+ self.assertEqual(status.running_models, ["Base", "CustomVoice"])
+
+ def test_qwen_detect_marks_our_server_as_managed(self):
+ from backends import qwen
+ from backends import servers as servers_mod
+ with tempfile.TemporaryDirectory() as td:
+ (Path(td) / "qwen-custom-server.pid").write_text(
+ "4242", encoding="utf-8")
+ with patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers_mod, "LOG_DIR", Path(td)), \
+ patch.object(servers_mod, "_pid_alive",
+ return_value=True):
+ status = qwen.detect()
+ self.assertTrue(status.managed)
+ # Without a live pid file the same server counts as remote.
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers_mod, "LOG_DIR", Path(td)):
+ status = qwen.detect()
+ self.assertFalse(status.managed)
def test_faster_status_reflects_install_clone_voices(self):
from backends import faster
diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py
index 02b65e6..987ff24 100644
--- a/app/tests/test_backends_servers.py
+++ b/app/tests/test_backends_servers.py
@@ -123,6 +123,57 @@ class StopTests(unittest.TestCase):
self.assertFalse((self.dir / "test-server.pid").exists())
+class ManagesTests(unittest.TestCase):
+ """manages(): a live recorded pid marks a server as ours."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+ self.specs = [ServerSpec("test", "http://127.0.0.1:9999", [])]
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _write_pid(self, name, pid):
+ (self.dir / f"{name}-server.pid").write_text(str(pid),
+ encoding="utf-8")
+
+ def test_false_without_pid_file(self):
+ with patch.object(servers, "LOG_DIR", self.dir):
+ self.assertFalse(servers.manages(self.specs))
+
+ def test_true_with_live_recorded_pid(self):
+ self._write_pid("test", 4242)
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch.object(servers, "_pid_alive", return_value=True):
+ self.assertTrue(servers.manages(self.specs))
+
+ def test_false_with_dead_recorded_pid(self):
+ self._write_pid("test", 4242)
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch.object(servers, "_pid_alive", return_value=False):
+ self.assertFalse(servers.manages(self.specs))
+
+ def test_false_with_corrupt_pid_file(self):
+ (self.dir / "test-server.pid").write_text("junk",
+ encoding="utf-8")
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch.object(servers, "_pid_alive",
+ return_value=True) as mk_alive:
+ self.assertFalse(servers.manages(self.specs))
+ mk_alive.assert_not_called()
+
+ def test_true_when_any_spec_is_ours(self):
+ other = ServerSpec("other", "http://127.0.0.1:9998", [])
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch.object(servers, "_pid_alive", return_value=True):
+ self._write_pid("test", 4242)
+ self.assertTrue(servers.manages([other] + self.specs))
+ # The live pid belongs to 'test'; 'other' alone stays unmanaged.
+ with patch.object(servers, "LOG_DIR", self.dir):
+ self.assertFalse(servers.manages([other]))
+
+
class PidForTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 0e7c2bd..61f0da1 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -8,7 +8,7 @@ import unittest
from pathlib import Path
from unittest.mock import patch
-from backends import BackendStatus, ServerSpec
+from backends import BackendInfo, BackendStatus, ServerSpec
from tests.test_tui import FakeCurses, FakeScreen
from ui import hub, tui
@@ -38,7 +38,15 @@ class HubHelperTests(unittest.TestCase):
def test_status_mark(self):
from backends import BackendStatus
running = BackendStatus("k", "l", installed=True, configured=True,
- running=True)
+ running=True, managed=True)
+ remote = BackendStatus("k", "l", installed=True, configured=True,
+ running=True)
+ models = BackendStatus("k", "l", installed=True, configured=True,
+ running=True, managed=True,
+ running_models=["Base", "CustomVoice"])
+ remote_models = BackendStatus("k", "l", installed=True,
+ configured=True, running=True,
+ running_models=["Base"])
installed = BackendStatus("k", "l", installed=True,
configured=False)
none = BackendStatus("k", "l", installed=False, configured=False)
@@ -46,6 +54,14 @@ class HubHelperTests(unittest.TestCase):
# only a backend that is neither installed nor running is dimmed.
self.assertEqual(hub._status_mark(running),
("running", "ok", "body"))
+ # A server without a live recorded pid was started externally.
+ self.assertEqual(hub._status_mark(remote),
+ ("running [remote]", "ok", "body"))
+ # Multi-model backends name the models that answered.
+ self.assertEqual(hub._status_mark(models),
+ ("running (Base, CustomVoice)", "ok", "body"))
+ self.assertEqual(hub._status_mark(remote_models),
+ ("running [remote] (Base)", "ok", "body"))
self.assertEqual(hub._status_mark(installed),
("installed", "warn", "body"))
self.assertEqual(hub._status_mark(none),
@@ -133,15 +149,16 @@ class HubMenuTests(unittest.TestCase):
patch.object(hub, "detect_all",
return_value=[dead, external]):
hub._hub_menu(screen)
- # Unusable backend: dim name. Running-but-not-installed stays bright.
+ # Unusable backend: dim name. Running-but-not-installed stays
+ # bright and is tagged remote (no pid file → not started by us).
self.assertEqual(
captured["rows"],
[("audio.cpp", "unavailable", "err", "dim"),
- ("qwen-tts", "running", "ok", "body")])
+ ("qwen-tts", "running [remote]", "ok", "body")])
- def test_menu_has_all_six_when_one_running_only(self):
- # Running but not installed (an external server) still unlocks the
- # Convert/Configure/Server entries.
+ def test_menu_hides_configure_and_server_when_only_running(self):
+ # Running but not installed (an external server) still unlocks
+ # Convert — but Configure/Server need the backend on this machine.
captured = {}
def fake_menu(stdscr, title, options, **kwargs):
@@ -157,9 +174,7 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Convert books", "Set up a backend",
- "Configure a backend", "Start/Stop Backend Servers",
- "Settings", "Quit"])
+ ["Convert books", "Set up a backend", "Settings", "Quit"])
def test_ffmpeg_warning_shown_when_missing(self):
# ffmpeg not on PATH → a red notice is passed above the table.
@@ -192,37 +207,182 @@ class HubMenuTests(unittest.TestCase):
hub._hub_menu(screen)
self.assertIsNone(captured["notice_lines"])
- def test_convert_with_no_available_backend_offers_setup(self):
- # One installed-but-not-ready backend → Convert is offered. The
- # convert menu lists no available backend, so only "Set up a
- # backend" is shown; Enter selects it → setup menu lists 3
- # backends; Esc goes back → convert returns None → main menu loops.
- # Then quit: main menu now has 5 options, Quit is the 5th (Down x4).
- from backends import BackendInfo, BackendStatus
- none = BackendStatus("k", "l", installed=True, configured=False)
- infos = [BackendInfo("audiocpp", "audio.cpp", lambda: none,
- lambda: 0),
- BackendInfo("qwen", "qwen-tts", lambda: none, lambda: 0),
- BackendInfo("faster", "faster", lambda: none, lambda: 0)]
- # installed=True so the main menu shows Convert; but ready/running
- # is False so the convert menu's available list is empty.
+ def test_convert_with_no_available_backend_flashes(self):
+ # Installed-but-not-ready backends → Convert is offered, but the
+ # convert flow has nothing to list: it flashes a hint (no "Set up
+ # a backend" detour anymore) and returns to the main menu. Then
+ # quit: 6 main-menu options, Quit is the 6th (Down x5).
+ from backends import BackendStatus
statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True,
configured=False),
BackendStatus("qwen", "qwen-tts", installed=True,
configured=False),
BackendStatus("faster", "faster", installed=True,
configured=False)]
+ flashed = []
+
+ def fake_flash(stdscr, text, kind="warn"):
+ flashed.append(text)
+
with patch.object(hub, "detect_all", return_value=statuses), \
- patch.object(hub, "REGISTRY", infos):
- # Convert(Enter), setup-entry(Enter), Esc on setup menu,
- # back at main menu -> Down x5 -> Enter (Quit; Settings sits
- # just before it).
- screen = FakeScreen(keys=[10, 10, 27,
- FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_DOWN, 10])
+ patch.object(hub.tui, "flash", fake_flash):
+ # Convert(Enter) → flash → main menu; Down x5 -> Quit, Enter.
+ screen = FakeScreen(keys=[10,
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, 10])
result = hub._hub_menu(screen)
self.assertIsNone(result)
+ self.assertEqual(len(flashed), 1)
+ self.assertIn("No backend is ready", flashed[0])
+
+
+class SubmenuStatusTableTests(unittest.TestCase):
+ """First picker screen of every flow repeats the backend status table.
+
+ Entries themselves stay clean: setup lists bare labels, and the
+ Start/Stop menu offers only installed backends.
+ """
+
+ def _capture_menu(self, captured):
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["title"] = title
+ captured["options"] = options
+ captured.update(kwargs)
+ return hub._GO_BACK # Esc: back out immediately
+
+ return fake_menu
+
+ def test_setup_menu_lists_bare_labels_and_status_table(self):
+ captured = {}
+ infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0),
+ BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)]
+ statuses = [
+ BackendStatus("audiocpp", "audio.cpp", installed=True,
+ configured=True),
+ BackendStatus("qwen", "qwen-tts", installed=False,
+ configured=False, running=True),
+ ]
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub.tui, "menu",
+ self._capture_menu(captured)), \
+ patch.object(hub.shutil, "which",
+ return_value="/usr/bin/ffmpeg"):
+ result = hub._setup_menu(None, statuses)
+ self.assertIsNone(result)
+ # No inline "(running)"-style suffix on the entries anymore...
+ self.assertEqual([label for label, _ in captured["options"]],
+ ["audio.cpp", "qwen-tts"])
+ # ...the shared status table carries the states instead.
+ self.assertEqual(captured["table_title"], "Backend status")
+ self.assertEqual(
+ captured["table_rows"],
+ [("audio.cpp", "installed", "warn", "body"),
+ ("qwen-tts", "running [remote]", "ok", "body")])
+ self.assertIsNone(captured["notice_lines"])
+
+ def test_convert_menu_shows_status_table(self):
+ captured = {}
+ st = BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True)
+ with patch.object(hub.tui, "menu", self._capture_menu(captured)), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ result = hub._convert_menu(None, [st])
+ self.assertIsNone(result)
+ self.assertEqual(captured["title"], "Convert books with...")
+ # Only convertible backends are listed — no "Set up a backend"
+ # detour inside the Convert flow.
+ self.assertEqual([label for label, _ in captured["options"]],
+ ["qwen-tts"])
+ self.assertEqual(
+ captured["table_rows"], [("qwen-tts", "installed", "warn",
+ "body")])
+
+ def test_convert_with_nothing_ready_flashes_instead_of_menu(self):
+ # Installed-but-unconfigured → nothing convertible: a hint flash
+ # replaces the old fallback menu entirely.
+ flashed = []
+ menus = []
+
+ def fake_menu(*args, **kwargs):
+ menus.append((args, kwargs))
+ return hub._GO_BACK
+
+ def fake_flash(stdscr, text, kind="warn"):
+ flashed.append(text)
+
+ st = BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=False)
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub.tui, "flash", fake_flash):
+ result = hub._convert_menu(None, [st])
+ self.assertIsNone(result)
+ self.assertEqual(menus, [])
+ self.assertIn("No backend is ready", flashed[0])
+
+ def test_configure_menu_shows_status_table(self):
+ captured = {}
+ infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)]
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True)]
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub.tui, "menu",
+ self._capture_menu(captured)), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ result = hub._configure_menu(None, statuses)
+ self.assertIsNone(result)
+ self.assertEqual(captured["table_title"], "Backend status")
+ self.assertEqual(
+ captured["table_rows"], [("qwen-tts", "installed", "warn",
+ "body")])
+
+ def test_server_menu_lists_only_installed_backends(self):
+ captured = {}
+ installed = BackendStatus("audiocpp", "audio.cpp", installed=True,
+ configured=True)
+ remote = BackendStatus("qwen", "qwen-tts", installed=False,
+ configured=False, running=True)
+ gone = BackendStatus("faster", "faster-qwen3-tts", installed=False,
+ configured=False)
+ with patch.object(hub.tui, "menu",
+ self._capture_menu(captured)), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ result = hub._server_menu(None, [installed, remote, gone])
+ self.assertIsNone(result)
+ # Only the installed backend is offered; a running external server
+ # (remote) can't be stopped from here and must not appear.
+ self.assertEqual([label for label, _ in captured["options"]],
+ ["audio.cpp"])
+ # The status table still shows all three, states included.
+ self.assertEqual([row[0] for row in captured["table_rows"]],
+ ["audio.cpp", "qwen-tts", "faster-qwen3-tts"])
+
+ def test_server_menu_flashes_when_nothing_installed(self):
+ flashed = []
+
+ def fake_flash(stdscr, text, kind="warn"):
+ flashed.append(text)
+
+ remote = BackendStatus("qwen", "qwen-tts", installed=False,
+ configured=False, running=True)
+ with patch.object(hub.tui, "flash", fake_flash):
+ result = hub._server_menu(None, [remote])
+ self.assertIsNone(result)
+ self.assertEqual(len(flashed), 1)
+ self.assertIn("No backend is installed", flashed[0])
+
+ def test_submenu_repeats_ffmpeg_warning(self):
+ captured = {}
+ infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)]
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True)]
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub.tui, "menu",
+ self._capture_menu(captured)), \
+ patch.object(hub.shutil, "which", return_value=None):
+ hub._setup_menu(None, statuses)
+ self.assertEqual(captured["notice_lines"],
+ [("Warning: ffmpeg not installed!", "err")])
class SelectSpecTests(unittest.TestCase):
@@ -492,6 +652,9 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(kinds["audio_format"], "choice")
self.assertEqual(kinds["audio_bitrate"], "text")
self.assertEqual(kinds["audiocpp_port"], "text")
+ labels = {f["key"]: f["label"] for f in captured["fields"]}
+ self.assertEqual(labels["qwen_clone_port"], "qwen-tts Base port")
+ self.assertNotIn("(clone)", " ".join(labels.values()))
# The ports section note hangs off the first port field so it
# renders between the output settings and the ports.
notes = {f["key"]: f.get("note") for f in captured["fields"]}
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index 38f4fa9..e7fd976 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -751,6 +751,17 @@ class FlashTests(TuiTestCase):
tui.flash(screen, "a notice", kind="warn")
self.assertEqual(screen.keys, [])
+ def test_message_is_shown_without_a_notice_heading(self):
+ screen = FakeScreen(keys=[10])
+ tui.flash(screen, "No backend is installed", kind="warn")
+ texts = [text for _, _, text, _ in screen.strings]
+ self.assertNotIn("Notice", texts)
+ # The warning itself is the dialog's content, in its kind color.
+ attr = next(a for _, _, text, a in screen.strings
+ if text == "No backend is installed")
+ self.assertEqual(attr, tui._THEME["warn"])
+ self.assert_inside_border(screen)
+
if __name__ == "__main__":
unittest.main()
diff --git a/app/ui/hub.py b/app/ui/hub.py
index c3efce6..5f3d039 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -79,20 +79,20 @@ def _hub_menu(stdscr) -> Optional[tuple]:
while True:
statuses = detect_all()
options = [("Set up a backend", "setup")]
+ # Converting works against an external (remote) server too, but
+ # configuring one and starting/stopping its servers need it on
+ # this machine.
if any(st.installed or st.running for st in statuses):
options.insert(0, ("Convert books", "convert"))
+ if any(st.installed for st in statuses):
options.append(("Configure a backend", "configure"))
options.append(("Start/Stop Backend Servers", "server"))
options.append(("Settings", "settings"))
options.append(("Quit", "quit"))
- rows = [(st.label, *_status_mark(st)) for st in statuses]
- notice_lines = None
- if shutil.which("ffmpeg") is None:
- notice_lines = [("Warning: ffmpeg not installed!", "err")]
choice = tui.menu(
stdscr, "tts-audiobook-generator", options,
- table_title="Backend status", table_rows=rows,
- notice_lines=notice_lines)
+ table_title="Backend status", table_rows=_status_rows(statuses),
+ notice_lines=_notice_lines())
if choice is None or choice == "quit":
return None
if choice == "convert":
@@ -117,13 +117,14 @@ def _hub_menu(stdscr) -> Optional[tuple]:
def _setup_menu(stdscr, statuses) -> Optional[tuple]:
"""Pick a backend to set up. Returns ("setup", key) or None to go back."""
- by_key = {st.key: st for st in statuses}
- options = [(f"{info.label} ({_status_mark(by_key.get(info.key))[0]})",
- info.key) for info in REGISTRY]
+ options = [(info.label, info.key) for info in REGISTRY]
choice = tui.menu(stdscr, "Set up a backend", options,
back_value=_GO_BACK,
help_lines=["Clone/build/install a backend so you can "
- "convert with it."])
+ "convert with it."],
+ table_title="Backend status",
+ table_rows=_status_rows(statuses),
+ notice_lines=_notice_lines())
if choice is _GO_BACK or choice is None:
return None
return ("setup", choice)
@@ -140,7 +141,11 @@ def _configure_menu(stdscr, statuses) -> Optional[tuple]:
"backend' first.")
return None
options = [(info.label, info.key) for info in installed]
- key = tui.menu(stdscr, "Configure a backend", options, back_value=_GO_BACK)
+ key = tui.menu(stdscr, "Configure a backend", options,
+ back_value=_GO_BACK,
+ table_title="Backend status",
+ table_rows=_status_rows(statuses),
+ notice_lines=_notice_lines())
if key is _GO_BACK or key is None:
return None
info = get(key)
@@ -161,36 +166,57 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
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.
"""
if status is not None and status.running:
- return ("running", "ok", "body")
+ text = "running"
+ if not status.managed:
+ text += " [remote]"
+ if status.running_models:
+ text += " (" + ", ".join(status.running_models) + ")"
+ return (text, "ok", "body")
if status is not None and status.installed:
return ("installed", "warn", "body")
return ("unavailable", "err", "dim")
+def _status_rows(statuses) -> list:
+ """Status-table rows for tui.menu: (label, status, kind, name_kind).
+
+ One row per detected backend, in detect order — the same table the
+ main menu shows, reused on each flow's first picker screen so the
+ backend states stay visible there.
+ """
+ return [(st.label, *_status_mark(st)) for st in statuses]
+
+
+def _notice_lines() -> Optional[list]:
+ """Warning lines shown above the status table, or None when all good."""
+ if shutil.which("ffmpeg") is None:
+ return [("Warning: ffmpeg not installed!", "err")]
+ return None
+
+
def _convert_menu(stdscr, statuses) -> Optional[tuple]:
"""Pick an available backend and collect per-backend run settings."""
available = [st for st in statuses if st.ready or st.running]
- options = [(st.label, st.key) for st in available]
if not available:
- choice = tui.menu(
- stdscr, "No backend is available",
- [("Set up a backend", "__setup__")],
- help_lines=["Set up a backend (clone/build/configure) before "
- "converting."])
- if choice == "__setup__":
- return _setup_menu(stdscr, statuses)
- return None
- options.append(("Set up a backend", "__setup__"))
+ tui.flash(stdscr, "No backend is ready to convert with yet — use "
+ "'Set up a backend' first.")
+ return None
+ options = [(st.label, st.key) for st in available]
+ table = {"table_title": "Backend status",
+ "table_rows": _status_rows(statuses),
+ "notice_lines": _notice_lines()}
key = tui.menu(stdscr, "Convert books with...", options,
- back_value=_GO_BACK)
+ back_value=_GO_BACK, **table)
if key is _GO_BACK or key is None:
return None
- if key == "__setup__":
- return _setup_menu(stdscr, statuses)
if key == BACKEND_AUDIOCPP:
cmd = _convert_audiocpp(stdscr, statuses)
elif key == BACKEND_QWEN:
@@ -406,7 +432,7 @@ def _settings_menu(stdscr) -> None:
"validate": _validate_port,
"note": "Ports apply to servers this tool starts and detecting "
"local servers"},
- {"key": "qwen_clone_port", "label": "qwen-tts Base (clone) port",
+ {"key": "qwen_clone_port", "label": "qwen-tts Base port",
"kind": "text",
"value": str(_port_from_url(config.CLONE_API_URL, 7861)),
"validate": _validate_port},
@@ -661,14 +687,20 @@ def _run_server_action(spec_name: str, action: str) -> None:
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]
+ # Only backends installed on this machine: a merely-running external
+ # server cannot be stopped from here (stop() refuses without our pid
+ # file), so listing it would dead-end.
+ candidates = [st for st in statuses if st.installed]
if not candidates:
- tui.flash(stdscr, "No backend with a server is available. "
- "Set one up first.")
+ tui.flash(stdscr, "No backend is installed yet — use 'Set up a "
+ "backend' 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)
+ back_value=_GO_BACK,
+ table_title="Backend status",
+ table_rows=_status_rows(statuses),
+ notice_lines=_notice_lines())
if key is _GO_BACK or key is None:
return None
status = next((s for s in statuses if s.key == key), None)
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 7b6c740..bf17f7d 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -78,9 +78,10 @@ def flash(scr, text: str, kind: str = "warn") -> None:
"""Show a one-line notice until any key is pressed, then return.
Used by the hub for "not set up yet"-style messages. KIND is a theme
- key (warn/err/ok/info). Esc dismisses the notice (it does not abort).
+ key (warn/err/ok/info). The notice itself is the dialog's only
+ content (no "Notice" heading); Esc dismisses it (it does not abort).
"""
- frame = Frame(scr, "Notice", "Press any key to continue Esc = back")
+ frame = Frame(scr, "", "Press any key to continue Esc = back")
frame.mark(text, frame.theme.get(kind, frame.theme["body"]))
frame.cursor = None
frame.draw()
@@ -378,9 +379,10 @@ class Frame:
inner_x = x0 + 1
inner_w = dialog_w - 2
- title = _fit(f" {self.title} ", inner_w)
- _addstr(scr, y0 + 1, inner_x + max(0, (inner_w - len(title)) // 2),
- title, theme["title"])
+ if self.title:
+ title = _fit(f" {self.title} ", inner_w)
+ _addstr(scr, y0 + 1, inner_x + max(0, (inner_w - len(title)) // 2),
+ title, theme["title"])
if total_lines > visible:
indicator = f" {self.scroll + 1}/{total_lines} "
_addstr(scr, y0, max(x0 + 1, x0 + dialog_w - 1 - len(indicator)),