diff options
| author | historia <historiavg@proton.me> | 2026-08-25 20:10:40 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-25 20:10:40 -0400 |
| commit | ca3c78ef577f5d8435dd10db0296c3e2fd8e69e2 (patch) | |
| tree | cff4b84c14d0ac88bc53f2aef63115352d57de90 | |
| parent | 757588321d4c27889be6e8e3c12b75873ad1218d (diff) | |
| download | tts-audiobook-generator-ca3c78ef577f5d8435dd10db0296c3e2fd8e69e2.tar.gz | |
feat: stop server and exit after generating
| -rw-r--r-- | app/backends/servers.py | 26 | ||||
| -rw-r--r-- | app/converter/converter.py | 25 | ||||
| -rw-r--r-- | app/tests/test_backends_servers.py | 60 | ||||
| -rw-r--r-- | app/tests/test_converter_progress.py | 27 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 92 | ||||
| -rw-r--r-- | app/tests/test_runview.py | 81 | ||||
| -rw-r--r-- | app/ui/hub.py | 31 | ||||
| -rw-r--r-- | app/ui/runview.py | 124 |
8 files changed, 425 insertions, 41 deletions
diff --git a/app/backends/servers.py b/app/backends/servers.py index 62eadb5..965d7df 100644 --- a/app/backends/servers.py +++ b/app/backends/servers.py @@ -135,6 +135,28 @@ def _pid_alive(pid: int) -> bool: return True +def _reap_exited(pid: int) -> bool: + """Reap PID when it is our exited child; True when confirmed dead. + + An exited child stays visible to signal-0 probes until its parent waits + for it, and the hub never reaps between ``start`` and ``stop`` — so a + server that honored SIGTERM would still count as alive and every stop + would burn the whole grace period before escalating to SIGKILL. Returns + False when the process may still be running or was not our child (the + caller then falls back to its own liveness probes). + """ + if not hasattr(os, "waitpid") or not hasattr(os, "WNOHANG"): + return False + try: + waited, _status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + # Not our child (or someone reaped it already): not ours to judge. + return False + except OSError: + return False + return waited == pid + + def _kill_pid(pid: int) -> bool: """Terminate PID (and its process group on POSIX). Returns True when dead.""" if sys.platform == "win32": @@ -163,6 +185,10 @@ def _kill_pid(pid: int) -> bool: except PermissionError: return False for _ in range(int(STOP_GRACE_SECONDS * 10)): + # Reap first so an exited (zombie) child ends the wait immediately + # instead of keeping the killpg(0) probe "alive" until SIGKILL. + if _reap_exited(pid): + return True try: os.killpg(pgid, 0) except ProcessLookupError: diff --git a/app/converter/converter.py b/app/converter/converter.py index 80411d3..455c1fb 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -206,6 +206,9 @@ class AudiobookConverter: self.backend = backend self.voice = voice self.debug = bool(debug) + # Output file names the book being converted will produce (filled in + # by convert_book; reported on the book_done/book_failed events). + self.current_outputs: List[str] = [] # Voice design / style instruction and free-form request options # (audio.cpp only): forwarded to AudioCppTTSClient, which validates # them against the server-hosted model at connect time. @@ -383,6 +386,9 @@ class AudiobookConverter: start_time = time.time() try: + # Reset per book (an instance may have been built without + # __init__, e.g. in tests): no outputs until the plan is known. + self.current_outputs = [] # Start from a clean scratch folder so a previous crash can never # affect this run audio.cleanup_chunks() @@ -396,6 +402,19 @@ class AudiobookConverter: stem = output_name or f"{file_path.stem}_{self._narrator_tag()}" + # The output files this book will produce (single final file, + # or one per chapter). Reported on the book_done/book_failed + # events so the run view can list them in its summary. + if self.output_format == "m4b" or self.single_file \ + or len(sections) == 1: + self.current_outputs = [f"{stem}.{self.output_format}"] + else: + self.current_outputs = [ + f"{stem}_{index:02d}_" + f"{self._sanitize_filename(section.title)}." + f"{self.output_format}" + for index, section in enumerate(sections, 1)] + # --debug: chunk text/audio dumps land in a per-book folder debug_dir = DEBUG_FOLDER / stem if self.debug else None @@ -814,7 +833,8 @@ class AudiobookConverter: success = self.convert_book(book_file, output_name=output_name) results[book_file.name] = success self._emit({"kind": "book_done", "name": book_file.name, - "ok": bool(success)}) + "ok": bool(success), + "files": list(getattr(self, "current_outputs", []))}) except ConversionCancelled: self._emit({"kind": "cancelled"}) logger.info("Conversion cancelled by user at %s", book_file.name) @@ -828,7 +848,8 @@ class AudiobookConverter: logger.error("Unexpected error: %s", exc) results[book_file.name] = False self._emit({"kind": "book_failed", "name": book_file.name, - "error": str(exc)}) + "error": str(exc), + "files": list(getattr(self, "current_outputs", []))}) if not results.get(book_file.name): logger.error("Conversion of %s failed; aborting the remaining books", book_file.name) diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py index 201d5b6..8eaf53f 100644 --- a/app/tests/test_backends_servers.py +++ b/app/tests/test_backends_servers.py @@ -1,5 +1,7 @@ """Tests for the server lifecycle module (backends/servers.py).""" +import os +import signal import tempfile import unittest from pathlib import Path @@ -225,6 +227,64 @@ class StopTests(unittest.TestCase): self.assertFalse((self.dir / "test-server.pid").exists()) +class ReapTests(unittest.TestCase): + """_reap_exited: an exited child must stop counting as alive.""" + + def test_true_when_child_exited(self): + with patch("os.waitpid", return_value=(4242, 0)) as mk: + self.assertTrue(servers._reap_exited(4242)) + mk.assert_called_once_with(4242, os.WNOHANG) + + def test_false_while_still_running(self): + # (0, 0) is WNOHANG's "still running" answer. + with patch("os.waitpid", return_value=(0, 0)): + self.assertFalse(servers._reap_exited(4242)) + + def test_false_when_not_our_child(self): + with patch("os.waitpid", side_effect=ChildProcessError): + self.assertFalse(servers._reap_exited(4242)) + + +class KillPidTests(unittest.TestCase): + """_kill_pid: the reap check ends the grace wait before SIGKILL.""" + + def test_reaped_child_ends_wait_without_sigkill(self): + with patch("os.getpgid", return_value=4242), \ + patch("os.killpg") as mk_killpg, \ + patch("os.waitpid", return_value=(4242, 0)) as mk_waitpid, \ + patch("time.sleep") as mk_sleep: + ok = servers._kill_pid(4242) + self.assertTrue(ok) + mk_killpg.assert_called_once_with(4242, signal.SIGTERM) + mk_waitpid.assert_called_once_with(4242, os.WNOHANG) + mk_sleep.assert_not_called() + + def test_escalates_to_sigkill_when_child_stays_alive(self): + with patch("os.getpgid", return_value=4242), \ + patch("os.killpg") as mk_killpg, \ + patch("os.waitpid", return_value=(0, 0)), \ + patch("time.sleep"): + ok = servers._kill_pid(4242) + self.assertTrue(ok) + calls = mk_killpg.call_args_list + self.assertEqual(calls[0].args, (4242, signal.SIGTERM)) + self.assertEqual(calls[-1].args, (4242, signal.SIGKILL)) + + def test_foreign_child_falls_back_to_group_probe(self): + # ChildProcessError from waitpid (not our child / already reaped): + # the killpg(0) probe decides; a vanished group ends the wait. + with patch("os.getpgid", return_value=4242), \ + patch("os.killpg", + side_effect=[None, ProcessLookupError]) as mk_killpg, \ + patch("os.waitpid", side_effect=ChildProcessError), \ + patch("time.sleep"): + ok = servers._kill_pid(4242) + self.assertTrue(ok) + calls = mk_killpg.call_args_list + self.assertEqual(calls[0].args, (4242, signal.SIGTERM)) + self.assertEqual(calls[-1].args, (4242, 0)) + + class ManagesTests(unittest.TestCase): """manages(): a live recorded pid marks a server as ours.""" diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py index 1041173..0f708a1 100644 --- a/app/tests/test_converter_progress.py +++ b/app/tests/test_converter_progress.py @@ -142,6 +142,33 @@ class ProgressEventTests(unittest.TestCase): self.assertEqual(events[-1]["kind"], "done") self.assertEqual(events[-1]["ok"], 0) + def test_book_done_reports_output_files(self): + # book_done carries the output file names the run view's summary + # lists after the TUI closes. + events = [] + converter = self.fixture.build(progress=events.append) + converter.run() + done = next(e for e in events if e["kind"] == "book_done") + self.assertEqual(done["files"], ["book_Vivian.mp3"]) + self.assertEqual(converter.current_outputs, ["book_Vivian.mp3"]) + + def test_multi_chapter_book_lists_every_chapter_file(self): + # A multi-section book (no --single-file) produces one file per + # chapter, all reported on the event. + sections = [MagicMock(text=f"chapter {n} text.", title=t) + for n, t in enumerate(("One", "Two"), 1)] + book = MagicMock(title="Book", author="Author", sections=sections) + events = [] + with patch.object(converter_mod.extractors, "extract_book", + return_value=book): + converter = self.fixture.build(progress=events.append) + converter.single_file = False + converter.run() + done = next(e for e in events if e["kind"] == "book_done") + self.assertTrue(done["ok"]) + self.assertEqual(done["files"], + ["book_Vivian_01_One.mp3", "book_Vivian_02_Two.mp3"]) + class CancelTests(unittest.TestCase): def setUp(self): diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 3547ba8..ebae82b 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -651,7 +651,8 @@ class ConvertFlowTests(unittest.TestCase): def _form_values(self, **overrides): """A fully-populated form result, with sensible defaults.""" values = {"output_format": "m4b", "speed": "1.0", - "single_file": False, "debug": False} + "single_file": False, "debug": False, + "stop_and_exit": True} values.update(overrides) return values @@ -666,23 +667,26 @@ class ConvertFlowTests(unittest.TestCase): """A backend status that is ready to convert with.""" return BackendStatus(key, label, installed=True, configured=True) - def _convert(self, stdscr, statuses): + def _convert(self, stdscr, statuses, run_result=None): """Run the convert flow with STATUSES, returning the command tuple. ``_run_conversion`` is stubbed so the accepted command is captured - instead of launching the run view; None is returned when the flow - aborts before reaching a conversion (nothing ready, a flash). + instead of launching the run view (and reporting RUN_RESULT — True + when the user answered "stop the server and exit"); None is returned + when the flow aborts before reaching a conversion (nothing ready, a + flash). The screen's navigation result lands on ``self.nav``. """ captured = {} def fake_run_conversion(self_, backend, kwargs): captured["backend"] = backend captured["kwargs"] = kwargs + return run_result with patch.object(hub, "detect_all", return_value=statuses), \ patch.object(hub._Hub, "_run_conversion", fake_run_conversion): - hub._Hub(None).screen_convert() + self.nav = hub._Hub(None).screen_convert() if "backend" not in captured: return None return ("convert", captured["backend"], captured["kwargs"]) @@ -742,9 +746,14 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual([f["key"] for f in fields], ["backend", "model_id", "audiocpp_voice", "instructions", "output_format", "speed", - "single_file", "debug"]) + "single_file", "debug", "stop_and_exit"]) self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel")) self.assertTrue(form_kwargs["start_on_buttons"]) + # The stop-and-exit toggle ships on by default. + stop_field = self._field("stop_and_exit") + self.assertEqual(stop_field["kind"], "bool") + self.assertTrue(stop_field["value"]) + self.assertTrue(cmd[2]["stop_and_exit"]) # The backend field offers the remote entry under a [remote] label. self.assertEqual(fields[0]["choices"], [("audio.cpp [remote]", "audiocpp-remote")]) @@ -1011,7 +1020,8 @@ class ConvertFlowTests(unittest.TestCase): fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], ["backend", "mode", "speaker", "clone", - "output_format", "speed", "single_file", "debug"]) + "output_format", "speed", "single_file", "debug", + "stop_and_exit"]) mode_field = self._field("mode") self.assertEqual(mode_field["choices"], [("Built-in speaker", "custom"), @@ -1080,6 +1090,24 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:8000") self.assertEqual(self._field("faster_voice")["kind"], "text") + def test_stop_and_exit_answer_quits_the_tui(self): + # Yes on the run view's stop-and-exit prompt ends the whole TUI: + # screen_convert returns None instead of Wizard.BACK. + st = self._remote("faster", "faster-qwen3-tts", + url="http://10.0.0.5:8000") + self._answer_form(backend="faster-remote", faster_voice="obama") + cmd = self._convert(None, [st], run_result=True) + self.assertIsNotNone(cmd) + self.assertIsNone(self.nav) + + def test_normal_finish_returns_to_the_menu(self): + st = self._remote("faster", "faster-qwen3-tts", + url="http://10.0.0.5:8000") + self._answer_form(backend="faster-remote", faster_voice="obama") + cmd = self._convert(None, [st]) + self.assertIsNotNone(cmd) + self.assertIs(self.nav, hub.tui.Wizard.BACK) + def test_qwen_remote_limited_modes_and_api_url(self): # A remote qwen with only the Base (clone) demo answering: the form # offers only clone mode and targets the clone remote URL. @@ -1130,7 +1158,7 @@ class ConvertFlowTests(unittest.TestCase): [f["key"] for f in fields], ["backend", "model_id", "audiocpp_voice", "instructions", "mode", "speaker", "clone", "output_format", "speed", - "single_file", "debug"]) + "single_file", "debug", "stop_and_exit"]) # The form opens on the configured default (audio.cpp): its fields # show, the other backend's hide. (Instructions is hidden too: the # default higgs entry is clone-only, which ignores instructions.) @@ -1216,6 +1244,24 @@ class PrepareRunConfigTests(unittest.TestCase): self.assertEqual(cfg.server_name, "qwen-custom") self.assertNotIn("autostart", kwargs) + def test_stop_and_exit_travels_on_the_config_not_the_kwargs(self): + # The run-view toggle is not a converter kwarg: it moves onto the + # config (and defaults to off when the form did not send it). + with tempfile.TemporaryDirectory() as tmp: + with patch.object(hub, "LOGS_FOLDER", Path(tmp)), \ + patch.object(hub, "detect_all", return_value=[]): + cfg = hub._prepare_run_config( + "audiocpp", {"stop_and_exit": True}) + self.assertTrue(cfg.stop_and_exit) + cfg = hub._prepare_run_config("audiocpp", {}) + self.assertFalse(cfg.stop_and_exit) + + def test_remote_config_carries_stop_and_exit(self): + cfg = hub._prepare_run_config( + "audiocpp", {"api_url": "http://10.0.0.5:8080", + "stop_and_exit": True}) + self.assertTrue(cfg.stop_and_exit) + def test_autostart_with_missing_spec_continues_with_notice(self): kwargs = {"autostart": "gone"} with patch.object(hub, "detect_all", return_value=[]), \ @@ -1336,11 +1382,14 @@ class DispatchConversionTests(unittest.TestCase): timeouts.append(ms) class FakeView: + run_result = None + def __init__(self, scr, config): self.config = config self.scr = scr + def run(self): - pass + return type(self).run_result screen = Screen() with patch.object(hub, "_prepare_run_config", @@ -1348,11 +1397,34 @@ class DispatchConversionTests(unittest.TestCase): backend="qwen", backend_label="qwen-tts", kwargs={}, book_files=[], planned=[])) as mk_cfg, \ patch.object(hub.runview, "RunView", FakeView): - hub._Hub(screen)._run_conversion("qwen", {}) + result = hub._Hub(screen)._run_conversion("qwen", {}) mk_cfg.assert_called_once() # The run view leaves a timed getch behind; it is reset so the hub # menus block for keys again. self.assertEqual(timeouts, [-1]) + # A normal finish lands back on the menu (no quit signal). + self.assertFalse(result) + + def test_stop_and_exit_answer_propagates_from_the_view(self): + class Screen: + def timeout(self, ms): + pass + + class FakeView: + def __init__(self, scr, config): + self.config = config + self.scr = scr + + def run(self): + return True + + with patch.object(hub, "_prepare_run_config", + return_value=hub.runview.RunConfig( + backend="qwen", backend_label="qwen-tts", + kwargs={}, book_files=[], planned=[])), \ + patch.object(hub.runview, "RunView", FakeView): + result = hub._Hub(Screen())._run_conversion("qwen", {}) + self.assertTrue(result) def test_no_run_config_skips_the_view(self): with patch.object(hub, "_prepare_run_config", return_value=None) as mk_cfg, \ diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index 556cb52..31a8ebe 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -171,6 +171,10 @@ class RenderTests(_FakeTui, unittest.TestCase): text = self._strings(screen) self.assertIn("stopping", text) self.assertIn("(1s)", text) + # The stop wait ignores keys, so the footer must not promise that + # pressing one returns to the menu. + self.assertNotIn("press any key", text) + self.assertIn("stopping the audiocpp server...", text) def test_error_screen_shows_detail_and_log(self): view, screen = self.make_view(log_path="/tmp/audiobook.log") @@ -236,6 +240,83 @@ class RunLoopTests(_FakeTui, unittest.TestCase): view.run() mk_stop.assert_not_called() + def test_finished_run_toggle_off_waits_for_key_then_menu(self): + # Toggle off: a key on the summary screen returns to the menu; no + # prompt is asked and the server keeps running. + with patch.object(runview.tui, "confirm") as mk_confirm, \ + patch.object(runview.servers, "alive", return_value=True), \ + patch.object(runview.servers, "stop") as mk_stop: + view, screen = self.make_view(keys=[ord("x")], + autostart_spec="SPEC") + view.started_server = True + view._queue.put({"kind": "done", "ok": 1, "total": 1}) + result = view.run() + self.assertFalse(result) + mk_confirm.assert_not_called() + mk_stop.assert_not_called() + + def test_stop_and_exit_stops_server_and_quits_without_keys(self): + # Toggle on: the moment the run ends the server is stopped and the + # view reports quit — no key press and no prompt anywhere. + with patch.object(runview.servers, "alive", return_value=True), \ + patch.object(runview.servers, "stop") as mk_stop, \ + patch.object(runview.common, + "record_post_tui_notice") as mk_notice: + view, screen = self.make_view([], autostart_spec="SPEC", + stop_and_exit=True) + view.started_server = True + view._queue.put({"kind": "book", "index": 1, "total": 1, + "name": "book.txt"}) + view._queue.put({"kind": "book_done", "name": "book.txt", + "ok": True, + "files": ["book_test_michael.mp3"]}) + view._queue.put({"kind": "done", "ok": 1, "total": 1}) + result = view.run() + self.assertTrue(result) + mk_stop.assert_called_once_with("audiocpp") + text = mk_notice.call_args[0][0] + self.assertIn("Output directory:", text) + self.assertIn("[OK] book.txt: book_test_michael.mp3", text) + self.assertIn("1 of 1 book(s) generated successfully", text) + self.assertIn("Elapsed time:", text) + + def test_stop_and_exit_leaves_external_servers_alone(self): + # A server this run did not start is never stopped; the TUI still + # quits and the summary is still printed. + with patch.object(runview.servers, "stop") as mk_stop, \ + patch.object(runview.common, + "record_post_tui_notice") as mk_notice: + view, screen = self.make_view([], server_name=None, + stop_and_exit=True) + view.started_server = False + view._queue.put({"kind": "done", "ok": 1, "total": 1}) + result = view.run() + self.assertTrue(result) + mk_stop.assert_not_called() + self.assertIn("No books were converted", + mk_notice.call_args[0][0]) + + def test_stop_and_exit_failure_summary_lists_the_error(self): + # A failed ending also auto-exits; the failing book's detail line + # lands in the post-TUI summary. + with patch.object(runview.servers, "alive", return_value=True), \ + patch.object(runview.servers, "stop"), \ + patch.object(runview.common, + "record_post_tui_notice") as mk_notice: + view, screen = self.make_view([], autostart_spec="SPEC", + stop_and_exit=True) + view.started_server = True + view._queue.put({"kind": "book_failed", "name": "bad.txt", + "error": "chunk 3 failed", + "files": ["bad_x.mp3"]}) + view._queue.put({"kind": "done", "ok": 0, "total": 1}) + result = view.run() + self.assertTrue(result) + text = mk_notice.call_args[0][0] + self.assertIn("[FAIL] bad.txt: bad_x.mp3", text) + self.assertIn("chunk 3 failed", text) + self.assertIn("0 of 1 book(s) generated successfully", text) + class WorkerTests(_FakeTui, unittest.TestCase): """The worker thread's handoff into audiobook.convert.""" diff --git a/app/ui/hub.py b/app/ui/hub.py index 9282fa7..a8c167e 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -365,22 +365,28 @@ class _Hub: continue if not ok: return tui.Wizard.BACK - self._run_conversion(cmd[1], cmd[2]) + if self._run_conversion(cmd[1], cmd[2]): + # "Stop server and exit after generating" was on: returning + # None ends the wizard stack (the whole TUI). + return None return tui.Wizard.BACK - def _run_conversion(self, backend: str, kwargs: dict) -> None: + def _run_conversion(self, backend: str, kwargs: dict) -> bool: """Run a conversion in the full-screen run view on this session. - A crash inside the view cancels the worker and flashes an error - instead of taking the whole hub down; the timed getch the run view - leaves behind is reset so the hub menus still block for keys. + Returns True when the run view's stop-and-exit toggle was on — the + caller then quits the whole TUI (results are printed after curses + closes) instead of landing back on the menu. A crash inside the view + cancels the worker and flashes an error instead of taking the whole + hub down; the timed getch the run view leaves behind is reset so the + hub menus still block for keys. """ run_config = _prepare_run_config(backend, kwargs) if run_config is None: - return + return False view = runview.RunView(self.stdscr, run_config) try: - view.run() + return bool(view.run()) except tui.WizardCancelled: pass except KeyboardInterrupt: @@ -394,6 +400,7 @@ class _Hub: self.stdscr.timeout(-1) except Exception: pass + return False # -- settings ------------------------------------------------------- @@ -843,6 +850,8 @@ def _common_fields() -> list: "kind": "bool", "value": False, "visible": lambda fs: _field_value(fs, "output_format") != "m4b"}, {"key": "debug", "label": "Debug", "kind": "bool", "value": False}, + {"key": "stop_and_exit", "label": "Stop server and exit after " + "generating", "kind": "bool", "value": True}, ] @@ -855,6 +864,7 @@ def _common_kwargs(values: dict) -> dict: "single_file": bool(values["single_file"]) and output_format != "m4b", "debug": bool(values["debug"]), + "stop_and_exit": bool(values["stop_and_exit"]), } @@ -1411,6 +1421,9 @@ def _prepare_run_config(backend: str, kwargs: dict LOGS_FOLDER.mkdir(parents=True, exist_ok=True) Path(log_path).touch() autostart = kwargs.pop("autostart", None) + # The run-view behavior toggle (not a converter kwarg): stop the server + # and quit the TUI once the generation ends. + stop_and_exit = bool(kwargs.pop("stop_and_exit", False)) # book_files/planned travel on the dedicated RunConfig fields; keeping # them in kwargs too would collide with convert()'s named parameters. book_files = kwargs.pop("book_files", None) or [] @@ -1424,7 +1437,7 @@ def _prepare_run_config(backend: str, kwargs: dict kwargs=kwargs, book_files=book_files, planned=planned, server_url=api_url, server_identity=identity, - log_path=log_path) + log_path=log_path, stop_and_exit=stop_and_exit) status = next((s for s in detect_all() if s.key == backend), None) notice = "" @@ -1448,7 +1461,7 @@ def _prepare_run_config(backend: str, kwargs: dict server_url=spec.url if spec is not None else None, server_identity=spec.identity if spec is not None else None, autostart_spec=spec if autostart else None, - log_path=log_path, notice=notice) + log_path=log_path, notice=notice, stop_and_exit=stop_and_exit) def _remote_identity(backend: str, kwargs: dict) -> Optional[str]: diff --git a/app/ui/runview.py b/app/ui/runview.py index 7ff7ab1..eb385f0 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -18,10 +18,14 @@ The screen is fed by two threads the widget spawns: Esc and 'q' do the same thing everywhere: a confirmation to cancel processing, then (when this run started the server) a confirmation to shut -it down, then back to the hub menu. Errors (the server exits while -booting, the server stops mid-conversion, a chunk fails and the book -aborts) put the corresponding state into error and wait for a key press -before returning to the menu, so the failure is never scrolled away. +it down. On the finished screen the behavior follows the convert form's +"Stop server and exit after generating" toggle: ON stops the server +automatically, quits the whole TUI, and prints the results summary to the +real terminal after curses closes; OFF waits for a key press and returns +to the hub menu with the server still running. Errors (the server exits +while booting, the server stops mid-conversion, a chunk fails and the book +aborts) put the corresponding state into error and wait for a key press, +so the failure is never scrolled away. """ import contextlib @@ -72,7 +76,10 @@ class RunConfig: server is not already running). LOG_PATH names the converter's log file for the error screen's "details" hint. NOTICE is an optional warning line shown under the progress panel (e.g. a foreign server - holding the managed port). + holding the managed port). STOP_AND_EXIT ("Stop server and exit after + generating") skips the finished screen entirely: the server is stopped + automatically, the TUI quits, and the results are printed to the real + terminal after curses closes. """ backend: str backend_label: str @@ -85,6 +92,7 @@ class RunConfig: autostart_spec: object = None log_path: str = "" notice: str = "" + stop_and_exit: bool = False class RunView: @@ -190,9 +198,13 @@ class RunView: self.server = "ready" elif kind == "book_done": self.book_results.append((event.get("name") or "?", - bool(event.get("ok")))) + bool(event.get("ok")), + list(event.get("files") or []), + "")) elif kind == "book_failed": - self.book_results.append((event.get("name") or "?", False)) + self.book_results.append((event.get("name") or "?", False, + list(event.get("files") or []), + event.get("error") or "conversion failed")) self.error_message = self.error_message or \ (event.get("error") or "conversion failed") elif kind == "done": @@ -292,8 +304,15 @@ class RunView: # Main loop # ------------------------------------------------------------------ - def run(self) -> None: - """Run the view until the user leaves the terminal screen.""" + def run(self) -> bool: + """Run the view until the user leaves the terminal screen. + + Returns True only when the run should end with the whole TUI + quitting — the "Stop server and exit after generating" path, which + stops the server automatically and records the results as a post-TUI + notice. Every other exit (a key press on the summary screen, the Esc + cancel flow) lands back on the hub menu. + """ scr = self.scr try: self.scr.timeout(_DRAW_TIMEOUT_MS) @@ -305,13 +324,18 @@ class RunView: try: while True: self._drain() + # The stop-and-exit setting never waits for a key: leave as + # soon as the run ends (an explicit Esc cancel keeps its own + # interactive flow instead). + if self.config.stop_and_exit and self.phase in _TERMINAL \ + and self.phase != "cancelled": + return self._auto_stop_and_exit() self.render() key = self._get_key() if key is None: continue if self.phase in _TERMINAL: - self._confirm_stop_server() - return + return False if key in (27, ord("q"), 3) and not self.cancelling: if self._prompt_cancel(): return @@ -373,12 +397,12 @@ class RunView: return True def _confirm_stop_server(self) -> None: - """Ask whether to stop the server this run started (once). + """Ask whether to stop the server this run started (Esc-cancel path). - On "yes" the stop runs on a background thread while the screen keeps - redrawing the server panel — showing "stopping" with an elapsed clock, - mirroring the boot screen — so the SIGTERM grace period never freezes - the TUI. Returns once the server is gone. + The stop runs on a background thread while the screen keeps + redrawing the server panel — showing "stopping" with an elapsed + clock, mirroring the boot screen — so the SIGTERM grace period never + freezes the TUI. Returns once the server is gone. """ if not self.started_server or self._server_stopped_confirmed: return @@ -395,6 +419,16 @@ class RunView: self._nonblocking() if not answer: return + self._stop_server_now() + + def _stop_server_now(self) -> None: + """Stop the managed server while the screen keeps repainting. + + Shared by the Esc-cancel flow and the stop-and-exit path: the stop + runs on a background thread and the view drains/render at redraw + cadence until it reports done. + """ + name = self.config.server_name self.server = "stopping" self.stop_started = self._now() done = threading.Event() @@ -414,6 +448,50 @@ class RunView: self._drain() self.render() + def _auto_stop_and_exit(self) -> bool: + """The "Stop server and exit after generating" path. + + No prompts and no key waits: stop the managed server this run + started (if any), record the results summary as a post-TUI notice + (printed to the real terminal once curses closes), and report + "quit" to the hub. An unmanaged/external server is left alone. + """ + if self.started_server: + name = self.config.server_name + if name and servers.alive(name): + self._stop_server_now() + common.record_post_tui_notice(self._summary_text()) + return True + + def _summary_text(self) -> str: + """The results summary printed after the TUI exits. + + Output directory, one line per book with its generated file names + and OK/FAIL status (plus the failure detail), a success count, and + the total elapsed time. + """ + from converter.converter import AUDIOBOOKS_FOLDER + lines = ["Audiobook generation finished", + f"Output directory: {AUDIOBOOKS_FOLDER}"] + ok_count = 0 + for name, ok, files, error in self.book_results: + ok_count += 1 if ok else 0 + lines.append(f"{'[OK]' if ok else '[FAIL]'} {name}" + + (f": {', '.join(files)}" if files else "")) + if not ok and error: + lines.append(f" {error}") + total = len(self.book_results) + if total: + lines.append(f"{ok_count} of {total} book(s) generated " + f"successfully") + else: + lines.append("No books were converted") + started = self.convert_started or self.boot_started + finished = self.finished_at or self._now() + elapsed = finished - (started if started is not None else finished) + lines.append(f"Elapsed time: {_format_elapsed(elapsed)}") + return "\n".join(lines) + def _blocking(self) -> None: """Make getch block (used while a confirm dialog owns the screen).""" try: @@ -489,6 +567,9 @@ class RunView: # -- footer ------------------------------------------------------ if self.cancelling and self.phase not in _TERMINAL: footer = "cancelling..." + elif self.server == "stopping": + name = self.config.server_name or "server" + footer = f"stopping the {name} server..." elif self.phase in _TERMINAL: footer = "press any key to return to the menu" else: @@ -577,7 +658,7 @@ class RunView: _text(scr, theme, y, value_x, _fit(result, value_w), theme.get(kind, theme["body"])) y += 1 - for name, ok in self.book_results[:5]: + for name, ok, _files, _error in self.book_results[:5]: mark = "[OK] " if ok else "[FAIL]" _text(scr, theme, y, value_x, _fit(f"{mark} {name}", value_w), @@ -688,7 +769,10 @@ def _format_elapsed(seconds: float) -> str: return f"{minutes}:{secs:02d}" -def run(scr, config: RunConfig) -> None: - """Enter the run view (called inside curses.wrapper by the hub).""" +def run(scr, config: RunConfig) -> bool: + """Enter the run view (called inside curses.wrapper by the hub). + + Returns True when the stop-and-exit toggle fired — see ``RunView.run``. + """ view = RunView(scr, config) - view.run() + return view.run() |
