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."""