diff options
| author | historia <historiavg@proton.me> | 2026-08-27 17:03:57 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-27 17:03:57 -0400 |
| commit | cef2352a5e81b272d067c2c02eb9588e54edfcfd (patch) | |
| tree | 83af416a82d9e854f44b6f3207eb8ea648b0896d /app/backends | |
| parent | 6527240aa69a08f06e36e796818721abeec9f592 (diff) | |
| download | tts-audiobook-generator-cef2352a5e81b272d067c2c02eb9588e54edfcfd.tar.gz | |
feat: automatic updates added to configure backend menu
Diffstat (limited to 'app/backends')
| -rw-r--r-- | app/backends/__init__.py | 8 | ||||
| -rw-r--r-- | app/backends/audiocpp/__init__.py | 6 | ||||
| -rw-r--r-- | app/backends/audiocpp/build.py | 114 | ||||
| -rw-r--r-- | app/backends/common.py | 114 | ||||
| -rw-r--r-- | app/backends/envs.py | 16 | ||||
| -rwxr-xr-x | app/backends/faster.py | 38 | ||||
| -rw-r--r-- | app/backends/qwen.py | 27 |
7 files changed, 308 insertions, 15 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py index e59c464..8c9f045 100644 --- a/app/backends/__init__.py +++ b/app/backends/__init__.py @@ -160,6 +160,10 @@ class BackendInfo: removes the backend (stops its servers, pip-uninstalls, deletes its files); the hub runs it inside the task view, calling it with optional ``emit``/``cancel`` keywords (cancel honored between phases only). + UPDATE refreshes the installed backend to the latest upstream version + (pip -U / git fetch+reset, rebuilding where a binary must match the + sources); same calling convention as UNINSTALL. Without UPDATE a + backend is skipped by the hub's "Update backends" action. CONFIGURE_SCREEN, when given, is what the hub's "Configure <label>" menu entry runs instead of SETUP_SCREEN once the backend exists — a @@ -173,6 +177,7 @@ class BackendInfo: detect: Callable[[], BackendStatus] setup_screen: Callable[[object], int] uninstall: Callable[..., int] = lambda *args, **kwargs: 0 + update: Optional[Callable[..., int]] = None configure_screen: Optional[Callable[[object], int]] = None @@ -192,6 +197,7 @@ def _build_registry() -> None: detect=audiocpp.detect, setup_screen=audiocpp.setup_screen, uninstall=audiocpp.uninstall, + update=audiocpp.update, )) REGISTRY.append(BackendInfo( key="qwen", @@ -199,6 +205,7 @@ def _build_registry() -> None: detect=qwen.detect, setup_screen=qwen.setup_screen, uninstall=qwen.uninstall, + update=qwen.update, configure_screen=qwen.models_screen, )) REGISTRY.append(BackendInfo( @@ -207,6 +214,7 @@ def _build_registry() -> None: detect=faster.detect, setup_screen=faster.setup_screen, uninstall=faster.uninstall, + update=faster.update, )) for info in REGISTRY: _BY_KEY[info.key] = info diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py index b97032b..f55cf35 100644 --- a/app/backends/audiocpp/__init__.py +++ b/app/backends/audiocpp/__init__.py @@ -10,7 +10,8 @@ Modules: models install state on disk, missing-model guidance, downloads voices reference-.wav transcription planning and execution configsync app/converter/config.py + server.json port/id/backend sync - build checkout lifecycle: ggml patches, binary build, uninstall + build checkout lifecycle: ggml patches, binary build, update, + uninstall remote querying a running server for its models/voices status detect() for the hub's backend menu wizard the TUI wizard and the CLI entry points @@ -66,6 +67,7 @@ from .build import ( find_local_checkout, find_audiocpp_server_bin, uninstall, + update, ) from .remote import fetch_server_models, fetch_server_voices from .wizard import ( @@ -99,7 +101,7 @@ __all__ = [ "update_server_backend", # build "find_local_checkout", "find_audiocpp_server_bin", "find_build_script", - "apply_ggml_patches", "build_audiocpp", "uninstall", + "apply_ggml_patches", "build_audiocpp", "uninstall", "update", # remote "fetch_server_models", "fetch_server_voices", # wizard / status diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py index e63a799..b850859 100644 --- a/app/backends/audiocpp/build.py +++ b/app/backends/audiocpp/build.py @@ -11,8 +11,8 @@ from typing import List, Optional from backends import common, servers from backends.common import APP_DIR -from .catalog import _BACKEND_TOKEN_RE -from .constants import AUDIOCPP_DIR_NAME, PATCH_DIR +from .catalog import _BACKEND_TOKEN_RE, detect_backend, load_server_config +from .constants import AUDIOCPP_DIR_NAME, BACKENDS, PATCH_DIR def uninstall(*, emit=None, cancel=None) -> int: """Remove the audio.cpp backend entirely: stop its server, delete the checkout. @@ -47,6 +47,116 @@ def uninstall(*, emit=None, cancel=None) -> int: return 0 +def update(*, emit=None, cancel=None) -> int: + """Update the audio.cpp backend: refresh the checkout, rebuild if stale. + + A managed server that is running is stopped first (best-effort): it + serves the binary whose sources are being replaced. Phases: stop + server / git update / rebuild — CANCEL is honored between phases only, + so a started phase always completes. The git update is a fetch plus + hard reset to origin's HEAD (see ``common.git_update``): everything + that matters lives untracked in the checkout (models, build trees, + server.json) and survives, while the vendored-ggml patch edit is + intentionally wiped — the rebuild re-applies it (the patch step is + idempotent and fails loudly when upstream re-shaped the file). + + The rebuild target is the backend recorded in server.json, else the + one detected from existing build directories; when neither names one + (nothing was ever built) the update stops after the checkout refresh + — 'Build audio.cpp server' handles a first build. The rebuild itself + runs when the sources changed (HEAD moved) or the on-disk binary is + missing or older than HEAD's commit time — the latter heals an + interrupted (cancelled or failed) earlier rebuild, which leaves the + previous binary in place against already-updated sources. An + up-to-date checkout with a fresh binary costs one fetch. Returns the + exit code (130 when cancelled before a remaining phase). + """ + # Only stop when a pid file exists: without one this tool never + # started the server, so the "not started by this tool" notice would + # be uninstall-time noise. + if servers.pid_for("audiocpp") is not None: + servers.stop("audiocpp") + if common.cancel_requested(cancel): + return 130 + checkout = find_local_checkout() + if checkout is None: + print("[INFO] No audio.cpp checkout to update.") + return 0 + head_before = common.git_head(checkout) + rc = common.git_update(checkout, emit=emit, cancel=cancel) + if rc != 0: + print(f"[WARNING] checkout update failed (exit {rc}); update " + f"manually: git -C {checkout} pull") + return rc + head_after = common.git_head(checkout) + if common.cancel_requested(cancel): + return 130 + backend = _rebuild_backend(checkout) + if backend is None: + print("[INFO] audiocpp_server was never built for a known " + "backend; skipping the rebuild. 'Build audio.cpp server' " + "builds one.") + return 0 + binary = built_server_binary(checkout, backend) + if not _rebuild_needed(checkout, binary, + moved=head_after not in (None, head_before)): + print(f"[OK] {checkout} is already at origin's HEAD with an " + "up-to-date audiocpp_server.") + return 0 + if head_after in (None, head_before): + print(f"[INFO] audiocpp_server on disk is older than the " + f"checked-out sources (earlier build interrupted?); " + f"rebuilding for {backend}.") + else: + print(f"[OK] Updated {checkout} to {head_after[:12]}; rebuilding " + f"audiocpp_server for {backend}.") + build_rc = build_audiocpp(checkout, backend, emit=emit, cancel=cancel) + if build_rc != 0: + print(f"[WARNING] rebuild exited with code {build_rc}; see the " + "messages above (the build log under app/logs/ has the " + "full output). The binary on disk is now older than the " + "checked-out sources; re-running 'Update backends' will " + "retry the rebuild.") + else: + print("[OK] rebuild complete.") + return build_rc + + +def _rebuild_needed(checkout: Path, binary: Optional[Path], + *, moved: bool) -> bool: + """True when audiocpp_server must be (re)built after an update. + + True when the checkout moved, the binary is missing, its age cannot + be compared (no commit time), or it predates HEAD's commit — the + last case is what a cancelled or failed earlier rebuild leaves + behind (old binary, already-updated sources). + """ + if moved or binary is None: + return True + commit_time = common.git_commit_time(checkout) + if commit_time is None: + return True + try: + return binary.stat().st_mtime <= commit_time + except OSError: + return True + + +def _rebuild_backend(checkout: Path) -> Optional[str]: + """The inference backend to rebuild for after an update, or None. + + server.json's recorded backend wins (it is what the managed server + launches); an existing build directory's token is the fallback for a + checkout that was built but never configured. None means neither + names a valid backend — there is no binary to keep fresh. + """ + server_config = load_server_config(checkout / "server.json") or {} + recorded = server_config.get("backend") + if recorded in BACKENDS: + return recorded + return detect_backend(checkout) + + def find_local_checkout() -> Optional[Path]: """Return the managed audio.cpp checkout at ``app/audio.cpp``. diff --git a/app/backends/common.py b/app/backends/common.py index 098dfb2..4edfd61 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -496,22 +496,124 @@ def git_clone(url: str, target: Path, *, emit=None, cancel=None) -> int: emit=emit, cancel=cancel) +def git_head(checkout: Path) -> Optional[str]: + """CHECKOUT's current HEAD commit sha, or None when it is not a repo.""" + proc = run_console_subprocess_quiet(["git", "-C", str(checkout), + "rev-parse", "HEAD"]) + if proc is None or proc.returncode != 0: + return None + return proc.stdout.decode("utf-8", errors="replace").strip() or None + + +def git_commit_time(checkout: Path) -> Optional[int]: + """CHECKOUT's HEAD commit time as a unix timestamp, or None. + + Uses the *committer* time (``%ct``): a rebase or cherry-pick rewrites + it to when the rewrite happened, so a force-pushed or rebased branch + always looks newer than binaries built from the pre-rewrite sources. + None (not a repo, probe failed) leaves the decision to the caller. + """ + proc = run_console_subprocess_quiet(["git", "-C", str(checkout), + "show", "-s", "--format=%ct", + "HEAD"]) + if proc is None or proc.returncode != 0: + return None + try: + return int(proc.stdout.decode("ascii", errors="replace").strip()) + except ValueError: + return None + + +def git_update(checkout: Path, *, emit=None, cancel=None) -> int: + """Update CHECKOUT to its remote's HEAD: fetch, then hard reset. + + The backend checkouts are read-only working copies of upstream repos — + all state that matters (models, build trees, server.json, voices.json) + is untracked and survives the reset, while local edits the installers + made (the vendored-ggml patch in the audio.cpp checkout) are meant to + be re-applied by the caller afterwards. ``git reset --hard`` is used + instead of ``git pull`` because a pull merges against the working tree + and would conflict on exactly those re-applied-by-design edits. + + The branch reset to is the remote's default (``refs/remotes/origin/ + HEAD``), falling back to ``main`` when the symbolic ref is missing (a + bare-ish mirror or a restrictive server). EMIT/CANCEL behave like + git_clone's (fetch runs with --progress so the task view sees updates). + Returns the exit code of the first failing step (0 when the checkout + now matches origin's HEAD). + """ + if emit is None: + print(f"[INFO] Updating git checkout {checkout}...") + else: + emit(f"[INFO] Updating git checkout {checkout}...") + fetch_argv = ["git", "-C", str(checkout), "fetch"] + reset_argv = ["git", "-C", str(checkout), "reset", "--hard"] + if emit is not None: + # --progress makes git report percentage updates even though stderr + # is piped (it normally only does so on a terminal), feeding the + # task view. + fetch_argv.append("--progress") + fetch_argv.append("origin") + fetch_rc = run_console_subprocess(fetch_argv, emit=emit, cancel=cancel) + if fetch_rc != 0: + return fetch_rc + branch = _origin_default_branch(checkout) + return run_console_subprocess(reset_argv + [f"origin/{branch}"], + emit=emit, cancel=cancel) + + +def _origin_default_branch(checkout: Path) -> str: + """The remote's default branch name for CHECKOUT ("main" as fallback).""" + proc = run_console_subprocess_quiet( + ["git", "-C", str(checkout), "symbolic-ref", + "refs/remotes/origin/HEAD"]) + if proc is not None and proc.returncode == 0: + ref = proc.stdout.decode("utf-8", errors="replace").strip() + # refs/remotes/origin/HEAD -> refs/remotes/origin/main + name = ref.rpartition("/")[2] + if name: + return name + return "main" + + +def run_console_subprocess_quiet(argv: List[str], + cwd: Optional[Path] = None): + """Run ARGV silently and return the completed result. + + Unlike run_console_subprocess (which streams or returns only an exit + code) this captures stdout and needs the process object itself, for the + small git probes (rev-parse, symbolic-ref) whose *output* matters and + whose failure is a normal, non-fatal outcome. Returns None when the + process could not be started. + """ + import subprocess + try: + return subprocess.run( + argv, capture_output=True, + cwd=str(cwd) if cwd is not None else None, check=False) + except OSError: + return None + + def pip_install(packages: List[str], *, emit=None, cancel=None, - env_dir: Optional[Path] = None) -> int: + env_dir: Optional[Path] = None, + upgrade: bool = False) -> int: """pip install PACKAGES into a managed venv. Returns exit code. Delegates to ``backends.envs.pip_install`` so backend TTS packages are installed into their dedicated tool-managed environments (``envs/tts`` default; ``envs/qwen`` / ``envs/faster`` via ENV_DIR) rather than into whatever interpreter happens to be running the wizard — and never two - conflicting stacks into the same env. With EMIT given (the in-TUI task - view) pip runs with its output streamed into EMIT; CANCEL aborts it. - The import is local to avoid a circular import (envs imports this - module). + conflicting stacks into the same env. With UPGRADE pip runs with + ``-U`` (the backend update action's freshness check: pip only installs + when a newer version resolves, else reports "already satisfied"). + With EMIT given (the in-TUI task view) pip runs with its output streamed + into EMIT; CANCEL aborts it. The import is local to avoid a circular + import (envs imports this module). """ from backends import envs return envs.pip_install(packages, emit=emit, cancel=cancel, - env_dir=env_dir) + env_dir=env_dir, upgrade=upgrade) def pip_uninstall(packages: List[str], *, emit=None, diff --git a/app/backends/envs.py b/app/backends/envs.py index 50154c2..ff6d1bf 100644 --- a/app/backends/envs.py +++ b/app/backends/envs.py @@ -190,22 +190,28 @@ def install_requirements(skip_optional: bool = False) -> int: def pip_install(packages: List[str], *, emit=None, cancel=None, - env_dir: Optional[Path] = None) -> int: + env_dir: Optional[Path] = None, + upgrade: bool = False) -> int: """pip install PACKAGES into ENV (an env dir, default the app env), creating it first if needed. Used by the qwen/faster setup wizards to install their TTS packages into their dedicated backend venvs (QWEN_ENV_DIR / FASTER_ENV_DIR), never - alongside each other or the app requirements. Returns pip's exit code. - With EMIT given (the in-TUI task view) pip runs with ``--progress-bar - off`` so its output is clean status lines rather than carriage-return - progress spam. + alongside each other or the app requirements. With UPGRADE the install + runs with ``-U``: pip then resolves the latest version itself and + reports "Requirement already satisfied" when the env already holds it — + the backend update action's cheap freshness check. Returns pip's exit + code. With EMIT given (the in-TUI task view) pip runs with + ``--progress-bar off`` so its output is clean status lines rather than + carriage-return progress spam. """ if not env_exists(env_dir) and create_env(env_dir) != 0: return 1 target = env_dir if env_dir is not None else ENV_DIR print(f"[INFO] pip install {' '.join(packages)} into {target}...") argv = [str(env_python(env_dir)), "-m", "pip", "install"] + if upgrade: + argv.append("-U") if emit is not None: argv.append("--progress-bar") argv.append("off") diff --git a/app/backends/faster.py b/app/backends/faster.py index 3631153..e52a023 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -511,6 +511,44 @@ def _detect_remote(managed: bool = False): return False, {} +def update(*, emit=None, cancel=None) -> int: + """Update the faster-qwen3-tts backend: pip upgrade + checkout refresh. + + A managed server that is running is stopped first (best-effort): the + server runs ``examples/openai_server.py`` from the checkout being + reset and imports the package being upgraded. Phases: stop server / + pip install -U / git update — CANCEL is honored between phases only, + so a started phase always completes. The pip package (into + FASTER_ENV) and the cloned checkout are refreshed independently: the + checkout only holds ``examples/openai_server.py`` (and the untracked + voices.json, which a hard reset leaves alone), so a failed phase is + warned about and reflected in the exit code without undoing the + other. Returns the exit code (130 when cancelled before a remaining + phase). + """ + if servers.pid_for("faster") is not None: + servers.stop("faster") + if common.cancel_requested(cancel): + return 130 + rc = common.pip_install([FASTER_PIP_PKG], emit=emit, cancel=cancel, + env_dir=FASTER_ENV, upgrade=True) + if rc != 0: + print(f"[WARNING] pip install -U failed (exit {rc}); update " + f"{FASTER_PIP_PKG} manually") + else: + print(f"[OK] {FASTER_PIP_PKG} is up to date (or just upgraded).") + if common.cancel_requested(cancel): + return 130 + if _is_cloned(): + clone_rc = common.git_update(_checkout(), emit=emit, cancel=cancel) + if clone_rc != 0: + print(f"[WARNING] checkout update failed (exit {clone_rc}); " + f"run 'git -C {_checkout()} pull' manually") + return clone_rc + print(f"[OK] {_checkout()} is at origin's HEAD.") + return rc + + def uninstall(*, emit=None, cancel=None) -> int: """Remove the faster-qwen3-tts backend entirely. diff --git a/app/backends/qwen.py b/app/backends/qwen.py index b2a1df4..4f3afd9 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -457,6 +457,33 @@ def uninstall(*, emit=None, cancel=None) -> int: return rc +def update(*, emit=None, cancel=None) -> int: + """Update the qwen-tts backend: pip install -U qwen-tts in its venv. + + A managed server that is running is stopped first (best-effort): it + imports ``qwen_tts`` from the very venv being upgraded, so an in-place + upgrade under a live process would leave it serving stale code. + CANCEL is a ``threading.Event`` honored between phases only (stop + server / pip) — a started phase always completes, so pip is never + killed mid-run. pip itself is the freshness check: it resolves the + latest version, upgrades when there is one, and reports "Requirement + already satisfied" otherwise. Returns the exit code (130 when + cancelled before a remaining phase). + """ + if servers.pid_for("qwen") is not None: + servers.stop("qwen") + if common.cancel_requested(cancel): + return 130 + rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel, + env_dir=QWEN_ENV, upgrade=True) + if rc != 0: + print(f"[WARNING] pip install -U failed (exit {rc}); update " + f"{QWEN_PIP_PKG} manually") + else: + print(f"[OK] {QWEN_PIP_PKG} is up to date (or just upgraded).") + return rc + + def models_screen(stdscr) -> int: """Per-model (un)install screen: the hub's Configure-qwen-tts leaf. |
