aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py31
-rw-r--r--app/ui/runview.py124
2 files changed, 126 insertions, 29 deletions
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()