aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/backends/servers.py26
-rw-r--r--app/converter/converter.py25
-rw-r--r--app/tests/test_backends_servers.py60
-rw-r--r--app/tests/test_converter_progress.py27
-rw-r--r--app/tests/test_hub.py92
-rw-r--r--app/tests/test_runview.py81
-rw-r--r--app/ui/hub.py31
-rw-r--r--app/ui/runview.py124
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()