aboutsummaryrefslogtreecommitdiff
path: root/app/ui/runview.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui/runview.py')
-rw-r--r--app/ui/runview.py643
1 files changed, 643 insertions, 0 deletions
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()