aboutsummaryrefslogtreecommitdiff
path: root/ui/hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 02:19:50 -0400
committerhistoria <historiavg@proton.me>2026-08-24 02:19:50 -0400
commit9dd4f9595be3b1d76a3a07dc3eca90cfaf8a3f97 (patch)
treec14c950f642107c196c1db23d96dbf69355eff54 /ui/hub.py
parentc02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a (diff)
downloadtts-audiobook-generator-9dd4f9595be3b1d76a3a07dc3eca90cfaf8a3f97.tar.gz
feat: settings menu in tui
Diffstat (limited to 'ui/hub.py')
-rw-r--r--ui/hub.py103
1 files changed, 103 insertions, 0 deletions
diff --git a/ui/hub.py b/ui/hub.py
index f3e2b7e..b2a2b08 100644
--- a/ui/hub.py
+++ b/ui/hub.py
@@ -13,6 +13,7 @@ main menu.
"""
import json
+import re
from pathlib import Path
from typing import Optional, Tuple
@@ -35,6 +36,7 @@ from converter.tts import (
BACKEND_AUDIOCPP,
BACKEND_FASTER,
BACKEND_QWEN,
+ normalize_language,
)
from ui import tui
@@ -79,6 +81,7 @@ def _hub_menu(stdscr) -> Optional[tuple]:
options.insert(0, ("Convert books...", "convert"))
options.append(("Configure a backend...", "configure"))
options.append(("Server...", "server"))
+ options.append(("Settings...", "settings"))
options.append(("Quit", "quit"))
rows = [(st.label, *_status_mark(st)) for st in statuses]
choice = tui.menu(
@@ -102,6 +105,8 @@ def _hub_menu(stdscr) -> Optional[tuple]:
cmd = _server_menu(stdscr, statuses)
if cmd is not None:
return cmd
+ elif choice == "settings":
+ _settings_menu(stdscr)
def _setup_menu(stdscr, statuses) -> Optional[tuple]:
@@ -373,6 +378,104 @@ def _common_options(stdscr) -> Optional[dict]:
}
+# ---------------------------------------------------------------------------
+# Settings menu (global output options -> converter/config.py)
+# ---------------------------------------------------------------------------
+
+def _settings_menu(stdscr) -> None:
+ """Edit the global output settings; Save writes them back to config.py."""
+ fields = [
+ {"key": "audio_format", "label": "Audio format", "kind": "choice",
+ "value": config.AUDIO_FORMAT, "choices": list(AUDIO_FORMATS)},
+ {"key": "audio_bitrate", "label": "Audio bitrate", "kind": "text",
+ "value": config.AUDIO_BITRATE,
+ "validate": _validate_bitrate},
+ {"key": "language", "label": "Language", "kind": "text",
+ "value": config.LANGUAGE, "validate": _validate_language},
+ {"key": "chunk_size", "label": "Chunk size (words)", "kind": "text",
+ "value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size},
+ ]
+ result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK)
+ if result is None or result is _GO_BACK:
+ return
+ try:
+ _apply_settings(result)
+ except ValueError as exc:
+ tui.flash(stdscr, str(exc), "err")
+ return
+ tui.flash(stdscr, "Settings saved.", "ok")
+
+
+def _validate_bitrate(value: str) -> Optional[str]:
+ """Error message for a blank audio bitrate, or None to accept it."""
+ if value.strip():
+ return None
+ return "Audio bitrate must not be empty"
+
+
+def _validate_language(value: str) -> Optional[str]:
+ """Error message for an unrecognized LANGUAGE, or None to accept it."""
+ try:
+ normalize_language(value)
+ return None
+ except ValueError as exc:
+ return str(exc)
+
+
+def _validate_chunk_size(value: str) -> Optional[str]:
+ """Error message for an invalid CHUNK_SIZE, or None to accept it."""
+ try:
+ number = int(value.strip())
+ except ValueError:
+ return "Enter a whole number of words, e.g. 250"
+ if number < 1:
+ return "Chunk size must be at least 1"
+ return None
+
+
+def _apply_settings(values: dict) -> None:
+ """Write VALUES to converter/config.py and reload them in-memory."""
+ chunk_size = int(values["chunk_size"].strip())
+ if chunk_size < 1:
+ raise ValueError("Chunk size must be at least 1")
+ bitrate = values["audio_bitrate"].strip()
+ if not bitrate:
+ raise ValueError("Audio bitrate must not be empty")
+ if values["audio_format"] not in AUDIO_FORMATS:
+ raise ValueError(f"Unsupported audio format: {values['audio_format']}")
+ updates = {
+ "AUDIO_FORMAT": values["audio_format"],
+ "AUDIO_BITRATE": bitrate,
+ "LANGUAGE": normalize_language(values["language"]),
+ "CHUNK_SIZE": chunk_size,
+ }
+ _write_config(updates)
+ for name, value in updates.items():
+ setattr(config, name, value)
+
+
+def _write_config(updates: dict) -> None:
+ """Rewrite the ``NAME = value`` lines for UPDATES in converter/config.py.
+
+ Only the value of each named assignment changes: the indentation, the
+ quotes (double, matching the file's style) and any trailing comment on
+ the line are preserved. Every other line is left untouched.
+ """
+ path = Path(config.__file__).resolve()
+ text = path.read_text(encoding="utf-8")
+ for name, value in updates.items():
+ rendered = str(value) if isinstance(value, int) else f'"{value}"'
+ pattern = re.compile(
+ rf"^(\s*{re.escape(name)}\s*=\s*)(\S*)(\s*(#.*))?$",
+ re.MULTILINE)
+ text, count = pattern.subn(
+ lambda m, rendered=rendered:
+ f"{m.group(1)}{rendered}{m.group(3) or ''}", text)
+ if count != 1:
+ raise ValueError(f"Could not find {name} in {path}")
+ 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).