aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py240
-rw-r--r--app/ui/runview.py643
2 files changed, 821 insertions, 62 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 77d0796..95ac06a 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -12,10 +12,13 @@ Esc on the main menu quits the hub ('q' mirrors Esc on every screen).
Esc inside a sub-menu falls back to the main menu.
"""
+import contextlib
+import io
import json
import re
import shutil
import urllib.parse
+from datetime import datetime
from pathlib import Path
from typing import Callable, Optional, Tuple
@@ -31,9 +34,15 @@ from backends import (
)
from backends import audiocpp as audiocpp_backend
from backends import faster as faster_backend
+from backends import probe as backend_probe
from backends import qwen as qwen_backend
from converter import config
-from converter.converter import AUDIO_FORMATS
+from converter.converter import (
+ AUDIO_FORMATS,
+ AudiobookConverter,
+ LOGS_FOLDER,
+ voice_mode_for,
+)
from converter.tts import (
AUDIOCPP_FAMILY_QWEN3_TTS,
BACKEND_AUDIOCPP,
@@ -41,7 +50,7 @@ from converter.tts import (
BACKEND_QWEN,
normalize_language,
)
-from ui import tui
+from ui import runview, tui
_GO_BACK = object()
@@ -70,7 +79,7 @@ def run() -> int:
if info is not None and command[2] < len(info.configure_actions):
info.configure_actions[command[2]].run()
elif kind == "convert":
- _run_conversion(command[1], command[2])
+ _dispatch_conversion(command[1], command[2])
elif kind == "server":
_run_server_action(command[1], command[2])
@@ -188,6 +197,8 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
text += " (" + ", ".join(status.running_models) + ")"
return (text, "ok", "body")
if status is not None and status.installed:
+ if status.models_missing and not status.running:
+ return ("installed (models missing)", "warn", "body")
return ("installed", "warn", "body")
return ("unavailable", "err", "dim")
@@ -287,9 +298,50 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]:
if cmd is None:
return None
_add_autostart(cmd, statuses)
+ if not _preflight(stdscr, cmd):
+ return None
return cmd
+def _preflight(stdscr, cmd: tuple) -> bool:
+ """Run the overwrite checks in the TUI; stash the plan on the command.
+
+ Asks every "output exists — overwrite?" question now (tui.confirm
+ instead of the console input()) so the run view itself is unattended,
+ and records the discovered books / accepted plan in the command's
+ kwargs (``book_files``/``planned``) for ``audiobook.convert``. Returns
+ False when nothing would be converted (a flash explains why), so the
+ user stays in the menu instead of entering an empty run.
+ """
+ _kind, backend, kwargs = cmd
+ voice_mode = voice_mode_for(backend, kwargs.get("voice"),
+ kwargs.get("clone"))
+
+ def confirm(message: str, default: bool) -> bool:
+ return tui.confirm(stdscr, message, default=default,
+ cancel_value=False)
+
+ with contextlib.redirect_stdout(io.StringIO()):
+ book_files, planned = AudiobookConverter.preflight_overwrites(
+ backend=backend, voice=kwargs.get("voice"),
+ voice_mode=voice_mode,
+ voice_clone_ref_audio=kwargs.get("clone"),
+ output_format=kwargs.get("output_format") or config.AUDIO_FORMAT,
+ instructions=kwargs.get("instructions"),
+ confirm=confirm)
+ if not book_files:
+ tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub "
+ "file to the input folder first.")
+ return False
+ if not planned:
+ tui.flash(stdscr, "Nothing to convert — every existing output was "
+ "kept.")
+ return False
+ kwargs["book_files"] = book_files
+ kwargs["planned"] = planned
+ return True
+
+
def _gate_backend(field: dict, key: str) -> Callable:
"""A visible() that shows FIELD only when the Backend field is KEY.
@@ -429,6 +481,24 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]:
# converter's default: unknown family means qwen3_tts.
entry["family"] = AUDIOCPP_FAMILY_QWEN3_TTS
+ if local:
+ # Only offer entries whose model files are actually on disk: a
+ # server.json can reference a package that was never downloaded,
+ # and picking it would fail the whole run at model-load time.
+ missing = audiocpp_backend.missing_model_entries(server_json)
+ if missing:
+ missing_ids = {item["id"] for item in missing}
+ models = [entry for entry in models
+ if entry.get("id") not in missing_ids]
+ if not models:
+ hints = audiocpp_backend.model_install_hints(checkout,
+ missing)
+ message = hints[0] if hints \
+ else "Download the models first."
+ tui.flash(stdscr, "No model files are downloaded for "
+ f"audio.cpp. {message}")
+ return None
+
local_voices = _list_voices(data.get("voice_dir")) \
if data.get("voice_dir") else []
voice_cache: dict = {} # model id -> voices (local: shared list)
@@ -871,76 +941,122 @@ def _write_config(updates: dict) -> None:
path.write_text(text, encoding="utf-8")
-def _run_conversion(backend: str, kwargs: dict) -> None:
- """Run a conversion in the plain console (after the TUI returns).
+def _dispatch_conversion(backend: str, kwargs: dict) -> None:
+ """Run a conversion in the full-screen run view (its own curses session).
- A remote conversion (``api_url`` in the kwargs) targets an externally-run
- server, so no autostart is attempted and the managed instance's setup
- state is irrelevant. Otherwise, when the convert menu recorded an
- ``autostart`` server (the user opted to have the hub start it), spawn it
- now and abort the conversion if it does not come up; a managed server
- whose port is already occupied by a server this tool did not start is
- left alone but warned about. After the conversion, offer to stop a
- server we started.
+ ``_prepare_run_config`` turns the accepted form (plus the autostart
+ decision the convert menu recorded) into everything the run view needs;
+ the view then boots the server when required, runs the conversion with
+ progress events, and asks the cancel/stop-server questions itself. A
+ crash inside the view cancels the worker and returns to the menu
+ instead of taking the whole hub down.
"""
- autostart = kwargs.pop("autostart", None)
- api_url = kwargs.get("api_url")
- if api_url:
- print(f"[INFO] Converting against remote server at {api_url}")
- else:
- status = next((s for s in detect_all() if s.key == backend), None)
- if status is not None and not status.ready and not status.running:
- print(f"[WARNING] {status.label} is not fully set up.")
- if autostart:
- spec = _find_spec(autostart)
- if spec is None:
- print(f"[WARNING] no server named '{autostart}'; continuing")
- elif not servers.start(spec):
- print("[ERROR] could not start the server; aborting conversion.")
- if status is not None and status.launch_hint:
- print("Start it manually and run the conversion again:")
- print(f" {status.launch_hint}")
- return
- elif status is not None and status.servers:
- # The managed server's port may be held by a server we did not
- # start (its pid file is absent); the conversion would silently
- # talk to that server, so call it out.
- spec = _select_spec(status, kwargs)
- if spec is not None and common.server_running(spec.url) \
- and not servers.alive(spec.name):
- print(f"[WARNING] A server this tool did not start is already "
- f"running at {spec.url}; the conversion will talk to it. "
- f"Stop it (or change the port) to use the managed "
- f"{status.label} instance.")
- elif status.launch_hint:
- print("[INFO] Make sure the server is running. Start it with:")
- print(f" {status.launch_hint}")
- try:
- audiobook.convert(backend=backend, **kwargs)
- finally:
- if autostart:
- _maybe_stop_server(autostart)
+ import curses
+ run_config = _prepare_run_config(backend, kwargs)
+ if run_config is None:
+ return
+ holder: dict = {}
+ def main(stdscr) -> None:
+ view = runview.RunView(stdscr, run_config)
+ holder["view"] = view
+ view.run()
-def _maybe_stop_server(name: str) -> None:
- """Ask (in the plain console) whether to stop a server we auto-started."""
try:
- ans = input(f"\n[?] Stop the '{name}' server now? [y/N] ").strip().lower()
- except EOFError:
- return
- if ans in ("y", "yes"):
- servers.stop(name)
+ curses.wrapper(main)
+ except tui.WizardCancelled:
+ pass
+ except KeyboardInterrupt:
+ pass
+ except Exception as exc: # noqa: BLE001 - keep the hub alive
+ view = holder.get("view")
+ if view is not None:
+ view._cancel.set()
+ view._worker.join(timeout=30)
+ print(f"[ERROR] The run view failed: {exc}")
+ finally:
+ try:
+ curses.curs_set(1) # restore the text cursor hidden by the TUI
+ except Exception:
+ pass
+
+
+def _prepare_run_config(backend: str, kwargs: dict
+ ) -> Optional[runview.RunConfig]:
+ """Build the run view's config from the accepted conversion kwargs.
+
+ A remote conversion (``api_url``) targets an externally-run server, so
+ no autostart is attempted and the managed instance's setup state is
+ irrelevant. Otherwise, when the convert menu recorded an ``autostart``
+ server (the server was not running), the run view boots it first; a
+ managed server whose port is already occupied by a server this tool
+ did not start is left alone but flagged with a notice. Returns None
+ when the backend disappeared between the menu and the dispatch.
+ """
+ label = backend
+ info = get(backend)
+ if info is not None:
+ label = info.label
+ log_path = str(LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log")
+ autostart = kwargs.pop("autostart", None)
+ api_url = kwargs.get("api_url")
+
+ if api_url:
+ identity = _remote_identity(backend, kwargs)
+ return runview.RunConfig(
+ backend=backend, backend_label=f"{label} [remote]",
+ kwargs=kwargs, book_files=kwargs.get("book_files") or [],
+ planned=kwargs.get("planned") or [],
+ server_url=api_url, server_identity=identity,
+ log_path=log_path)
+
+ status = next((s for s in detect_all() if s.key == backend), None)
+ notice = ""
+ spec: Optional[ServerSpec] = None
+ if autostart:
+ spec = _find_spec(autostart)
+ elif status is not None:
+ spec = _select_spec(status, kwargs)
+ if spec is not None and common.server_running(spec.url) \
+ and not servers.alive(spec.name):
+ notice = (f"a server this tool did not start is running at "
+ f"{spec.url} — the conversion will talk to it")
+ if autostart and spec is None:
+ # The recorded server vanished (backend reconfigured meanwhile):
+ # converting without it is still meaningful, so continue.
+ notice = (f"no server named '{autostart}' — starting it was skipped")
+ return runview.RunConfig(
+ backend=backend, backend_label=label, kwargs=kwargs,
+ book_files=kwargs.get("book_files") or [],
+ planned=kwargs.get("planned") or [],
+ server_name=spec.name if spec is not None else None,
+ 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)
+
+
+def _remote_identity(backend: str, kwargs: dict) -> Optional[str]:
+ """The probe identity of the remote server a conversion targets."""
+ if backend == BACKEND_AUDIOCPP:
+ return backend_probe.IDENTITY_AUDIOCPP
+ if backend == BACKEND_QWEN:
+ return backend_probe.IDENTITY_QWEN_CLONE if kwargs.get("clone") \
+ else backend_probe.IDENTITY_QWEN_CUSTOM
+ if backend == BACKEND_FASTER:
+ return backend_probe.IDENTITY_FASTER
+ return None
def _add_autostart(cmd: tuple, statuses) -> None:
"""Auto-start the conversion's target server when it isn't running.
Records the chosen server spec name as ``kwargs['autostart']`` for
- ``_run_conversion`` to act on. The user already accepted the run on the
- Generate! screen, so no start-server prompt is asked here — the server
- is simply started. Mode-aware for qwen (custom vs clone). Remote
- conversions (a ``api_url`` in the kwargs) never autostart: the server
- is external to this tool.
+ ``_prepare_run_config`` to act on. The user already accepted the run on
+ the Generate! screen, so no start-server prompt is asked here — the
+ server is simply started. Mode-aware for qwen (custom vs clone).
+ Remote conversions (an ``api_url`` in the kwargs) never autostart: the
+ server is external to this tool.
"""
_, key, kwargs = cmd
if kwargs.get("api_url"):
diff --git a/app/ui/runview.py b/app/ui/runview.py
new file mode 100644
index 0000000..d65978d
--- /dev/null
+++ b/app/ui/runview.py
@@ -0,0 +1,643 @@
+#!/usr/bin/env python3
+"""The full-screen run view: server boot + conversion on one screen.
+
+Replaces the old plain-console drop after "Generate!": instead of dumping
+the user into scrolling log output, this widget keeps them in the TUI and
+shows the two processes that matter — the TTS server (top) and the
+conversion (bottom, with a chunk progress bar and elapsed time).
+
+The screen is fed by two threads the widget spawns:
+
+ * the worker runs the same code the console path runs —
+ ``backends.servers.start`` (when the conversion needs to boot a managed
+ server; its progress events stream in as they happen) followed by
+ ``audiobook.convert`` with a ``progress`` callback — so behavior is
+ identical to the CLI, only the presentation differs;
+ * a monitor polls the server URL while the conversion runs and reports
+ when it stops answering.
+
+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.
+"""
+
+import contextlib
+import io
+import threading
+import time
+from dataclasses import dataclass, field
+from queue import Empty, Queue
+from typing import Callable, List, Optional
+
+from backends import common, servers
+from ui import tui
+
+# Terminal states: the run is over and the screen waits for a key.
+_TERMINAL = ("done", "error", "cancelled")
+
+# Server panel states -> (text, theme kind) with the elapsed clock added
+# while booting.
+_SERVER_STATES = {
+ "starting": ("starting", "warn"),
+ "ready": ("ready", "ok"),
+ "processing": ("processing", "ok"),
+ "down": ("not responding", "err"),
+ "error": ("error", "err"),
+ "stopped": ("stopped", "info"),
+}
+
+# Redraw cadence / poll cadence (milliseconds / seconds).
+_DRAW_TIMEOUT_MS = 250
+_MONITOR_INTERVAL = 2.0
+
+
+@dataclass
+class RunConfig:
+ """Everything the run view needs to execute one conversion.
+
+ BACKEND/BACKEND_LABEL identify the chosen backend (label for display);
+ KWARGS are the converter keyword arguments the hub collected (voice,
+ clone, output format, api_url, ...); BOOK_FILES/PLANNED carry the
+ pre-flight overwrite result so the questions are not asked again.
+
+ SERVER_NAME/SERVER_URL/SERVER_IDENTITY describe the TTS server the
+ conversion talks to (the name is the backends.ServerSpec name; the URL
+ is what the monitor polls). AUTOSTART_SPEC, when not None, is the
+ ServerSpec the worker boots first (the hub only sets it when the
+ 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).
+ """
+ backend: str
+ backend_label: str
+ kwargs: dict
+ book_files: list
+ planned: list
+ server_name: Optional[str] = None
+ server_url: Optional[str] = None
+ server_identity: Optional[str] = None
+ autostart_spec: object = None
+ log_path: str = ""
+ notice: str = ""
+
+
+class RunView:
+ """Draws and drives one conversion run; see the module docstring."""
+
+ def __init__(self, scr, config: RunConfig,
+ clock: Callable[[], float] = time.time):
+ import curses
+ self.curses = curses
+ self.scr = scr
+ self.config = config
+ self.theme = tui._ensure_theme(curses)
+ self._clock = clock
+ # -- state -----------------------------------------------------
+ self.phase = "boot" # boot | convert | done | error | cancelled
+ self.server = "starting"
+ self.server_message = ""
+ self.log_tail: List[str] = []
+ self.book: Optional[tuple] = None # (index, total, name)
+ self.chapter: Optional[tuple] = None # (index, total)
+ self.chunk_done = 0
+ self.chunk_total = 0
+ self.book_results: List[tuple] = [] # (name, ok)
+ self.error_message = ""
+ self.cancelled = False
+ self.cancelling = False
+ self.started_server = False
+ self.finished_at: Optional[float] = None
+ self.boot_started: Optional[float] = None
+ self.convert_started: Optional[float] = None
+ self.server_log_path = ""
+ # -- threads ---------------------------------------------------
+ self._queue: Queue = Queue()
+ self._cancel = threading.Event()
+ self._monitor_stop = threading.Event()
+ self._worker = threading.Thread(target=self._worker_main,
+ daemon=True)
+
+ # ------------------------------------------------------------------
+ # Event handling (pure state transitions; no drawing)
+ # ------------------------------------------------------------------
+
+ def handle_event(self, event: dict) -> None:
+ """Fold one worker/monitor event into the view state."""
+ kind = event.get("kind")
+ if kind == "starting":
+ self.phase = "boot"
+ self.server = "starting"
+ self.boot_started = self._now()
+ self.started_server = True
+ self.server_log_path = event.get("log_path") or ""
+ elif kind == "running":
+ self.server = "ready"
+ self.boot_started = self.boot_started or self._now()
+ elif kind == "ready":
+ self.server = "ready"
+ elif kind in ("exited", "timeout"):
+ self.server = "error"
+ self.server_message = {
+ "exited": f"server exited with code "
+ f"{event.get('returncode')}",
+ "timeout": "server did not become ready in time",
+ }[kind]
+ self.log_tail = list(event.get("log_tail") or [])
+ self._finish("error")
+ elif kind == "cancelled":
+ self.cancelled = True
+ if self.server in ("starting", "ready", "processing"):
+ self.server = "stopped"
+ self._finish("cancelled")
+ elif kind == "server_down":
+ if self.phase == "convert":
+ self.server = "down"
+ elif kind == "book":
+ self.phase = "convert"
+ self.book = (event.get("index"), event.get("total"),
+ event.get("name") or "")
+ self.chapter = None
+ self.chunk_done = 0
+ self.chunk_total = 0
+ self.convert_started = self.convert_started or self._now()
+ if self.server == "ready":
+ self.server = "processing"
+ elif kind == "chapter":
+ self.chapter = (event.get("index"), event.get("total"))
+ self.chunk_done = 0
+ self.chunk_total = 0
+ elif kind == "chunks":
+ self.chunk_total = event.get("total") or 0
+ self.chunk_done = 0
+ elif kind == "chunk_done":
+ self.chunk_done = event.get("chunk") or self.chunk_done
+ self.chunk_total = event.get("total") or self.chunk_total
+ if self.server in ("ready", "processing"):
+ self.server = "processing"
+ elif kind == "chunk_failed":
+ self.error_message = (f"chunk {event.get('chunk')}/"
+ f"{event.get('total')} failed")
+ if self.server in ("ready", "processing"):
+ self.server = "ready"
+ elif kind == "book_done":
+ self.book_results.append((event.get("name") or "?",
+ bool(event.get("ok"))))
+ elif kind == "book_failed":
+ self.book_results.append((event.get("name") or "?", False))
+ self.error_message = self.error_message or \
+ (event.get("error") or "conversion failed")
+ elif kind == "done":
+ ok = event.get("ok") or 0
+ total = event.get("total") or 0
+ if event.get("cancelled"):
+ self.cancelled = True
+ self._finish("cancelled")
+ elif total and ok >= total and not self.error_message:
+ self._finish("done")
+ else:
+ self.error_message = self.error_message or \
+ f"{total - ok} of {total} book(s) failed"
+ self._finish("error")
+ elif kind == "error":
+ self.error_message = str(event.get("message") or "error")
+ self._finish("error")
+ elif kind == "worker_exit":
+ if self.phase not in _TERMINAL:
+ self.error_message = self.error_message or \
+ "the conversion ended unexpectedly"
+ self._finish("error")
+
+ def _finish(self, phase: str) -> None:
+ """Enter a terminal phase, freezing the elapsed clock."""
+ self.phase = phase
+ if self.finished_at is None:
+ self.finished_at = self._now()
+
+ def _now(self) -> float:
+ return self._clock()
+
+ # ------------------------------------------------------------------
+ # Threads
+ # ------------------------------------------------------------------
+
+ def _worker_main(self) -> None:
+ """Boot the server (when asked) and run the conversion."""
+ import audiobook
+ config = self.config
+ try:
+ with contextlib.redirect_stdout(io.StringIO()):
+ if config.autostart_spec is not None:
+ ok = servers.start(config.autostart_spec,
+ progress=self._queue.put,
+ cancel=self._cancel)
+ if not ok:
+ if self._cancel.is_set() and self.phase != "error":
+ self._queue.put({"kind": "cancelled"})
+ return
+ if self._cancel.is_set():
+ self._queue.put({"kind": "cancelled"})
+ return
+ audiobook.convert(backend=config.backend,
+ progress=self._queue.put,
+ cancel=self._cancel,
+ book_files=config.book_files,
+ planned=config.planned,
+ **config.kwargs)
+ except Exception as exc: # noqa: BLE001 - reported to the view
+ self._queue.put({"kind": "error", "message": f"{exc}"})
+ finally:
+ self._queue.put({"kind": "worker_exit"})
+
+ def _monitor_main(self) -> None:
+ """Watch the server URL while converting; report when it drops."""
+ url = self.config.server_url
+ if not url:
+ return
+ # Give a booting server the full start window before judging it.
+ while not self._monitor_stop.wait(_MONITOR_INTERVAL):
+ if self.phase in _TERMINAL:
+ return
+ if self.phase != "convert":
+ continue
+ if not common.server_running(url):
+ self._queue.put({"kind": "server_down"})
+ return
+
+ # ------------------------------------------------------------------
+ # Main loop
+ # ------------------------------------------------------------------
+
+ def run(self) -> None:
+ """Run the view until the user leaves the terminal screen."""
+ scr = self.scr
+ try:
+ self.scr.timeout(_DRAW_TIMEOUT_MS)
+ except Exception:
+ pass
+ self._worker.start()
+ monitor = threading.Thread(target=self._monitor_main, daemon=True)
+ monitor.start()
+ try:
+ while True:
+ self._drain()
+ self.render()
+ key = self._get_key()
+ if key is None:
+ continue
+ if self.phase in _TERMINAL:
+ self._confirm_stop_server()
+ return
+ if key in (27, ord("q"), 3) and not self.cancelling:
+ if self._prompt_cancel():
+ return
+ finally:
+ self._monitor_stop.set()
+ self._cancel.set()
+
+ def _get_key(self) -> Optional[int]:
+ """One key from the screen (None on the redraw timeout)."""
+ try:
+ key = self.scr.getch()
+ except KeyboardInterrupt:
+ return 3
+ if key == -1:
+ return None
+ return key
+
+ def _drain(self) -> None:
+ """Fold every queued event into the state."""
+ while True:
+ try:
+ event = self._queue.get_nowait()
+ except Empty:
+ return
+ self.handle_event(event)
+
+ def _prompt_cancel(self) -> bool:
+ """The Esc/q flow: confirm cancel, then confirm stopping the server.
+
+ Returns True when the run view should return to the menu (the run
+ is over); False when the user changed their mind and the run keeps
+ going.
+ """
+ self._blocking()
+ answer = tui.confirm(self.scr, "Cancel processing?", default=False,
+ cancel_value=False)
+ if not answer:
+ self._nonblocking()
+ return False
+ self.cancelling = True
+ self._cancel.set()
+ # When this run booted the server, offer to shut it down too (the
+ # boot path kills it itself when cancelled before ready).
+ self._confirm_stop_server()
+ # Wait for the worker to wind down so the hub menu shows the real
+ # backend state (and the summary screen is drawn at least once).
+ self._worker.join(timeout=60)
+ self._drain()
+ self.render()
+ # One more key press acknowledges the final screen.
+ self._blocking()
+ try:
+ self.scr.getch()
+ except KeyboardInterrupt:
+ pass
+ return True
+
+ def _confirm_stop_server(self) -> None:
+ """Ask whether to stop the server this run started (once)."""
+ if not self.started_server or self._server_stopped_confirmed:
+ return
+ self._server_stopped_confirmed = True
+ name = self.config.server_name
+ if not name or not servers.alive(name):
+ return
+ self._blocking()
+ answer = tui.confirm(self.scr,
+ f"Stop the '{name}' server now?", default=True,
+ cancel_value=False)
+ if answer:
+ with contextlib.redirect_stdout(io.StringIO()):
+ servers.stop(name)
+ self.server = "stopped"
+
+ def _blocking(self) -> None:
+ """Make getch block (used while a confirm dialog owns the screen)."""
+ try:
+ self.scr.timeout(-1)
+ except Exception:
+ pass
+
+ def _nonblocking(self) -> None:
+ """Restore the redraw-cadence getch timeout."""
+ try:
+ self.scr.timeout(_DRAW_TIMEOUT_MS)
+ except Exception:
+ pass
+
+ _server_stopped_confirmed = False
+
+ # ------------------------------------------------------------------
+ # Drawing
+ # ------------------------------------------------------------------
+
+ def render(self) -> None:
+ """Repaint the whole screen from the current state."""
+ curses, theme = self.curses, self.theme
+ scr = self.scr
+ scr.erase()
+ height, width = scr.getmaxyx()
+ if height < 14 or width < 46:
+ _text(scr, theme, height // 2, 2, "Terminal too small",
+ curses.A_BOLD)
+ scr.refresh()
+ return
+
+ _box(scr, curses, theme, height, width)
+ _text(scr, theme, 0, 2, " Converting audiobooks ", theme["title"])
+
+ inner_x = 3
+ label_w = 9 # "Server", "Status", "Chunk", "Elapsed"
+ value_x = inner_x + label_w + 1
+ value_w = width - value_x - 3
+
+ # -- server panel ------------------------------------------------
+ y = 2
+ url = self.config.server_url or "not managed"
+ _text(scr, theme, y, inner_x, "Server".ljust(label_w), theme["dim"])
+ _text(scr, theme, y, value_x,
+ _fit(f"{self.config.backend_label} @ {url}", value_w),
+ theme["body"])
+ y += 1
+ state_text, state_kind = _SERVER_STATES.get(
+ self.server, (self.server, "info"))
+ if self.server == "starting" and self.boot_started is not None:
+ state_text += f" ({int(self._now() - self.boot_started)}s)"
+ if self.config.autostart_spec is None and self.server == "ready":
+ state_text += " (external)"
+ _text(scr, theme, y, inner_x, "Status".ljust(label_w), theme["dim"])
+ _text(scr, theme, y, value_x, _fit(state_text, value_w),
+ theme.get(state_kind, theme["body"]))
+ y += 2
+
+ # -- separator ---------------------------------------------------
+ _sep(scr, curses, theme, y, width)
+ y += 2
+
+ if self.phase in _TERMINAL:
+ y = self._draw_summary(scr, theme, y, inner_x, label_w,
+ value_x, value_w, width)
+ else:
+ y = self._draw_progress(scr, theme, y, inner_x, label_w,
+ value_x, value_w, width)
+
+ # -- footer ------------------------------------------------------
+ if self.cancelling and self.phase not in _TERMINAL:
+ footer = "cancelling..."
+ elif self.phase in _TERMINAL:
+ footer = "press any key to return to the menu"
+ else:
+ footer = "Esc or q: cancel"
+ _text(scr, theme, height - 2, 2, _fit(footer, width - 4),
+ theme["dim"])
+ scr.refresh()
+
+ def _draw_progress(self, scr, theme, y, inner_x, label_w, value_x,
+ value_w, width) -> int:
+ """The live panel: book, chapter, chunk bar, elapsed, message."""
+ # Book line
+ if self.book is not None:
+ index, total, name = self.book
+ book_text = f"{index}/{total} {name}"
+ else:
+ book_text = "waiting..." if self.phase == "convert" else "-"
+ _text(scr, theme, y, inner_x, "Book".ljust(label_w), theme["dim"])
+ _text(scr, theme, y, value_x, _fit(book_text, value_w), theme["body"])
+ y += 1
+ # Chapter line (only while a multi-chapter book is converting)
+ if self.chapter is not None:
+ _text(scr, theme, y, inner_x, "Chapter".ljust(label_w),
+ theme["dim"])
+ _text(scr, theme, y, value_x,
+ _fit(f"{self.chapter[0]}/{self.chapter[1]}", value_w),
+ theme["body"])
+ y += 1
+ # Chunk bar
+ bar_label = "Chunk".ljust(label_w)
+ _text(scr, theme, y, inner_x, bar_label, theme["dim"])
+ bar_x = value_x
+ bar_room = max(10, value_w - 12)
+ filled = 0
+ if self.chunk_total:
+ filled = round(bar_room * self.chunk_done / self.chunk_total)
+ filled = max(0, min(bar_room, filled))
+ try:
+ scr.addstr(y, bar_x, " " * filled, theme["bar"])
+ except Exception:
+ pass
+ _text(scr, theme, y, bar_x + bar_room + 1,
+ f"{self.chunk_done}/{self.chunk_total or '?'}",
+ theme["accent"])
+ y += 1
+ # Elapsed
+ started = self.convert_started or self.boot_started or self._now()
+ _text(scr, theme, y, inner_x, "Elapsed".ljust(label_w), theme["dim"])
+ _text(scr, theme, y, value_x, _format_elapsed(self._now() - started),
+ theme["body"])
+ y += 2
+ # Message line (last error / current activity)
+ if self.error_message:
+ _text(scr, theme, y, inner_x,
+ _fit(self.error_message, width - inner_x - 3),
+ theme["err"])
+ y += 1
+ elif self.server == "down":
+ _text(scr, theme, y, inner_x,
+ _fit("the server stopped responding; the conversion "
+ "will fail", width - inner_x - 3), theme["err"])
+ y += 1
+ elif self.config.notice:
+ _text(scr, theme, y, inner_x,
+ _fit(self.config.notice, width - inner_x - 3),
+ theme["warn"])
+ y += 1
+ elif self.server_log_path and self.phase == "boot":
+ _text(scr, theme, y, inner_x,
+ _fit(f"loading the model can take a while — log: "
+ f"{self.server_log_path}", width - inner_x - 3),
+ theme["dim"])
+ y += 1
+ return y
+
+ def _draw_summary(self, scr, theme, y, inner_x, label_w, value_x,
+ value_w, width) -> int:
+ """The terminal panel: result, per-book lines, error detail."""
+ if self.phase == "done":
+ result, kind = "completed", "ok"
+ elif self.phase == "cancelled":
+ result, kind = "cancelled", "warn"
+ else:
+ result, kind = "failed", "err"
+ _text(scr, theme, y, inner_x, "Result".ljust(label_w), theme["dim"])
+ _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]:
+ mark = "[OK] " if ok else "[FAIL]"
+ _text(scr, theme, y, value_x,
+ _fit(f"{mark} {name}", value_w),
+ theme["ok"] if ok else theme["err"])
+ y += 1
+ if len(self.book_results) > 5:
+ _text(scr, theme, y, value_x,
+ _fit(f"... and {len(self.book_results) - 5} more",
+ value_w), theme["dim"])
+ y += 1
+ if self.phase == "error":
+ detail = self.error_message or self.server_message
+ if detail:
+ for line in _wrap(detail, width - inner_x - 3)[:2]:
+ _text(scr, theme, y, inner_x, line, theme["err"])
+ y += 1
+ if self.log_tail:
+ for line in self.log_tail[:3]:
+ _text(scr, theme, y, inner_x,
+ _fit(line.strip() or " ", width - inner_x - 3),
+ theme["dim"])
+ y += 1
+ if self.config.log_path:
+ _text(scr, theme, y, inner_x,
+ _fit(f"details: {self.config.log_path}",
+ width - inner_x - 3), theme["dim"])
+ y += 1
+ elif self.phase == "cancelled":
+ _text(scr, theme, y, inner_x,
+ "no audiobook was produced for the cancelled book",
+ theme["dim"])
+ y += 1
+ return y
+
+
+# ---------------------------------------------------------------------------
+# Small drawing/formatting helpers (module-level for testability)
+# ---------------------------------------------------------------------------
+
+def _text(scr, theme, y, x, text, attr) -> None:
+ """addstr wrapper that ignores out-of-bounds errors."""
+ try:
+ scr.addstr(y, x, text, attr)
+ except Exception:
+ pass
+
+
+def _box(scr, curses, theme, height, width) -> None:
+ """Draw the full-screen frame."""
+ border = theme["border"]
+ try:
+ scr.addch(0, 0, curses.ACS_ULCORNER, border)
+ scr.addch(0, width - 1, curses.ACS_URCORNER, border)
+ scr.addch(height - 1, 0, curses.ACS_LLCORNER, border)
+ scr.addch(height - 1, width - 1, curses.ACS_LRCORNER, border)
+ scr.hline(0, 1, curses.ACS_HLINE, width - 2, border)
+ scr.hline(height - 1, 1, curses.ACS_HLINE, width - 2, border)
+ for y in range(1, height - 1):
+ scr.addch(y, 0, curses.ACS_VLINE, border)
+ scr.addch(y, width - 1, curses.ACS_VLINE, border)
+ except Exception:
+ pass
+
+
+def _sep(scr, curses, theme, y, width) -> None:
+ """A horizontal separator line inside the frame."""
+ try:
+ scr.addch(y, 0, curses.ACS_LTEE, theme["border"])
+ scr.addch(y, width - 1, curses.ACS_RTEE, theme["border"])
+ scr.hline(y, 1, curses.ACS_HLINE, width - 2, theme["dim"])
+ except Exception:
+ pass
+
+
+def _fit(text: str, width: int) -> str:
+ """Truncate TEXT to WIDTH columns, appending '~' when cut."""
+ if width < 1:
+ return ""
+ if len(text) <= width:
+ return text
+ return text[: max(0, width - 1)] + "~"
+
+
+def _wrap(text: str, width: int) -> List[str]:
+ """Greedy word wrap (no textwrap dependency on curses chars)."""
+ lines: List[str] = []
+ current = ""
+ for word in text.split():
+ candidate = f"{current} {word}".strip()
+ if len(candidate) <= max(10, width):
+ current = candidate
+ else:
+ if current:
+ lines.append(current)
+ current = word
+ if current:
+ lines.append(current)
+ return lines
+
+
+def _format_elapsed(seconds: float) -> str:
+ """Format a duration as H:MM:SS / M:SS."""
+ seconds = max(0, int(seconds))
+ hours, remainder = divmod(seconds, 3600)
+ minutes, secs = divmod(remainder, 60)
+ if hours:
+ return f"{hours}:{minutes:02d}:{secs:02d}"
+ return f"{minutes}:{secs:02d}"
+
+
+def run(scr, config: RunConfig) -> None:
+ """Enter the run view (called inside curses.wrapper by the hub)."""
+ view = RunView(scr, config)
+ view.run()