From d950fc8e64ee508334e608f6045d687d73a464be Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 17:37:34 -0400 Subject: feat: tui backend server progress and generate script progress --- app/ui/hub.py | 240 ++++++++++++++------ app/ui/runview.py | 643 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 821 insertions(+), 62 deletions(-) create mode 100644 app/ui/runview.py (limited to 'app/ui') 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() -- cgit v1.2.3