From ca3c78ef577f5d8435dd10db0296c3e2fd8e69e2 Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 25 Aug 2026 20:10:40 -0400 Subject: feat: stop server and exit after generating --- app/ui/hub.py | 31 ++++++++++---- app/ui/runview.py | 124 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 126 insertions(+), 29 deletions(-) (limited to 'app/ui') 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() -- cgit v1.2.3