From ecd5839a138d36b264846f4b81541465df4a7cb5 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 12 Aug 2026 23:42:36 -0700 Subject: [PATCH] feat(settings): delete unused downloaded speech models (#671) * feat(settings): delete unused downloaded speech models Add a Remove Model dropdown on the Speech Model page so whisper.cpp, Whisper, and VOSK downloads can be deleted from Settings. The currently selected model is marked in use and cannot be removed until you switch away from it. Fixes #650 * fix(settings): move unused model deletion below the picker Unused downloads is its own group under the model info card. Only leftover files appear, each with a Delete button. The group hides when empty. * fix(settings): collapse unused downloads into an expander Keep leftover models behind one row so a long list does not stretch the Speech Model page. Expand to delete files one at a time; the list scrolls once it grows past a few entries. * fix(settings): keep leftover model list from clipping in the expander Wrap the unused-downloads list so the expander can show a few rows and scroll the rest, instead of painting only the first leftover. --- README.md | 4 +- docs/USER_GUIDE.md | 9 + src/vocalinux/ui/settings_dialog.py | 285 ++++++++++++++++++- src/vocalinux/utils/paths.py | 19 ++ src/vocalinux/utils/vosk_model_info.py | 109 +++++++ src/vocalinux/utils/whispercpp_model_info.py | 33 ++- tests/test_model_deletion.py | 114 ++++++++ tests/test_paths.py | 13 + tests/test_settings_dialog.py | 28 ++ 9 files changed, 611 insertions(+), 3 deletions(-) create mode 100644 tests/test_model_deletion.py diff --git a/README.md b/README.md index 3366852..bb837b6 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,9 @@ Configuration is stored in `~/.config/vocalinux/config.json`: For whisper.cpp, `model_size` may be a size such as `tiny` or an exact ggml model ID such as `medium.en-q5_0` or `large-v3-turbo`. You can also configure this through the graphical Settings dialog, where whisper.cpp models are split into **Model Size** -and **Specialization** controls. +and **Specialization** controls. Unused leftover downloads can be deleted from +**Unused downloads** on the Speech Model page (expand the section, then delete +one model at a time). ### Neural Voice Activity Detection diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c49bf08..8b8653a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -136,6 +136,15 @@ Vocalinux now offers **three speech recognition engines**: English-only whisper.cpp specializations limit the language selector to English. +### Removing unused models + +Open Settings and go to **Speech Model**. If leftover files are on disk that are +not the model currently selected, **Unused downloads** appears under the model +info card. Expand it to delete leftovers one at a time. Confirming removes those +files from `~/.local/share/vocalinux`. Vocalinux will download a model again if +you select it later. Packaged system-wide VOSK models are left alone. The section +is hidden when there is nothing unused to delete. + ### When to Use Each Model **For real-time dictation:** Use **tiny** or **base** - they're fast enough to keep up with your speech. diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index e2bbb30..2dd92ea 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -43,10 +43,19 @@ from ..utils.update_checker import ( # noqa: E402 is_update_available, normalize_channel, ) -from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402 +from ..utils.vosk_model_info import ( # noqa: E402 + SUPPORTED_LANGUAGES, + VOSK_MODEL_INFO, + delete_vosk_model, + list_downloaded_vosk_models, + vosk_model_dirname, +) from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES from ..utils.whispercpp_model_info import ( WHISPERCPP_MODEL_INFO, +) +from ..utils.whispercpp_model_info import delete_model as delete_whispercpp_model +from ..utils.whispercpp_model_info import ( detect_compute_backend, detect_vulkan_devices, get_backend_display_name, @@ -56,6 +65,9 @@ from ..utils.whispercpp_model_info import get_model_variants as get_whispercpp_m from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model from ..utils.whispercpp_model_info import is_english_only_model as is_english_only_whispercpp_model from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded +from ..utils.whispercpp_model_info import ( + list_downloaded_models as list_downloaded_whispercpp_models, +) from ..version import __copyright__, __description__, __url__, __version__ # noqa: E402 from .config_manager import DEFAULT_CONFIG # noqa: E402 from .keyboard_backends import ( # noqa: E402 @@ -628,6 +640,15 @@ spinbutton { font-size: 1.0em; } +/* Unused downloads: one collapsed row until expanded */ +.unused-downloads-expander { + padding: 8px 12px; +} + +.unused-downloads-expander list { + background-color: transparent; +} + .model-info-subtitle { font-size: 0.9em; color: @theme_unfocused_fg_color; @@ -748,6 +769,36 @@ def _get_whisper_cache_dir() -> str: return os.path.join(MODELS_DIR, "whisper") +def _whisper_model_files(model_name: str) -> list[str]: + """Return existing OpenAI Whisper weight files for a catalog model name.""" + if model_name not in WHISPER_MODEL_INFO: + return [] + + filename = f"{model_name}.pt" + candidates = [ + os.path.join(_get_whisper_cache_dir(), filename), + os.path.join(os.path.expanduser("~/.cache/whisper"), filename), + ] + allowed_parents = { + os.path.realpath(_get_whisper_cache_dir()), + os.path.realpath(os.path.expanduser("~/.cache/whisper")), + } + + found: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + if not os.path.isfile(candidate): + continue + real = os.path.realpath(candidate) + if real in seen or os.path.dirname(real) not in allowed_parents: + continue + if os.path.basename(real) != filename: + continue + seen.add(real) + found.append(real) + return found + + def _is_whisper_model_downloaded(model_name: str) -> bool: """Check if a Whisper model is downloaded.""" cache_dir = _get_whisper_cache_dir() @@ -759,6 +810,26 @@ def _is_whisper_model_downloaded(model_name: str) -> bool: return os.path.exists(os.path.join(default_cache, f"{model_name}.pt")) +def _list_downloaded_whisper_models() -> list[str]: + """Return OpenAI Whisper catalog names that are present on disk.""" + return [name for name in WHISPER_MODEL_INFO if _is_whisper_model_downloaded(name)] + + +def _delete_whisper_model(model_name: str) -> list[str]: + """Delete OpenAI Whisper weight files for a catalog model name.""" + if model_name not in WHISPER_MODEL_INFO: + raise ValueError(f"Unknown Whisper model: {model_name}") + + files = _whisper_model_files(model_name) + if not files: + raise FileNotFoundError(model_name) + + for path in files: + os.remove(path) + logger.info("Deleted Whisper model %s (%s)", model_name, path) + return files + + def _format_size(size_mb: int) -> str: """Format size in MB to human readable string.""" if size_mb >= 1000: @@ -982,6 +1053,12 @@ class PreferencesGroup(Gtk.Box): self.listbox.add(widget) self.rows.append(widget) + def clear_rows(self): + """Remove all preference rows from the group.""" + for row in list(self.listbox.get_children()): + self.listbox.remove(row) + self.rows.clear() + class PreferenceRow(Gtk.ListBoxRow): """A single preference row with title, subtitle, and a control widget.""" @@ -2261,6 +2338,36 @@ class SettingsDialog(Gtk.Dialog): self.content_box.pack_start(self.model_info_card, False, False, 0) + self.unused_models_group = PreferencesGroup( + keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"), + ) + self.unused_models_group.title = "Unused downloads" + self.unused_models_group.description = "On disk, but not the model currently selected" + + self.unused_expander = Gtk.Expander(label="Unused downloads") + self.unused_expander.set_expanded(False) + self.unused_expander.set_use_underline(False) + self.unused_expander.set_tooltip_text( + "Leftover model files on disk. Expand to delete them one at a time." + ) + self.unused_expander.get_style_context().add_class("unused-downloads-expander") + + self.unused_models_scroll = Gtk.ScrolledWindow() + self.unused_models_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.unused_models_scroll.set_shadow_type(Gtk.ShadowType.NONE) + self.unused_models_scroll.set_propagate_natural_height(True) + self.unused_models_scroll.set_max_content_height(220) + + # ListBox is GtkScrollable, so wrapping it in a Box forces a Viewport + # and lets the expander child report a real height. + list_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.unused_models_group.remove(self.unused_models_group.listbox) + list_holder.pack_start(self.unused_models_group.listbox, False, False, 0) + self.unused_models_scroll.add(list_holder) + self.unused_expander.add(self.unused_models_scroll) + self.unused_models_group.pack_start(self.unused_expander, False, False, 0) + self.content_box.pack_start(self.unused_models_group, False, False, 0) + # Connect signals self.engine_combo.connect("changed", self._on_engine_changed) self.model_combo.connect("changed", self._on_model_changed) @@ -4052,6 +4159,7 @@ class SettingsDialog(Gtk.Dialog): logger.info(f"Final selected model: {self.model_combo.get_active_text()}") finally: self._populating_models = False + self._refresh_unused_downloads() def _populate_whispercpp_model_options(self, saved_model_for_engine: str): """Populate whisper.cpp size and specialization selectors.""" @@ -4109,6 +4217,177 @@ class SettingsDialog(Gtk.Dialog): self._set_combo_active_id_or_first(self.model_variant_combo, model_to_set) self._update_model_picker_tooltips() + def _active_removable_model_id(self) -> Optional[str]: + """Return the on-disk id of the model currently selected in Settings.""" + engine = self._get_selected_engine() + if engine == "whisper_cpp": + return self._get_selected_whispercpp_model() + if engine == "whisper": + model_id = self.model_combo.get_active_id() + return model_id.lower() if model_id else None + if engine == "vosk": + size = (self.model_combo.get_active_id() or "").lower() + language = self.language_combo.get_active_id() or self.language + return vosk_model_dirname(size, language) + return None + + def _list_unused_downloads(self) -> list[tuple[str, str, str]]: + """Return (id, title, size_label) for unused downloaded models.""" + return [ + (model_id, label, size_label) + for model_id, label, size_label, in_use in self._list_downloaded_models() + if not in_use + ] + + def _list_downloaded_models(self) -> list[tuple[str, str, str, bool]]: + """Return (id, title, size_label, in_use) for downloaded models.""" + engine = self._get_selected_engine() + active_id = self._active_removable_model_id() + items: list[tuple[str, str, str, bool]] = [] + + if engine == "whisper_cpp": + for name in list_downloaded_whispercpp_models(): + info = WHISPERCPP_MODEL_INFO[name] + size_mb = info["size_mb"] if isinstance(info.get("size_mb"), int) else 0 + items.append( + ( + name, + _model_display_name(name), + _format_size(size_mb), + name == active_id, + ) + ) + elif engine == "whisper": + for name in _list_downloaded_whisper_models(): + info = WHISPER_MODEL_INFO[name] + size_mb = info["size_mb"] if isinstance(info.get("size_mb"), int) else 0 + items.append( + ( + name, + _model_display_name(name), + _format_size(size_mb), + name == active_id, + ) + ) + elif engine == "vosk": + for model in list_downloaded_vosk_models(): + lang_info = SUPPORTED_LANGUAGES.get(model.language, {}) + lang_name = model.language + if isinstance(lang_info, dict): + name = lang_info.get("name") + if isinstance(name, str): + lang_name = name + items.append( + ( + model.dirname, + f"{lang_name} ยท {_model_display_name(model.size)}", + _format_size(model.size_mb), + model.dirname == active_id, + ) + ) + + return items + + def _refresh_unused_downloads(self): + """Rebuild the Unused downloads list, or hide it when empty.""" + if not hasattr(self, "unused_models_group"): + return + + if self._get_selected_engine() == "remote_api": + self.unused_models_group.hide() + return + + unused = self._list_unused_downloads() + self.unused_models_group.clear_rows() + if not unused: + self.unused_models_group.hide() + return + + count = len(unused) + leftover = "leftover model" if count == 1 else "leftover models" + self.unused_expander.set_label(f"Unused downloads ({count} {leftover})") + self.unused_models_scroll.set_min_content_height(min(220, 56 * count)) + + was_expanded = self.unused_expander.get_expanded() + for model_id, title, size_label in unused: + delete_btn = Gtk.Button(label="Delete") + delete_btn.set_tooltip_text(f"Delete {title} from disk") + delete_btn.get_style_context().add_class("destructive-action") + delete_btn.connect( + "clicked", + self._on_unused_download_delete_clicked, + model_id, + f"{title} ({size_label})", + ) + row = PreferenceRow( + title=title, + subtitle=size_label, + widget=delete_btn, + keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"), + ) + self.unused_models_group.add_row(row) + + self.unused_models_group.show_all() + self.unused_expander.set_expanded(was_expanded) + + def _confirm_model_delete(self, text: str, secondary: str) -> bool: + """Ask before deleting model files from disk.""" + dialog = Gtk.MessageDialog( + transient_for=self, + flags=Gtk.DialogFlags.MODAL, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.NONE, + text=text, + ) + dialog.format_secondary_text(secondary) + dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL) + delete_btn = dialog.add_button("_Delete", Gtk.ResponseType.YES) + delete_btn.get_style_context().add_class("destructive-action") + response = dialog.run() + dialog.destroy() + return response == Gtk.ResponseType.YES + + def _delete_model_from_disk(self, model_id: str) -> None: + """Delete a downloaded model for the selected engine.""" + engine = self._get_selected_engine() + if engine == "whisper_cpp": + delete_whispercpp_model(model_id) + elif engine == "whisper": + _delete_whisper_model(model_id) + elif engine == "vosk": + delete_vosk_model(model_id) + else: + raise ValueError(f"No local models to delete for engine {engine}") + + def _on_unused_download_delete_clicked(self, widget, model_id: str, label: str): + """Confirm and delete one unused downloaded model.""" + if model_id == self._active_removable_model_id(): + return + if not self._confirm_model_delete( + "Delete unused download?", + f"{label} will be removed from disk. You can download it again later if needed.", + ): + return + + try: + self._delete_model_from_disk(model_id) + except (OSError, ValueError, FileNotFoundError) as e: + logger.error("Failed to delete model %s: %s", model_id, e) + err = Gtk.MessageDialog( + transient_for=self, + flags=Gtk.DialogFlags.MODAL, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text="Could not delete model", + ) + err.format_secondary_text(str(e)) + err.run() + err.destroy() + return + + self._populate_model_options() + self._update_model_info() + def _on_engine_changed(self, widget): """Handle changes in the selected engine.""" engine_text = self.engine_combo.get_active_text() @@ -4159,6 +4438,7 @@ class SettingsDialog(Gtk.Dialog): self._sync_language_options_for_selected_model() self._update_model_info() + self._refresh_unused_downloads() self._auto_apply_settings() def _on_model_variant_changed(self, widget): @@ -4168,6 +4448,7 @@ class SettingsDialog(Gtk.Dialog): self._sync_language_options_for_selected_model() self._update_model_info() + self._refresh_unused_downloads() self._auto_apply_settings() def _on_vad_changed(self, widget): @@ -4307,6 +4588,7 @@ class SettingsDialog(Gtk.Dialog): if is_remote: self.model_row.hide() self.model_variant_row.hide() + self.unused_models_group.hide() self.model_info_card.hide() self.remote_server_group.show_all() self.remote_status_label.show() @@ -4320,6 +4602,7 @@ class SettingsDialog(Gtk.Dialog): self.remote_status_label.hide() self._update_model_info() + self._refresh_unused_downloads() self._update_language_warning() self._update_model_picker_tooltips() self._update_advanced_tab_sensitivity() diff --git a/src/vocalinux/utils/paths.py b/src/vocalinux/utils/paths.py index 64009fc..5606bc2 100644 --- a/src/vocalinux/utils/paths.py +++ b/src/vocalinux/utils/paths.py @@ -28,3 +28,22 @@ def data_dir() -> str: def models_dir() -> str: """Return the directory where speech-recognition models are stored.""" return os.path.join(data_dir(), "models") + + +def is_within_directory(path: str, directory: str, *, allow_root: bool = False) -> bool: + """Return whether ``path`` resolves inside ``directory``. + + Symlinks are resolved before the check. The directory itself is included + only when ``allow_root`` is true. + """ + real_path = os.path.realpath(path) + real_dir = os.path.realpath(directory) + try: + rel = os.path.relpath(real_path, real_dir) + except ValueError: + return False + if os.path.isabs(rel) or rel == ".." or rel.startswith(".." + os.sep): + return False + if rel == ".": + return allow_root + return True diff --git a/src/vocalinux/utils/vosk_model_info.py b/src/vocalinux/utils/vosk_model_info.py index cd47cf0..0044d46 100644 --- a/src/vocalinux/utils/vosk_model_info.py +++ b/src/vocalinux/utils/vosk_model_info.py @@ -1,3 +1,14 @@ +"""Language definitions and VOSK model metadata.""" + +import logging +import os +import shutil +from typing import Any, NamedTuple, Optional + +from .paths import is_within_directory, models_dir + +logger = logging.getLogger(__name__) + # Language definitions with display names and Whisper/VOSK codes # Supported languages for speech recognition # @@ -269,3 +280,101 @@ VOSK_MODEL_INFO = { }, }, } + + +class DownloadedVoskModel(NamedTuple): + """A VOSK model directory present in the user models folder.""" + + dirname: str + language: str + size: str + size_mb: int + path: str + + +def _vosk_catalog_entry(size: str) -> Optional[dict[str, Any]]: + info = VOSK_MODEL_INFO.get(size) + return info if isinstance(info, dict) else None + + +def _vosk_language_map(info: dict[str, Any]) -> dict[str, str]: + languages = info.get("languages") + if not isinstance(languages, dict): + return {} + return {str(key): str(value) for key, value in languages.items()} + + +def vosk_model_dirname(size: str, language: str) -> Optional[str]: + """Return the on-disk directory name for a VOSK size and language.""" + info = _vosk_catalog_entry(size) + if info is None: + return None + languages = _vosk_language_map(info) + if language == "auto" or language not in languages: + language = "en-us" + return languages.get(language) + + +def _known_vosk_dirnames() -> set[str]: + names: set[str] = set() + for info in VOSK_MODEL_INFO.values(): + if isinstance(info, dict): + names.update(_vosk_language_map(info).values()) + return names + + +def list_downloaded_vosk_models() -> list[DownloadedVoskModel]: + """Return unique user-installed VOSK model directories. + + System-wide preinstalled models are ignored; only ``models_dir()`` is + scanned so Settings can offer deletion without touching package files. + """ + seen: set[str] = set() + found: list[DownloadedVoskModel] = [] + models_root = models_dir() + for size, info in VOSK_MODEL_INFO.items(): + if not isinstance(info, dict): + continue + size_mb_raw = info.get("size_mb", 0) + size_mb = int(size_mb_raw) if isinstance(size_mb_raw, (int, float)) else 0 + for language, dirname in _vosk_language_map(info).items(): + if dirname in seen: + continue + path = os.path.join(models_root, dirname) + if os.path.isdir(path): + seen.add(dirname) + found.append( + DownloadedVoskModel( + dirname=dirname, + language=language, + size=size, + size_mb=size_mb, + path=path, + ) + ) + return found + + +def delete_vosk_model(dirname: str) -> str: + """Delete a user-installed VOSK model directory. + + Returns: + The removed filesystem path. + + Raises: + ValueError: Unknown model name, or path outside the models directory. + FileNotFoundError: The model directory is not present. + OSError: The directory could not be removed. + """ + if dirname not in _known_vosk_dirnames() or os.path.basename(dirname) != dirname: + raise ValueError(f"Unknown VOSK model: {dirname}") + + path = os.path.join(models_dir(), dirname) + if not is_within_directory(path, models_dir()): + raise ValueError("Refusing to delete a path outside the models directory") + if not os.path.isdir(path): + raise FileNotFoundError(path) + + shutil.rmtree(path) + logger.info("Deleted VOSK model %s (%s)", dirname, path) + return path diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index 916da73..e26ca0f 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -12,7 +12,7 @@ import subprocess from functools import lru_cache from typing import Optional -from .paths import models_dir +from .paths import is_within_directory, models_dir logger = logging.getLogger(__name__) @@ -428,6 +428,37 @@ def is_model_downloaded(model_name: str) -> bool: return os.path.exists(model_path) +def list_downloaded_models() -> list[str]: + """Return catalog model names whose files are present on disk.""" + return [name for name in AVAILABLE_MODELS if is_model_downloaded(name)] + + +def delete_model(model_name: str) -> str: + """Delete a downloaded whisper.cpp model file. + + Returns: + The removed filesystem path. + + Raises: + ValueError: Unknown model name, or path outside the models directory. + FileNotFoundError: The model file is not present. + OSError: The file could not be removed. + """ + if model_name not in WHISPERCPP_MODEL_INFO: + raise ValueError(f"Unknown whisper.cpp model: {model_name}") + + model_path = get_model_path(model_name) + whispercpp_dir = os.path.join(models_dir(), "whispercpp") + if not is_within_directory(model_path, whispercpp_dir): + raise ValueError("Refusing to delete a path outside the whisper.cpp models directory") + if not os.path.isfile(model_path): + raise FileNotFoundError(model_path) + + os.remove(model_path) + logger.info("Deleted whisper.cpp model %s (%s)", model_name, model_path) + return model_path + + def get_backend_display_name(backend: str) -> str: """ Get a user-friendly display name for a compute backend. diff --git a/tests/test_model_deletion.py b/tests/test_model_deletion.py new file mode 100644 index 0000000..d94bc38 --- /dev/null +++ b/tests/test_model_deletion.py @@ -0,0 +1,114 @@ +"""Tests for deleting downloaded speech models from disk.""" + +import os +from unittest.mock import patch + +import pytest + +from vocalinux.utils.vosk_model_info import ( + delete_vosk_model, + list_downloaded_vosk_models, + vosk_model_dirname, +) +from vocalinux.utils.whispercpp_model_info import delete_model, list_downloaded_models + + +def test_whispercpp_list_and_delete(tmp_path): + with patch("vocalinux.utils.whispercpp_model_info.models_dir", return_value=str(tmp_path)): + assert list_downloaded_models() == [] + + from vocalinux.utils.whispercpp_model_info import get_model_path + + tiny = get_model_path("tiny") + base = get_model_path("base") + os.makedirs(os.path.dirname(tiny), exist_ok=True) + with open(tiny, "wb") as handle: + handle.write(b"tiny") + with open(base, "wb") as handle: + handle.write(b"base") + + downloaded = list_downloaded_models() + assert downloaded == ["tiny", "base"] + + deleted = delete_model("tiny") + assert deleted == tiny + assert not os.path.exists(tiny) + assert os.path.exists(base) + assert list_downloaded_models() == ["base"] + + +def test_whispercpp_delete_unknown_and_missing(tmp_path): + with patch("vocalinux.utils.whispercpp_model_info.models_dir", return_value=str(tmp_path)): + with pytest.raises(ValueError, match="Unknown whisper.cpp model"): + delete_model("not-a-real-model") + with pytest.raises(FileNotFoundError): + delete_model("tiny") + + +def test_vosk_dirname_and_unique_list(tmp_path): + assert vosk_model_dirname("small", "en-us") == "vosk-model-small-en-us-0.15" + assert vosk_model_dirname("medium", "en-us") == "vosk-model-en-us-0.22" + # Korean medium reuses the small folder. + assert vosk_model_dirname("small", "ko") == vosk_model_dirname("medium", "ko") + + small_en = tmp_path / "vosk-model-small-en-us-0.15" + small_en.mkdir() + (small_en / "am").write_text("x") + korean = tmp_path / "vosk-model-small-ko-0.22" + korean.mkdir() + + with patch("vocalinux.utils.vosk_model_info.models_dir", return_value=str(tmp_path)): + found = list_downloaded_vosk_models() + dirnames = [item.dirname for item in found] + assert dirnames == [ + "vosk-model-small-en-us-0.15", + "vosk-model-small-ko-0.22", + ] + assert dirnames.count("vosk-model-small-ko-0.22") == 1 + + deleted = delete_vosk_model("vosk-model-small-en-us-0.15") + assert deleted == str(small_en) + assert not small_en.exists() + assert korean.exists() + assert [item.dirname for item in list_downloaded_vosk_models()] == [ + "vosk-model-small-ko-0.22" + ] + + +def test_vosk_delete_rejects_unknown_and_missing(tmp_path): + with patch("vocalinux.utils.vosk_model_info.models_dir", return_value=str(tmp_path)): + with pytest.raises(ValueError, match="Unknown VOSK model"): + delete_vosk_model("../etc") + with pytest.raises(ValueError, match="Unknown VOSK model"): + delete_vosk_model("not-a-vosk-model") + with pytest.raises(FileNotFoundError): + delete_vosk_model("vosk-model-small-en-us-0.15") + + +def test_whisper_list_and_delete(tmp_path): + cache = tmp_path / "whisper" + cache.mkdir() + tiny = cache / "tiny.pt" + tiny.write_bytes(b"weights") + missing_default = tmp_path / "missing-whisper-cache" + + from vocalinux.ui.settings_dialog import ( + _delete_whisper_model, + _list_downloaded_whisper_models, + ) + + with ( + patch( + "vocalinux.ui.settings_dialog._get_whisper_cache_dir", + return_value=str(cache), + ), + patch( + "vocalinux.ui.settings_dialog.os.path.expanduser", + return_value=str(missing_default), + ), + ): + assert _list_downloaded_whisper_models() == ["tiny"] + deleted = _delete_whisper_model("tiny") + assert deleted == [str(tiny)] + assert not tiny.exists() + assert _list_downloaded_whisper_models() == [] diff --git a/tests/test_paths.py b/tests/test_paths.py index a5858e7..2d87376 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -36,3 +36,16 @@ class TestXdgHelpers: flatpak_data = "/home/user/.var/app/com.vocalinux.Vocalinux/data" with patch.dict(os.environ, {"XDG_DATA_HOME": flatpak_data}, clear=True): assert paths.models_dir() == os.path.join(flatpak_data, "vocalinux", "models") + + def test_is_within_directory(self, tmp_path): + nested = tmp_path / "models" / "ggml-tiny.bin" + nested.parent.mkdir() + nested.write_bytes(b"x") + + assert paths.is_within_directory(str(nested), str(tmp_path / "models")) + assert not paths.is_within_directory(str(tmp_path / "models"), str(tmp_path / "models")) + assert paths.is_within_directory( + str(tmp_path / "models"), str(tmp_path / "models"), allow_root=True + ) + assert not paths.is_within_directory(str(tmp_path / "other"), str(tmp_path / "models")) + assert not paths.is_within_directory("/etc/passwd", str(tmp_path / "models")) diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 80601e4..9501edb 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -712,6 +712,34 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): ) self.assertEqual(_default_whispercpp_variant_for_size("medium", "auto"), "medium") + def test_whisper_delete_unknown_model(self): + """Test deleting an unknown Whisper model raises ValueError.""" + from vocalinux.ui.settings_dialog import _delete_whisper_model + + with self.assertRaises(ValueError): + _delete_whisper_model("not-a-whisper-model") + + def test_unused_downloads_group_exists_in_source(self): + """Test that unused downloads live in their own group, not Speech Engine.""" + import os + + source_path = os.path.join( + os.path.dirname(__file__), + "..", + "src", + "vocalinux", + "ui", + "settings_dialog.py", + ) + with open(source_path, "r") as handle: + source_code = handle.read() + + self.assertIn('label="Unused downloads"', source_code) + self.assertIn("self.unused_expander", source_code) + self.assertIn("self._on_unused_download_delete_clicked", source_code) + self.assertNotIn('title="Remove Model"', source_code) + self.assertIn("self._refresh_unused_downloads()", source_code) + class TestLanguageComboSearch(unittest.TestCase): """Searchable language ComboBox matching and commit helpers."""