aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 22:27:51 -0400
committerhistoria <historiavg@proton.me>2026-08-26 22:27:51 -0400
commitf18f421d9180ae0e3bff9496b1fdaf53d3624a75 (patch)
tree6f89ccbda3844bb74ebe239dbf15ed3441b13204
parent477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52 (diff)
downloadtts-audiobook-generator-f18f421d9180ae0e3bff9496b1fdaf53d3624a75.tar.gz
feat: manage qwen-tts model installs manually, delete model(s) when uninstalled
-rw-r--r--app/backends/__init__.py15
-rw-r--r--app/backends/qwen.py270
-rw-r--r--app/docs/backend-qwen.md2
-rw-r--r--app/tests/test_backends.py405
-rw-r--r--app/tests/test_hub.py77
-rw-r--r--app/ui/hub.py43
6 files changed, 779 insertions, 33 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
index 192067c..e59c464 100644
--- a/app/backends/__init__.py
+++ b/app/backends/__init__.py
@@ -18,8 +18,10 @@ importing this package must stay cheap and dependency-free.
Adding a backend: create ``backends/<name>.py`` exposing
``detect() -> BackendStatus``, ``setup_screen(stdscr) -> int`` (the setup
wizard run on the hub's own screen) and ``uninstall() -> int``, then append
-a ``BackendInfo`` in ``_build_registry`` below. ``audiobook.py`` and the hub
-pick it up automatically. A backend's standalone CLI keeps its own
+a ``BackendInfo`` in ``_build_registry`` below. Optionally expose a
+``configure_screen(stdscr) -> int`` to give the hub's "Configure <label>"
+entry somewhere to go besides re-running the wizard. ``audiobook.py`` and
+the hub pick it up automatically. A backend's standalone CLI keeps its own
``run_tui()`` entry (its own curses session), which is not part of the
registry.
"""
@@ -158,12 +160,20 @@ 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).
+
+ CONFIGURE_SCREEN, when given, is what the hub's "Configure <label>"
+ menu entry runs instead of SETUP_SCREEN once the backend exists — a
+ place for install-time-independent management (qwen uses it for its
+ per-model weight installs). Without one, the hub falls back to
+ SETUP_SCREEN; a backend whose wizard asks nothing (bare qwen) offers
+ no Configure entry at all.
"""
key: str
label: str
detect: Callable[[], BackendStatus]
setup_screen: Callable[[object], int]
uninstall: Callable[..., int] = lambda *args, **kwargs: 0
+ configure_screen: Optional[Callable[[object], int]] = None
REGISTRY: List[BackendInfo] = []
@@ -189,6 +199,7 @@ def _build_registry() -> None:
detect=qwen.detect,
setup_screen=qwen.setup_screen,
uninstall=qwen.uninstall,
+ configure_screen=qwen.models_screen,
))
REGISTRY.append(BackendInfo(
key="faster",
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index c0c2cd1..3309c69 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -7,14 +7,24 @@ ONE Qwen3-TTS model per process — CustomVoice (built-in speakers), Base
end-to-end: pip-install the package into the managed venv. There are no
questions to ask — the port and which model to run live in
``app/converter/config.py`` (the model is chosen per run on the hub's
-Generate-audiobooks screen), and only one server runs at a time. It is driven
-by ``audiobook.py``'s hub but can also be run directly:
+Generate-audiobooks screen), and only one server runs at a time.
+
+Model weights are not part of the install: each demo lazily fetches its
+~4GB snapshot from HuggingFace into the standard hub cache the first time a
+server for it starts. This module tracks those three cache directories so
+weights can be pre-fetched ("Install") or deleted ("Uninstall") per model —
+via the hub's Configure-qwen-tts screen — and so the backend uninstaller can
+remove every downloaded weight alongside the package.
+
+It is driven by ``audiobook.py``'s hub but can also be run directly:
Usage:
python app/backends/qwen.py [--skip-install]
"""
import argparse
+import os
+import shutil
import sys
from pathlib import Path
from typing import List, Optional
@@ -33,7 +43,7 @@ from backends import (
)
from converter import config
from converter.clients import QWEN3_TTS_SPEAKERS
-from ui import taskview
+from ui import taskview, tui
QWEN_PIP_PKG = "qwen-tts"
DEFAULT_PORT = 7860
@@ -60,6 +70,96 @@ DEFAULT_MODEL = "CustomVoice"
QWEN_SPEAKERS = QWEN3_TTS_SPEAKERS
+# -- HuggingFace weight cache ------------------------------------------------
+#
+# The demo servers pull each model's snapshot into huggingface_hub's default
+# hub cache on first start (nothing in this project redirects it). These
+# helpers read and delete the same directories ``from_pretrained`` writes,
+# honoring the same environment overrides, so per-model (un)installs land
+# exactly where a server start would look.
+
+def _hf_cache_dir() -> Path:
+ """The HF hub cache dir servers fetch model weights into.
+
+ Resolution mirrors huggingface_hub.constants: HF_HUB_CACHE beats
+ HUGGINGFACE_HUB_CACHE beats HF_HOME/hub beats ~/.cache/huggingface/hub.
+ """
+ override = os.environ.get("HF_HUB_CACHE") or os.environ.get(
+ "HUGGINGFACE_HUB_CACHE")
+ if override:
+ return Path(override)
+ home = os.environ.get("HF_HOME")
+ if home:
+ return Path(home) / "hub"
+ return Path.home() / ".cache" / "huggingface" / "hub"
+
+
+def repo_dir(repo_id: str) -> Path:
+ """The cache directory HF keeps REPO_ID's weights in (models--Qwen--…)."""
+ return _hf_cache_dir() / ("models--" + repo_id.replace("/", "--"))
+
+
+def model_repo_dir(model: str) -> Path:
+ """The cached-weights directory for a MODEL_REPOS key."""
+ return repo_dir(MODEL_REPOS[model])
+
+
+def _tree_has_file(path: Path) -> bool:
+ """True when any file or symlink exists under PATH (recursively)."""
+ try:
+ for item in path.iterdir():
+ # Snapshot files are symlinks into blobs/; count them even when
+ # temporarily broken (presence is what the loader checks).
+ if item.is_symlink() or item.is_file():
+ return True
+ if item.is_dir() and _tree_has_file(item):
+ return True
+ except OSError:
+ return False
+ return False
+
+
+def model_installed(model: str) -> bool:
+ """True when MODEL's weights look complete in the local HF cache.
+
+ A fetched repo has refs/main plus at least one file under snapshots/;
+ anything less counts as not installed. An interrupted download simply
+ resumes — via Install, or the next server start for that model.
+ """
+ directory = model_repo_dir(model)
+ if not (directory / "refs" / "main").is_file():
+ return False
+ return _tree_has_file(directory / "snapshots")
+
+
+def installed_models() -> List[str]:
+ """The MODEL_REPOS keys whose weights are already on disk."""
+ return [name for name in MODEL_REPOS if model_installed(name)]
+
+
+def delete_model_weights(models: Optional[List[str]] = None) -> int:
+ """Delete the cached HF weight dirs of MODELS (every model by default).
+
+ Best-effort rmtree of each ``models--Qwen--…`` directory; returns how
+ many were present and removed. Only those directories are ever touched —
+ the rest of the HF cache may be shared with unrelated tools. Prints its
+ progress, which streams into the task view under curses too.
+ """
+ names = sorted(MODEL_REPOS) if models is None else list(models)
+ removed = 0
+ for name in names:
+ directory = model_repo_dir(name)
+ if not directory.is_dir():
+ continue
+ print(f"[INFO] Removing cached {MODEL_REPOS[name]} weights...")
+ shutil.rmtree(directory, ignore_errors=True)
+ removed += 1
+ if removed:
+ print(f"[OK] Deleted cached weights for {removed} "
+ f"{'model' if removed == 1 else 'models'}.")
+ return removed
+
+
def _is_installed() -> bool:
if envs.env_script("qwen-tts-demo").is_file():
return True
@@ -174,6 +274,30 @@ def build_parser() -> argparse.ArgumentParser:
return parser
+def _build_spec(model: str) -> ServerSpec:
+ """The single managed ServerSpec hosting MODEL on the configured port."""
+ url = config.QWEN_API_URL
+ return ServerSpec(
+ "qwen", url,
+ [str(envs.env_script("qwen-tts-demo")), MODEL_REPOS[model],
+ "--ip", "127.0.0.1",
+ "--port", str(_config_port(url, DEFAULT_PORT))],
+ identity=desired_identity(model))
+
+
+def _managed_running_model() -> Optional[str]:
+ """The model a locally-managed, up-and-running demo answers as.
+
+ None when no pid file exists (this tool never started that server), the
+ process is gone, or the probe cannot identify which model it hosts.
+ """
+ if servers.pid_for("qwen") is None:
+ return None
+ if not servers.alive("qwen"):
+ return None
+ return model_for_identity(probe.identify_server(config.QWEN_API_URL))
+
+
def detect() -> BackendStatus:
"""Detect whether qwen-tts is installed, plus the launch command.
@@ -192,13 +316,7 @@ def detect() -> BackendStatus:
details.append(f"port: {_config_port(url, DEFAULT_PORT)}")
details.append(f"model: {model}")
details.append(f"speaker: {config.SPEAKER}")
- demo = str(envs.env_script("qwen-tts-demo"))
- specs = [
- ServerSpec("qwen", url,
- [demo, MODEL_REPOS[model], "--ip", "127.0.0.1",
- "--port", str(_config_port(url, DEFAULT_PORT))],
- identity=desired_identity(model)),
- ]
+ specs = [_build_spec(model)]
managed = servers.manages(specs)
# A locally-managed server names its running model via the probe of the
# managed URL; a remotely-run demo names it via the remote-URL probe.
@@ -244,20 +362,72 @@ def _detect_remote(managed: bool = False):
return remote_models, remote_urls
+def _hf_download_prefix() -> Optional[List[str]]:
+ """The venv's hf CLI argv prefix (None when neither script is present)."""
+ for name in ("hf", "huggingface-cli"):
+ candidate = envs.env_script(name)
+ if candidate.is_file():
+ return [str(candidate)]
+ return None
+
+
+def install_model(model: str, *, emit=None, cancel=None) -> int:
+ """Pre-download MODEL's weights into the HF cache via the venv's hf CLI.
+
+ Exactly what the first server start does implicitly, made explicit:
+ streamed progress through EMIT (percent bars feed the task view),
+ CANCEL kills the download process group mid-flight, and re-running
+ resumes where a previous attempt left off. Returns the exit code.
+ """
+ prefix = _hf_download_prefix()
+ if prefix is None:
+ print("[ERROR] No hf CLI found in the managed venv; pip install "
+ f"{QWEN_PIP_PKG} first")
+ return 1
+ repo = MODEL_REPOS[model]
+ print(f"[INFO] Downloading {repo} into {_hf_cache_dir()}...")
+ rc = common.run_console_subprocess(prefix + ["download", repo],
+ emit=emit, cancel=cancel)
+ if rc == 0:
+ print(f"[OK] {repo} downloaded.")
+ return rc
+
+
+def uninstall_model(model: str, *, emit=None, cancel=None) -> int:
+ """Remove MODEL's cached weights (the inverse of install_model).
+
+ A locally-managed server that currently answers as MODEL is stopped
+ first (best-effort) so its weights are not deleted under a live
+ process; no server, a down one, or one serving another model leaves
+ everything else untouched. CANCEL is honored after that stop phase
+ only. Returns the exit code.
+ """
+ if _managed_running_model() == model:
+ servers.stop("qwen")
+ if common.cancel_requested(cancel):
+ return 130
+ delete_model_weights([model])
+ return 0
+
+
def uninstall(*, emit=None, cancel=None) -> int:
- """Remove the qwen-tts backend entirely: stop its server, pip uninstall.
+ """Remove the qwen-tts backend entirely: stop its server, pip uninstall,
+ then delete every downloaded model.
qwen-tts is a pip package (``qwen_tts`` + the ``qwen-tts-demo`` script)
installed into the managed venv, so uninstalling it removes the backend.
- Any server this tool started is stopped first (best-effort). Model
- weights already fetched into the HuggingFace cache stay on disk.
+ Any server this tool started is stopped first (best-effort). The three
+ models' weight snapshots — multi-GB directories lazily fetched into the
+ HuggingFace cache (~/.cache/huggingface/hub) — are deleted too, matching
+ the hub's confirmation dialog; only those three directories are removed,
+ never the shared cache itself.
With EMIT given (the in-TUI task view) pip runs piped, streaming into
EMIT, so its output never touches the terminal behind curses. CANCEL is
- a ``threading.Event`` honored between phases only (after the server has
- been stopped, before pip starts) — a started phase always completes,
- so pip is never killed mid-run. Returns the exit code (130 when
- cancelled before pip ran).
+ a ``threading.Event`` honored between phases only (stop servers / pip /
+ delete models) — a started phase always completes, so pip is never
+ killed mid-run. Returns the exit code (130 when cancelled before a
+ remaining phase).
"""
if servers.pid_for("qwen") is not None:
# Only stop when a pid file exists: without one this tool never
@@ -272,9 +442,75 @@ def uninstall(*, emit=None, cancel=None) -> int:
f"{QWEN_PIP_PKG} from the managed venv manually")
else:
print(f"[OK] {QWEN_PIP_PKG} removed.")
+ # Weights go even when the pip step failed: the package can be
+ # re-installed any time, multi-GB snapshots are what actually cost disk.
+ if common.cancel_requested(cancel):
+ return 130
+ delete_model_weights()
return rc
+def models_screen(stdscr) -> int:
+ """Per-model (un)install screen: the hub's Configure-qwen-tts leaf.
+
+ Each of the three models gets exactly one action reflecting disk state:
+ Install pre-downloads its weights via the hf CLI (a streamed, resumable,
+ cancelable task-view run) and Uninstall deletes them again (stopping a
+ managed server that answers as that model first). Install requires the
+ pip package; without one a guidance flash replaces the download, since
+ pre-fetched weights without a backend to serve them buy nothing. The
+ status table and options re-render after every action, so Esc pops back
+ to Configure backends. Always returns 0.
+ """
+ while True:
+ present = installed_models()
+ rows = [(name, "installed", "ok") if name in present
+ else ("not installed", "warn") for name in MODEL_REPOS]
+ options = []
+ for name in MODEL_REPOS:
+ if name in present:
+ options.append((f"Uninstall {name}", ("uninstall", name)))
+ elif _is_installed():
+ options.append((f"Install {name}", ("install", name)))
+ else:
+ options.append((f"{name} (backend not installed)",
+ ("noop", name)))
+
+ def make_work(action: str, target: str):
+ def work(emit, cancel) -> int:
+ if action == "install":
+ return install_model(target, emit=emit, cancel=cancel)
+ return uninstall_model(target, emit=emit, cancel=cancel)
+ return work
+
+ choice = tui.menu(
+ stdscr, "Configure qwen-tts", options,
+ back_value=tui.Wizard.BACK,
+ help_lines=["Models are normally pulled when their server first",
+ "starts; Install pre-downloads one right now."],
+ table_title="Model state", table_rows=rows)
+ if choice is tui.Wizard.BACK:
+ return 0
+ action, name = choice
+ if action == "noop":
+ continue
+ if action == "install" and not _is_installed():
+ tui.flash(stdscr, "Install the qwen-tts backend first "
+ "(Configure backends > Install Backend).", "warn")
+ continue
+ title = (f"Download {MODEL_REPOS[name]}" if action == "install"
+ else f"Delete {name} weights")
+ step = taskview.TaskStep(title, make_work(action, name))
+ rc = taskview.run_steps(stdscr, title, [step], wait_on_finish=False)
+ if rc == 0:
+ tui.flash(stdscr,
+ f"{name} downloaded." if action == "install"
+ else f"{name} weights removed.", "ok")
+ else:
+ verb = "download" if action == "install" else "remove"
+ tui.flash(stdscr, f"Could not {verb} {name}.", "err")
+
+
def main() -> int:
parser = build_parser()
args = parser.parse_args()
diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md
index 5cfb0b3..3fd55ca 100644
--- a/app/docs/backend-qwen.md
+++ b/app/docs/backend-qwen.md
@@ -4,7 +4,7 @@ The easiest way is to run `python audiobook.py` → **Configure backends… →
If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port (its `GET /info` names which of the three demos answers), so a manually-installed backend works once its server is up. To use a demo server on another machine, set `QWEN_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:7860`) — the hub probes it and offers the matching `qwen-tts [remote]` mode limited to the model that server hosts — or pass `--api-url` on the CLI.
-Model weights download automatically from HuggingFace into the standard cache (`~/.cache/huggingface/hub`) the first time a server for each model starts — there is nothing else to install per model.
+Model weights download automatically from HuggingFace into the standard cache (`~/.cache/huggingface/hub`) the first time a server for each model starts — there is nothing else to install per model. To pre-fetch or remove a single model's weights without starting its server, open **Configure backends… → Configure qwen-tts**: each of Base / CustomVoice / VoiceDesign gets an Install (a streamed, resumable download — canceling one just means it resumes later) or Uninstall action, with a server hosting that model stopped first. Uninstalling the whole backend deletes all three of those directories along with the pip package; only they are ever touched — anything else in your HuggingFace cache is left alone.
Install qwen-tts with pip into your environment:
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index 5cf5633..1e1a3f9 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -15,6 +15,7 @@ from backends import (
get,
invalidate_detect_cache,
)
+from ui import tui
class FormatLaunchHintTests(unittest.TestCase):
@@ -50,6 +51,10 @@ class RegistryTests(unittest.TestCase):
self.assertIs(get("audiocpp").key, "audiocpp")
self.assertIsNone(get("nonexistent"))
+ def test_qwen_carries_the_per_model_configure_screen(self):
+ from backends import qwen
+ self.assertIs(get("qwen").configure_screen, qwen.models_screen)
+
class DetectAllTests(unittest.TestCase):
def test_detect_all_returns_one_status_per_backend(self):
@@ -313,6 +318,406 @@ class RemoteSuppressionTests(unittest.TestCase):
self.assertEqual(status.remote_urls, {})
+class QwenModelCacheTests(unittest.TestCase):
+ """HF-cache awareness for the three demo repos (see backends.qwen)."""
+
+ def test_repo_dirs_map_to_hf_cache_names(self):
+ from backends import qwen
+ with tempfile.TemporaryDirectory() as td, \
+ patch.dict("os.environ", {"HF_HUB_CACHE": td}):
+ self.assertEqual(
+ qwen.model_repo_dir("CustomVoice"),
+ Path(td) / "models--Qwen--Qwen3-TTS-12Hz-1.7B-CustomVoice")
+ self.assertEqual(
+ qwen.model_repo_dir("Base"),
+ Path(td) / "models--Qwen--Qwen3-TTS-12Hz-1.7B-Base")
+ self.assertEqual(
+ qwen.model_repo_dir("VoiceDesign"),
+ Path(td) / "models--Qwen--Qwen3-TTS-12Hz-1.7B-VoiceDesign")
+
+ def test_cache_dir_resolution_matches_huggingface_hub_precedence(self):
+ # HF_HUB_CACHE > HUGGINGFACE_HUB_CACHE > HF_HOME/hub > default.
+ # Listed vars are blanked so ambient env can't leak in.
+ from backends import qwen
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ with patch.dict("os.environ", {"HF_HUB_CACHE": str(base / "a"),
+ "HUGGINGFACE_HUB_CACHE": "",
+ "HF_HOME": ""}):
+ self.assertEqual(qwen._hf_cache_dir(), base / "a")
+ with patch.dict("os.environ", {"HF_HUB_CACHE": "",
+ "HUGGINGFACE_HUB_CACHE":
+ str(base / "b"),
+ "HF_HOME": ""}):
+ self.assertEqual(qwen._hf_cache_dir(), base / "b")
+ with patch.dict("os.environ", {"HF_HUB_CACHE": "",
+ "HUGGINGFACE_HUB_CACHE": "",
+ "HF_HOME": str(base / "c")}):
+ self.assertEqual(qwen._hf_cache_dir(), base / "c" / "hub")
+ with patch.dict("os.environ", {"HF_HUB_CACHE": "",
+ "HUGGINGFACE_HUB_CACHE": "",
+ "HF_HOME": ""}):
+ self.assertEqual(qwen._hf_cache_dir(),
+ Path.home() / ".cache" / "huggingface"
+ / "hub")
+
+ def _seed_model(self, cache: Path, repo_id: str) -> Path:
+ """A fully-fetched-looking repo dir: refs/main + a snapshot file."""
+ d = cache / ("models--" + repo_id.replace("/", "--"))
+ (d / "snapshots" / "abc123").mkdir(parents=True)
+ (d / "refs").mkdir()
+ (d / "refs" / "main").write_text("abc123\n", encoding="utf-8")
+ (d / "snapshots" / "abc123" / "config.json").write_bytes(b"x")
+ return d
+
+ def test_installed_requires_refs_and_a_snapshot_file(self):
+ from backends import qwen
+ repo = qwen.MODEL_REPOS["CustomVoice"]
+ with tempfile.TemporaryDirectory() as td:
+ with patch.dict("os.environ", {"HF_HUB_CACHE": td}):
+ self.assertFalse(qwen.model_installed("CustomVoice"))
+ self.assertEqual(qwen.installed_models(), [])
+ self._seed_model(Path(td), repo)
+ self.assertTrue(qwen.model_installed("CustomVoice"))
+ self.assertEqual(qwen.installed_models(), ["CustomVoice"])
+
+ def test_partial_download_counts_as_not_installed(self):
+ # An interrupted fetch leaves blobs/ behind but no refs/main yet;
+ # resuming (Install or the next server start) takes over cleanly.
+ from backends import qwen
+ d = None
+ with tempfile.TemporaryDirectory() as td:
+ d = Path(td)
+ with patch.dict("os.environ", {"HF_HUB_CACHE": td}):
+ blob = (d / "models--Qwen--Qwen3-TTS-12Hz-1.7B-Base"
+ / "blobs")
+ blob.mkdir(parents=True)
+ (blob / "half.bin").write_bytes(b"x")
+ self.assertFalse(qwen.model_installed("Base"))
+
+
+class QwenUninstallWeightsTests(unittest.TestCase):
+ """qwen.uninstall now also deletes every downloaded HF weight dir."""
+
+ def _seed_all(self, cache: Path):
+ from backends import qwen
+ for repo_id in qwen.MODEL_REPOS.values():
+ d = cache / ("models--" + repo_id.replace("/", "--"))
+ (d / "snapshots" / "abc123").mkdir(parents=True)
+ (d / "snapshots" / "abc123"
+ / "model.safetensors").write_bytes(b"x")
+ (d / "refs").mkdir()
+ (d / "refs" / "main").write_text("abc123", encoding="utf-8")
+
+ def test_uninstall_deletes_every_cached_model(self):
+ from backends import qwen
+ with tempfile.TemporaryDirectory() as td:
+ self._seed_all(Path(td))
+ with patch.dict("os.environ", {"HF_HUB_CACHE": td}), \
+ patch.object(qwen.servers, "pid_for",
+ return_value=None), \
+ patch.object(qwen.common, "pip_uninstall",
+ return_value=0) as mk_pip:
+ rc = qwen.uninstall(emit="EMIT")
+ self.assertEqual(rc, 0)
+ mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT")
+ self.assertEqual(list(Path(td).iterdir()), [])
+
+ def test_weights_deleted_even_when_pip_failed(self):
+ # The package is trivially re-installable; multi-GB snapshots are
+ # what actually cost disk. Deleting them is not conditional on pip.
+ from backends import qwen
+ with tempfile.TemporaryDirectory() as td:
+ self._seed_all(Path(td))
+ with patch.dict("os.environ", {"HF_HUB_CACHE": td}), \
+ patch.object(qwen.servers, "pid_for",
+ return_value=None), \
+ patch.object(qwen.common, "pip_uninstall",
+ return_value=1):
+ rc = qwen.uninstall()
+ self.assertEqual(rc, 1)
+ self.assertEqual(list(Path(td).iterdir()), [])
+
+ def test_cancel_after_pip_skips_weight_deletion(self):
+ import threading
+
+ from backends import qwen
+ # Cancel fires mid-pip (the only moment the user can): everything
+ # through pip completes, but the weight-deletion phase never starts.
+ cancel = threading.Event()
+
+ def pip_flips_cancel(*args, **kwargs):
+ cancel.set()
+ return 0
+
+ with tempfile.TemporaryDirectory() as td:
+ self._seed_all(Path(td))
+ with patch.dict("os.environ", {"HF_HUB_CACHE": td}), \
+ patch.object(qwen.servers, "pid_for",
+ return_value=None), \
+ patch.object(qwen.common, "pip_uninstall",
+ side_effect=pip_flips_cancel) as mk_pip:
+ rc = qwen.uninstall(cancel=cancel)
+ self.assertEqual(rc, 130)
+ mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit=None)
+ # Cancelled between phases: the weights stay untouched...
+ self.assertEqual(len(list(Path(td).iterdir())), 3)
+
+ def test_only_qwen_repos_are_touched_in_the_shared_cache(self):
+ from backends import qwen
+ with tempfile.TemporaryDirectory() as td:
+ self._seed_all(Path(td))
+ other = Path(td) / "models--Other--Repo"
+ other.mkdir()
+ (other / "weights.bin").write_bytes(b"x")
+ with patch.dict("os.environ", {"HF_HUB_CACHE": td}), \
+ patch.object(qwen.servers, "pid_for",
+ return_value=None), \
+ patch.object(qwen.common, "pip_uninstall",
+ return_value=0):
+ qwen.uninstall()
+ self.assertTrue(other.is_dir())
+
+
+class QwenUninstallModelTests(unittest.TestCase):
+ """Per-model uninstall: stop only a server serving THAT model."""
+
+ def test_stops_managed_server_only_when_it_serves_that_model(self):
+ from backends import qwen
+ cases = [("Base", True), ("CustomVoice", False), ("VoiceDesign",
+ False)]
+ for model, should_stop in cases:
+ with self.subTest(model=model):
+ with patch.object(qwen, "_managed_running_model",
+ return_value="Base"), \
+ patch.object(qwen.servers, "stop") as mk_stop, \
+ patch.object(qwen, "delete_model_weights") as mk_del:
+ rc = qwen.uninstall_model(model)
+ self.assertEqual((rc, mk_stop.called),
+ (0, should_stop))
+ mk_del.assert_called_once_with([model])
+
+ # No managed server at all: nothing to stop either.
+ with patch.object(qwen, "_managed_running_model",
+ return_value=None), \
+ patch.object(qwen.servers, "stop") as mk_stop:
+ qwen.uninstall_model("Base")
+ mk_stop.assert_not_called()
+
+ def test_removes_only_that_models_cache_dir(self):
+ from backends import qwen
+ with tempfile.TemporaryDirectory() as td, \
+ patch.dict("os.environ", {"HF_HUB_CACHE": td}), \
+ patch.object(qwen, "_managed_running_model",
+ return_value=None):
+ kept = qwen.repo_dir(qwen.MODEL_REPOS["CustomVoice"])
+ kept.mkdir(parents=True)
+ gone = qwen.repo_dir(qwen.MODEL_REPOS["VoiceDesign"])
+ gone.mkdir(parents=True)
+ rc = qwen.uninstall_model("VoiceDesign")
+ self.assertEqual(rc, 0)
+ # Only the named model's directory is gone; every other cache
+ # entry (unrelated repos included) survives.
+ self.assertFalse(gone.exists())
+ self.assertTrue(kept.is_dir())
+
+ def test_missing_weights_still_succeed(self):
+ # Idempotent removal, like apt purge on an already-clean system.
+ from backends import qwen
+ with patch.object(qwen, "_managed_running_model",
+ return_value=None):
+ self.assertEqual(qwen.uninstall_model("Base"), 0)
+
+ def test_cancel_after_stop_skips_deletion(self):
+ import threading
+
+ from backends import qwen
+ cancel = threading.Event()
+ cancel.set()
+ with patch.object(qwen, "_managed_running_model",
+ return_value="Base"), \
+ patch.object(qwen.servers, "stop") as mk_stop, \
+ patch.object(qwen, "delete_model_weights"):
+ rc = qwen.uninstall_model("Base", cancel=cancel)
+ self.assertEqual(rc, 130)
+ mk_stop.assert_called_once_with("qwen")
+
+
+class QwenInstallModelTests(unittest.TestCase):
+ """install_model: venv hf CLI download of exactly one repo's weights."""
+
+ def test_hf_cli_prefers_hf_then_falls_back(self):
+ from backends import qwen
+ with tempfile.TemporaryDirectory() as td:
+ with patch.object(qwen.envs, "ENV_DIR", Path(td)):
+ self.assertIsNone(qwen._hf_download_prefix())
+ cli = qwen.envs.env_script("huggingface-cli")
+ cli.parent.mkdir(parents=True)
+ cli.write_bytes(b"x")
+ self.assertEqual(qwen._hf_download_prefix(), [str(cli)])
+ hf = qwen.envs.env_script("hf")
+ hf.write_bytes(b"x")
+ self.assertEqual(qwen._hf_download_prefix(), [str(hf)])
+
+ def test_download_runs_through_console_streaming(self):
+ from backends import qwen
+ with patch.object(qwen, "_hf_download_prefix",
+ return_value=["/venv/bin/hf"]), \
+ patch.object(qwen.common, "run_console_subprocess",
+ return_value=0) as mk_run:
+ rc = qwen.install_model("VoiceDesign", emit="EMIT",
+ cancel="CANCEL")
+ self.assertEqual(rc, 0)
+ mk_run.assert_called_once_with(
+ ["/venv/bin/hf", "download",
+ "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"],
+ emit="EMIT", cancel="CANCEL")
+
+ def test_no_cli_is_a_failure_not_an_exception(self):
+ from backends import qwen
+ with patch.object(qwen, "_hf_download_prefix", return_value=None), \
+ patch.object(qwen.common, "run_console_subprocess"):
+ rc = qwen.install_model("Base")
+ self.assertEqual(rc, 1)
+
+
+class QwenModelsScreenTests(unittest.TestCase):
+ """models_screen: per-model menu driving task-view steps."""
+
+ def _screen(self, answers, *, installed=("CustomVoice",),
+ package=True, extra=()):
+ """Run models_screen with scripted menu answers; record calls.
+
+ Returns ``(rc, menus, flashes, runs)``: menus holds one
+ (title, options, kwargs) per render, flashes every (text, kind),
+ and runs each task-view run's title while executing its first
+ step's work inline (so delegation to install/uninstall model
+ functions is observable). EXTRA holds additional patch context
+ managers entered around the whole run.
+ """
+ import contextlib
+
+ from backends import qwen
+ choices = list(answers)
+ menus = []
+ flashes = []
+ runs = []
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ menus.append((title, options, kwargs))
+ return choices.pop(0)
+
+ def fake_flash(scr, text, kind="warn"):
+ flashes.append((text, kind))
+
+ def fake_run(scr, title, steps, **kwargs):
+ runs.append(title)
+ steps[0].work(None, None)
+ return 0
+
+ patches = [
+ patch.object(qwen, "_is_installed", return_value=package),
+ patch.object(qwen, "installed_models",
+ return_value=list(installed)),
+ patch.object(qwen.tui, "menu", fake_menu),
+ patch.object(qwen.tui, "flash", fake_flash),
+ patch.object(qwen.taskview, "run_steps", fake_run),
+ *extra,
+ ]
+ with contextlib.ExitStack() as stack:
+ for ctx in patches:
+ stack.enter_context(ctx)
+ rc = qwen.models_screen(None)
+ return rc, menus, flashes, runs
+
+ def test_options_reflect_disk_state_per_model(self):
+ rc, menus, _, _ = self._screen([tui.Wizard.BACK],
+ installed=("CustomVoice",))
+ self.assertEqual(rc, 0)
+ title, options, kwargs = menus[0]
+ self.assertEqual(title, "Configure qwen-tts")
+ # One action per model, mirroring disk state; order follows
+ # MODEL_REPOS. The table repeats the state in color.
+ self.assertEqual(options, [
+ ("Uninstall CustomVoice", ("uninstall", "CustomVoice")),
+ ("Install Base", ("install", "Base")),
+ ("Install VoiceDesign", ("install", "VoiceDesign")),
+ ])
+ self.assertEqual(kwargs["table_title"], "Model state")
+ self.assertEqual(kwargs["table_rows"][0],
+ ("CustomVoice", "installed", "ok"))
+
+ def test_install_action_runs_a_download_step_in_the_task_view(self):
+ from backends import qwen
+ requested = []
+
+ def capture(model, *, emit=None, cancel=None):
+ requested.append(model)
+ return 0
+
+ rc, _, flashes, runs = self._screen(
+ [("install", "Base"), tui.Wizard.BACK], installed=(),
+ extra=[patch.object(qwen, "install_model",
+ side_effect=capture)])
+ self.assertEqual(rc, 0)
+ self.assertEqual(requested, ["Base"])
+ self.assertEqual(len(runs), 1)
+ self.assertEqual(runs[0], "Download Qwen/Qwen3-TTS-12Hz-1.7B-Base")
+ self.assertEqual(flashes[-1], ("Base downloaded.", "ok"))
+
+ def test_uninstall_action_stops_the_server_then_deletes_weights(self):
+ from backends import qwen
+ stopped = []
+ with tempfile.TemporaryDirectory() as td:
+ gone = Path(td) / "models--Qwen--Qwen3-TTS-12Hz-1.7B-CustomVoice"
+ gone.mkdir(parents=True)
+ other = Path(td) / "models--Other--Repo"
+ other.mkdir(parents=True)
+ rc, _, flashes, _ = self._screen(
+ [("uninstall", "CustomVoice"), tui.Wizard.BACK],
+ extra=[
+ patch.dict("os.environ", {"HF_HUB_CACHE": td}),
+ patch.object(qwen, "_managed_running_model",
+ return_value="CustomVoice"),
+ patch.object(qwen.servers, "stop",
+ side_effect=lambda name:
+ stopped.append(name)),
+ # delete_model_weights stays real: it must rm the exact
+ # directory below (inside a redirected HF_HUB_CACHE).
+ ])
+ self.assertEqual(rc, 0)
+ self.assertEqual(stopped, ["qwen"])
+ self.assertFalse(gone.exists())
+ self.assertTrue(other.is_dir())
+ self.assertEqual(flashes[-1],
+ ("CustomVoice weights removed.", "ok"))
+
+ def test_install_without_package_flashes_guidance_instead(self):
+ rc, menus, flashes, runs = self._screen(
+ [("install", "Base"), tui.Wizard.BACK], installed=(),
+ package=False)
+ self.assertEqual(rc, 0)
+ # No task-view run, one guidance flash instead of a download.
+ self.assertEqual(runs, [])
+ guidance = ("Install the qwen-tts backend first "
+ "(Configure backends > Install Backend).")
+ self.assertEqual(flashes, [(guidance, "warn")])
+ # While the backend is missing, Install is replaced by dimmed-out
+ # "(backend not installed)" placeholders — actions stay inert.
+ self.assertEqual([label for label, _ in menus[0][1]],
+ ["CustomVoice (backend not installed)",
+ "Base (backend not installed)",
+ "VoiceDesign (backend not installed)"])
+
+ def test_noop_placeholder_actions_change_nothing(self):
+ rc, _, flashes, runs = self._screen(
+ [("noop", "Base"), tui.Wizard.BACK], installed=(),
+ package=False)
+ self.assertEqual(rc, 0)
+ self.assertEqual(runs, [])
+ self.assertEqual(flashes, [])
+
+
class DetectCacheTests(unittest.TestCase):
"""detect_all's short-TTL cache (menu renders re-probe only after it)."""
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 44c63fd..439be26 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -520,6 +520,83 @@ class SubmenuStatusTableTests(unittest.TestCase):
self.assertEqual(self._labels(captured["options"]),
["Configure audio.cpp", "Uninstall Backend"])
+ def test_configure_menu_offers_qwens_per_model_manager(self):
+ # qwen ships a dedicated configure screen (per-model weight
+ # installs): once installed, "Configure qwen-tts" appears in the
+ # flat action list.
+ captured = {}
+ infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0,
+ uninstall=lambda **kwargs: 0,
+ configure_screen=lambda scr: 0)]
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True)]
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub, "detect_all", return_value=statuses), \
+ patch.object(hub.tui, "menu",
+ self._capture_menu(captured)), \
+ patch.object(hub.shutil, "which",
+ return_value="/usr/bin/ffmpeg"):
+ result = hub._Hub(None).screen_configure()
+ self.assertIs(result, tui.Wizard.BACK)
+ self.assertEqual(self._labels(captured["options"]),
+ ["Configure qwen-tts", "Uninstall Backend"])
+ self.assertEqual(captured["table_rows"],
+ [("qwen-tts", "installed", "warn", "body")])
+
+ def test_selecting_qwen_runs_its_configure_screen_not_setup(self):
+ ran = []
+ invalidated = []
+
+ def configure(scr):
+ ran.append("configure")
+ return 0
+
+ def setup(scr):
+ ran.append("setup")
+ return 0
+
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, setup,
+ uninstall=lambda **kwargs: 0,
+ configure_screen=configure)
+
+ def first_pick(stdscr, title, options, **kwargs):
+ return ("configure", "qwen")
+
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True)]
+ with patch.object(hub, "REGISTRY", [info]), \
+ patch.object(hub, "get", return_value=info), \
+ patch.object(hub, "detect_all", return_value=statuses), \
+ patch.object(hub.tui, "menu", first_pick), \
+ patch.object(hub, "invalidate_detect_cache",
+ side_effect=lambda: invalidated.append(True)), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ leaf = hub._Hub(None).screen_configure()
+ # The selection pushes the backend's configure screen as one
+ # leaf of the wizard stack (which invalidates the status
+ # cache when it finishes).
+ self.assertIs(leaf(), tui.Wizard.BACK)
+ self.assertEqual(ran, ["configure"])
+ self.assertEqual(invalidated, [True])
+
+ def test_bare_qwen_without_configure_screen_still_has_no_entry(self):
+ # Without a dedicated configure screen, plain qwen stays excluded
+ # from Configure backends (its wizard asks nothing to configure).
+ captured = {}
+ infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0,
+ uninstall=lambda **kwargs: 0)]
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True)]
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub, "detect_all", return_value=statuses), \
+ patch.object(hub.tui, "menu",
+ self._capture_menu(captured)), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ result = hub._Hub(None).screen_configure()
+ self.assertIs(result, tui.Wizard.BACK)
+ self.assertEqual(self._labels(captured["options"]),
+ ["Uninstall Backend"])
+
def test_convert_menu_builds_one_form_with_backend_field(self):
captured = {}
st = BackendStatus("qwen", "qwen-tts", installed=True,
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 10484d2..230def3 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -150,10 +150,10 @@ class _Hub:
no binary) or download its missing models (only once built, so
build > configure > download — Build and Download never appear
together) — heads the menu with a yellow ``[recommended]`` tag,
- separated from the rest by a blank line. remaining actions are populated from the detected statuses:
- configure each configurable backend (qwen asks nothing to
- configure, so it has no entry), install (backends with nothing on
- disk), and uninstall.
+ separated from the rest by a blank line. The remaining actions are
+ populated from the detected statuses: configure each configurable
+ backend (qwen offers its per-model weight (un)installer there),
+ install (backends with nothing on disk), and uninstall.
Selecting one pushes the next screen; Esc pops back to the main
menu. The Build action downloads any missing models alongside the
build (a split view), so it heals a configured-but-unbuilt backend
@@ -232,18 +232,33 @@ class _Hub:
return self.screen_setup(info)
def screen_setup(self, info):
- """Run one backend's setup wizard as a leaf screen of the stack.
+ """Run one backend's setup/configure wizard as a leaf screen.
The wizard drives its own internal ``tui.Wizard`` on this screen;
Esc on its first screen (or Ctrl-C) returns here and the hub pops
back to the menu that launched it. A crash flashes and does the same.
"""
def screen():
- self._run_setup(info)
+ # A dedicated configure screen (qwen's per-model manager) takes
+ # precedence over the plain setup wizard here; Install Backend
+ # keeps running the setup wizard either way.
+ if info.configure_screen is not None:
+ self._run_configure(info)
+ else:
+ self._run_setup(info)
invalidate_detect_cache()
return tui.Wizard.BACK
return screen
+ def _run_configure(self, info) -> None:
+ """Run one backend's dedicated configure screen on this session."""
+ try:
+ info.configure_screen(self.stdscr)
+ except tui.WizardCancelled:
+ pass
+ except Exception as exc: # noqa: BLE001 - keep the hub alive
+ tui.flash(self.stdscr, str(exc), "err")
+
def _run_setup(self, info) -> None:
"""Run one backend's setup wizard on this session (no stack frame)."""
try:
@@ -586,14 +601,16 @@ def _server_action_step(spec, action: str):
def _configurable(info) -> bool:
- """True when INFO has a setup wizard worth running to reconfigure.
-
- qwen-tts is excluded: its wizard asks no questions (ports live in the
- Settings screen, the speaker is chosen per run on Generate audiobooks),
- so a "Configure" entry could only ever flash "already installed".
- Installing it stays possible via Install Backend.
+ """True when INFO has a configure screen worth running from the hub.
+
+ A backend with a dedicated ``configure_screen`` (qwen manages its three
+ model weight installs there) is always configurable; other backends
+ count via their non-trivial setup wizard. Bare qwen — whose wizard asks
+ no questions: ports live in Settings, the speaker is chosen per run on
+ Generate audiobooks — would only ever flash "already installed", so it
+ stays excluded until it ships a dedicated screen.
"""
- return info.key != "qwen"
+ return info.configure_screen is not None or info.key != "qwen"
def _installable(info, by_key: dict) -> bool: