diff --git a/locales/de.json b/locales/de.json index 7ded102a..b8a11ec5 100644 --- a/locales/de.json +++ b/locales/de.json @@ -341,6 +341,10 @@ "saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "Bereits heruntergeladene Modellversionen überspringen", + "help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe." + }, "layoutSettings": { "displayDensity": "Anzeige-Dichte", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "In {otherType}-Ordner verschieben", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "An Workflow senden" } }, "embeddings": { diff --git a/locales/en.json b/locales/en.json index 46bbe052..aa1a2827 100644 --- a/locales/en.json +++ b/locales/en.json @@ -325,7 +325,7 @@ }, "downloadSkipBaseModels": { "label": "Skip downloads for base models", - "help": "When a model version uses one of these base models, LoRA Manager will skip the download before any file transfer starts. Applies to all download flows. Only supported base models can be selected here.", + "help": "When enabled, versions using the selected base models will be skipped.", "searchPlaceholder": "Filter base models...", "empty": "No base models match the current search.", "summary": { @@ -341,6 +341,10 @@ "saveFailed": "Unable to save excluded base models: {message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "Skip previously downloaded model versions", + "help": "When enabled, versions downloaded before will be skipped." + }, "layoutSettings": { "displayDensity": "Display Density", "displayDensityOptions": { diff --git a/locales/es.json b/locales/es.json index 777f581f..d613ee0a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -341,6 +341,10 @@ "saveFailed": "No se pudieron guardar los modelos base excluidos: {message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "Omitir versiones de modelos previamente descargadas", + "help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga." + }, "layoutSettings": { "displayDensity": "Densidad de visualización", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "Mover a la carpeta {otherType}", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "Enviar al flujo de trabajo" } }, "embeddings": { diff --git a/locales/fr.json b/locales/fr.json index 3c426a59..be536721 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -341,6 +341,10 @@ "saveFailed": "Impossible d’enregistrer les modèles de base exclus : {message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "Ignorer les versions de modèles précédemment téléchargées", + "help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement." + }, "layoutSettings": { "displayDensity": "Densité d'affichage", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "Envoyer vers le workflow" } }, "embeddings": { diff --git a/locales/he.json b/locales/he.json index 6c45e3ad..a23b613d 100644 --- a/locales/he.json +++ b/locales/he.json @@ -341,6 +341,10 @@ "saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "דלג על גרסאות מודלים שהורדו בעבר", + "help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה." + }, "layoutSettings": { "displayDensity": "צפיפות תצוגה", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "העבר לתיקיית {otherType}", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "שלח ל-workflow" } }, "embeddings": { diff --git a/locales/ja.json b/locales/ja.json index e06cc209..2515e147 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -341,6 +341,10 @@ "saveFailed": "除外するベースモデルを保存できませんでした: {message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "以前にダウンロードしたモデルバージョンをスキップ", + "help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。" + }, "layoutSettings": { "displayDensity": "表示密度", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "{otherType} フォルダに移動", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "ワークフローに送信" } }, "embeddings": { diff --git a/locales/ko.json b/locales/ko.json index a8e0d0b1..e6a16222 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -341,6 +341,10 @@ "saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "이전에 다운로드한 모델 버전 건너뛰기", + "help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다." + }, "layoutSettings": { "displayDensity": "표시 밀도", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "{otherType} 폴더로 이동", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "워크플로우로 전송" } }, "embeddings": { diff --git a/locales/ru.json b/locales/ru.json index aa1d2800..4c3bb4d0 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -341,6 +341,10 @@ "saveFailed": "Не удалось сохранить исключённые базовые модели: {message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "Пропускать ранее загруженные версии моделей", + "help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки." + }, "layoutSettings": { "displayDensity": "Плотность отображения", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "Переместить в папку {otherType}", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "Отправить в workflow" } }, "embeddings": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index a4b737c1..71c0703f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -341,6 +341,10 @@ "saveFailed": "无法保存已排除的基础模型:{message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "跳过已下载的模型版本", + "help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。" + }, "layoutSettings": { "displayDensity": "显示密度", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "移动到 {otherType} 文件夹", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "发送到工作流" } }, "embeddings": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 2d1ffdb4..e5f541bc 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -341,6 +341,10 @@ "saveFailed": "無法儲存已排除的基礎模型:{message}" } }, + "skipPreviouslyDownloadedModelVersions": { + "label": "跳過已下載的模型版本", + "help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。" + }, "layoutSettings": { "displayDensity": "顯示密度", "displayDensityOptions": { @@ -827,7 +831,7 @@ }, "contextMenu": { "moveToOtherTypeFolder": "移動到 {otherType} 資料夾", - "sendToWorkflow": "[TODO: Translate] Send to Workflow" + "sendToWorkflow": "傳送到工作流" } }, "embeddings": { diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 4f32faee..8e19eb60 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -64,6 +64,19 @@ class DownloadManager: """Get the checkpoint scanner from registry""" return await ServiceRegistry.get_checkpoint_scanner() + async def _has_been_downloaded(self, model_type: str, model_version_id: int) -> bool: + try: + history_service = await ServiceRegistry.get_downloaded_version_history_service() + return await history_service.has_been_downloaded(model_type, model_version_id) + except Exception as exc: + logger.debug( + "Failed to read download history for %s version %s: %s", + model_type, + model_version_id, + exc, + ) + return False + async def download_from_civitai( self, model_id: int = None, @@ -355,6 +368,57 @@ class DownloadManager: "error": f'Model type "{model_type_from_info}" is not supported for download', } + resolved_version_id = model_version_id + raw_version_id = version_info.get("id") + if resolved_version_id is None and raw_version_id is not None: + try: + resolved_version_id = int(raw_version_id) + except (TypeError, ValueError): + resolved_version_id = None + + if ( + get_settings_manager().get_skip_previously_downloaded_model_versions() + and resolved_version_id is not None + and await self._has_been_downloaded(model_type, resolved_version_id) + ): + file_name = "" + files = version_info.get("files") + if isinstance(files, list): + primary_file = next( + ( + file_info + for file_info in files + if isinstance(file_info, dict) and file_info.get("primary") + ), + None, + ) + selected_file = primary_file + if selected_file is None: + selected_file = next( + (file_info for file_info in files if isinstance(file_info, dict)), + None, + ) + if isinstance(selected_file, dict): + raw_file_name = selected_file.get("name", "") + if isinstance(raw_file_name, str): + file_name = raw_file_name.strip() + + message = ( + f"Skipped download for '{file_name or version_info.get('name') or f'model_version:{resolved_version_id}'}' " + f"because version {resolved_version_id} was already downloaded before" + ) + logger.info(message) + return { + "success": True, + "skipped": True, + "status": "skipped", + "reason": "previously_downloaded_version", + "message": message, + "model_version_id": resolved_version_id, + "file_name": file_name, + "download_id": download_id, + } + excluded_base_models = get_settings_manager().get_download_skip_base_models() base_model_value = version_info.get("baseModel", "") if ( diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index f75101be..64feeb3a 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -91,6 +91,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "update_flag_strategy": "same_base", "auto_organize_exclusions": [], "metadata_refresh_skip_paths": [], + "skip_previously_downloaded_model_versions": False, "download_skip_base_models": [], } @@ -314,6 +315,10 @@ class SettingsManager: self.settings["download_skip_base_models"] = [] inserted_defaults = True + if "skip_previously_downloaded_model_versions" not in self.settings: + self.settings["skip_previously_downloaded_model_versions"] = False + inserted_defaults = True + had_mature_level = "mature_blur_level" in self.settings raw_mature_level = self.settings.get("mature_blur_level") normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level) @@ -1090,6 +1095,17 @@ class SettingsManager: self._save_settings() return base_models + def get_skip_previously_downloaded_model_versions(self) -> bool: + value = self.settings.get("skip_previously_downloaded_model_versions", False) + if isinstance(value, bool): + return value + normalized = False + if isinstance(value, str): + normalized = value.strip().lower() in {"1", "true", "yes", "on"} + self.settings["skip_previously_downloaded_model_versions"] = normalized + self._save_settings() + return normalized + def get_extra_folder_paths(self) -> Dict[str, List[str]]: """Get extra folder paths for the active library. diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 8467219d..50767ce1 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -146,6 +146,10 @@ export class SettingsManager { backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths ); + merged.skip_previously_downloaded_model_versions = + backendSettings?.skip_previously_downloaded_model_versions + ?? defaults.skip_previously_downloaded_model_versions; + merged.download_skip_base_models = this.normalizeDownloadSkipBaseModels( backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models ); @@ -836,6 +840,12 @@ export class SettingsManager { hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false; } + const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions'); + if (skipPreviouslyDownloadedModelVersionsCheckbox) { + skipPreviouslyDownloadedModelVersionsCheckbox.checked = + state.global.settings.skip_previously_downloaded_model_versions || false; + } + // Set optimize example images setting const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages'); if (optimizeExampleImagesCheckbox) { diff --git a/static/js/state/index.js b/static/js/state/index.js index 5061506e..5abea0ec 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -38,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ hide_early_access_updates: false, auto_organize_exclusions: [], metadata_refresh_skip_paths: [], + skip_previously_downloaded_model_versions: false, download_skip_base_models: [], }); diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 8c59f17a..872938d6 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -735,6 +735,24 @@ +
+
+
+ +
+
+ +
+
+
+
diff --git a/tests/frontend/managers/settingsManager.downloadSkipBaseModels.test.js b/tests/frontend/managers/settingsManager.downloadSkipBaseModels.test.js index 1f41b090..55f5c954 100644 --- a/tests/frontend/managers/settingsManager.downloadSkipBaseModels.test.js +++ b/tests/frontend/managers/settingsManager.downloadSkipBaseModels.test.js @@ -20,6 +20,7 @@ vi.mock('../../../static/js/state/index.js', () => { }, createDefaultSettings: () => ({ language: 'en', + skip_previously_downloaded_model_versions: false, download_skip_base_models: [], }), }; @@ -117,6 +118,7 @@ describe('SettingsManager download skip base models UI', () => { document.body.innerHTML = ''; vi.clearAllMocks(); state.global.settings = { + skip_previously_downloaded_model_versions: false, download_skip_base_models: [], }; }); @@ -150,4 +152,31 @@ describe('SettingsManager download skip base models UI', () => { expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(0); expect(document.getElementById('downloadSkipBaseModelsEmpty').hidden).toBe(false); }); + + it('initializes the previously-downloaded-version toggle from settings', () => { + document.body.innerHTML = ''; + state.global.settings.skip_previously_downloaded_model_versions = true; + const manager = createManager(); + + manager.loadSettingsToUI(); + + expect(document.getElementById('skipPreviouslyDownloadedModelVersions').checked).toBe(true); + }); + + it('saves the previously-downloaded-version toggle with the expected setting key', async () => { + document.body.innerHTML = ''; + const manager = createManager(); + manager.saveSetting = vi.fn().mockResolvedValue(); + manager.applyFrontendSettings = vi.fn(); + + await manager.saveToggleSetting( + 'skipPreviouslyDownloadedModelVersions', + 'skip_previously_downloaded_model_versions', + ); + + expect(manager.saveSetting).toHaveBeenCalledWith( + 'skip_previously_downloaded_model_versions', + true, + ); + }); }); diff --git a/tests/services/test_download_manager_basic.py b/tests/services/test_download_manager_basic.py index bb63003e..ac801212 100644 --- a/tests/services/test_download_manager_basic.py +++ b/tests/services/test_download_manager_basic.py @@ -38,6 +38,7 @@ def isolate_settings(monkeypatch, tmp_path): "embedding": "{base_model}/{first_tag}", }, "base_model_path_mappings": {"BaseModel": "MappedModel"}, + "skip_previously_downloaded_model_versions": False, "download_skip_base_models": [], } ) @@ -454,7 +455,7 @@ async def test_download_skips_excluded_base_model(monkeypatch, scanners, metadat metadata_provider.get_model_version = AsyncMock( return_value={ - "id": 42, + "id": 99, "model": {"type": "LoRA", "tags": ["fantasy"]}, "baseModel": "SDXL 1.0", "creator": {"username": "Author"}, @@ -490,3 +491,104 @@ async def test_download_skips_excluded_base_model(monkeypatch, scanners, metadat assert "file.safetensors" in result["message"] execute_download.assert_not_called() assert manager._active_downloads[result["download_id"]]["status"] == "skipped" + + +@pytest.mark.asyncio +async def test_download_skips_previously_downloaded_version(monkeypatch, scanners, metadata_provider): + manager = DownloadManager() + get_settings_manager().settings["skip_previously_downloaded_model_versions"] = True + + metadata_provider.get_model_version = AsyncMock( + return_value={ + "id": 42, + "model": {"type": "LoRA", "tags": ["fantasy"]}, + "baseModel": "SDXL 1.0", + "creator": {"username": "Author"}, + "files": [ + { + "type": "Model", + "primary": True, + "downloadUrl": "https://example.invalid/file.safetensors", + "name": "file.safetensors", + } + ], + } + ) + + history_service = AsyncMock() + history_service.has_been_downloaded = AsyncMock(return_value=True) + monkeypatch.setattr( + ServiceRegistry, + "get_downloaded_version_history_service", + AsyncMock(return_value=history_service), + ) + + execute_download = AsyncMock() + monkeypatch.setattr( + DownloadManager, "_execute_download", execute_download, raising=False + ) + + result = await manager.download_from_civitai( + model_version_id=99, + use_default_paths=True, + progress_callback=None, + source=None, + ) + + assert result["success"] is True + assert result["skipped"] is True + assert result["status"] == "skipped" + assert result["reason"] == "previously_downloaded_version" + assert result["model_version_id"] == 99 + assert result["file_name"] == "file.safetensors" + history_service.has_been_downloaded.assert_awaited_once_with("lora", 99) + execute_download.assert_not_called() + assert manager._active_downloads[result["download_id"]]["status"] == "skipped" + + +@pytest.mark.asyncio +async def test_download_proceeds_when_history_skip_disabled(monkeypatch, scanners, metadata_provider): + manager = DownloadManager() + get_settings_manager().settings["skip_previously_downloaded_model_versions"] = False + + metadata_provider.get_model_version = AsyncMock( + return_value={ + "id": 42, + "model": {"type": "LoRA", "tags": ["fantasy"]}, + "baseModel": "SDXL 1.0", + "creator": {"username": "Author"}, + "files": [ + { + "type": "Model", + "primary": True, + "downloadUrl": "https://example.invalid/file.safetensors", + "name": "file.safetensors", + } + ], + } + ) + + history_service = AsyncMock() + history_service.has_been_downloaded = AsyncMock(return_value=True) + monkeypatch.setattr( + ServiceRegistry, + "get_downloaded_version_history_service", + AsyncMock(return_value=history_service), + ) + + execute_download = AsyncMock(return_value={"success": True, "download_id": "done"}) + monkeypatch.setattr( + DownloadManager, "_execute_download", execute_download, raising=False + ) + + result = await manager.download_from_civitai( + model_version_id=99, + use_default_paths=True, + progress_callback=None, + source=None, + ) + + assert result["success"] is True + assert result.get("skipped") is not True + history_service.has_been_downloaded.assert_not_called() + execute_download.assert_awaited_once() diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index 51fec3cb..56005709 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -829,3 +829,14 @@ def test_setting_download_skip_base_models_normalizes_string_input(manager): manager.set("download_skip_base_models", "SDXL 1.0, Pony; Invalid\nSDXL 1.0") assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"] + + +def test_skip_previously_downloaded_model_versions_defaults_false(manager): + assert manager.get_skip_previously_downloaded_model_versions() is False + + +def test_skip_previously_downloaded_model_versions_coerces_string_input(manager): + manager.settings["skip_previously_downloaded_model_versions"] = "true" + + assert manager.get_skip_previously_downloaded_model_versions() is True + assert manager.settings["skip_previously_downloaded_model_versions"] is True