diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/__init__.py | 9 | ||||
| -rwxr-xr-x | app/backends/audiocpp.py | 11 | ||||
| -rwxr-xr-x | app/backends/faster.py | 9 | ||||
| -rw-r--r-- | app/backends/qwen.py | 22 | ||||
| -rw-r--r-- | app/backends/servers.py | 16 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 40 | ||||
| -rw-r--r-- | app/tests/test_backends_servers.py | 51 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 227 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 11 | ||||
| -rw-r--r-- | app/ui/hub.py | 92 | ||||
| -rw-r--r-- | app/ui/tui.py | 12 |
11 files changed, 416 insertions, 84 deletions
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)), |
