diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index ceca9f8f53b..ddc26d9bece 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -148,6 +148,28 @@ async def list_model_records( return ModelsList(models=found_models) +@model_manager_router.get( + "/missing", + operation_id="list_missing_models", + responses={200: {"description": "List of models with missing files"}}, +) +async def list_missing_models() -> ModelsList: + """Get models whose files are missing from disk. + + These are models that have database entries but their corresponding + weight files have been deleted externally (not via Model Manager). + """ + record_store = ApiDependencies.invoker.services.model_manager.store + models_path = ApiDependencies.invoker.services.configuration.models_path + + missing_models: list[AnyModelConfig] = [] + for model_config in record_store.all_models(): + if not (models_path / model_config.path).resolve().exists(): + missing_models.append(model_config) + + return ModelsList(models=missing_models) + + @model_manager_router.get( "/get_by_attrs", operation_id="get_model_records_by_attrs", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 5327b6d8251..532204e3e34 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -974,6 +974,8 @@ "loraModels": "LoRAs", "main": "Main", "metadata": "Metadata", + "missingFiles": "Missing Files", + "missingFilesTooltip": "Model files are missing from disk", "model": "Model", "modelConversionFailed": "Model Conversion Failed", "modelConverted": "Model Converted", diff --git a/invokeai/frontend/web/src/features/modelManagerV2/models.ts b/invokeai/frontend/web/src/features/modelManagerV2/models.ts index cd83315d48c..c4dd56f8113 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/models.ts +++ b/invokeai/frontend/web/src/features/modelManagerV2/models.ts @@ -22,15 +22,15 @@ import { } from 'services/api/types'; import { objectEntries } from 'tsafe'; -import type { FilterableModelType } from './store/modelManagerV2Slice'; +import type { ModelCategoryType } from './store/modelManagerV2Slice'; export type ModelCategoryData = { - category: FilterableModelType; + category: ModelCategoryType; i18nKey: string; filter: (config: AnyModelConfig) => boolean; }; -export const MODEL_CATEGORIES: Record = { +export const MODEL_CATEGORIES: Record = { unknown: { category: 'unknown', i18nKey: 'common.unknown', diff --git a/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts b/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts index 65c9cbc1302..092998d0c31 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts +++ b/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts @@ -7,7 +7,10 @@ import { zModelType } from 'features/nodes/types/common'; import { assert } from 'tsafe'; import z from 'zod'; -const zFilterableModelType = zModelType.exclude(['onnx']).or(z.literal('refiner')); +const zModelCategoryType = zModelType.exclude(['onnx']).or(z.literal('refiner')); +export type ModelCategoryType = z.infer; + +const zFilterableModelType = zModelCategoryType.or(z.literal('missing')); export type FilterableModelType = z.infer; const zModelManagerState = z.object({ diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/MissingModelsContext.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/MissingModelsContext.tsx new file mode 100644 index 00000000000..2490a5a8648 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/MissingModelsContext.tsx @@ -0,0 +1,32 @@ +import type { PropsWithChildren } from 'react'; +import { createContext, useContext, useMemo } from 'react'; +import { modelConfigsAdapterSelectors, useGetMissingModelsQuery } from 'services/api/endpoints/models'; + +type MissingModelsContextValue = { + missingModelKeys: Set; + isLoading: boolean; +}; + +const MissingModelsContext = createContext({ + missingModelKeys: new Set(), + isLoading: false, +}); + +export const MissingModelsProvider = ({ children }: PropsWithChildren) => { + const { data, isLoading } = useGetMissingModelsQuery(); + + const value = useMemo(() => { + const missingModels = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} }); + const missingModelKeys = new Set(missingModels.map((m) => m.key)); + return { missingModelKeys, isLoading }; + }, [data, isLoading]); + + return {children}; +}; + +const useMissingModels = () => useContext(MissingModelsContext); + +export const useIsModelMissing = (modelKey: string) => { + const { missingModelKeys } = useMissingModels(); + return missingModelKeys.has(modelKey); +}; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx index 2159d538bee..f3be0b4686c 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx @@ -18,12 +18,14 @@ import { serializeError } from 'serialize-error'; import { modelConfigsAdapterSelectors, useBulkDeleteModelsMutation, + useGetMissingModelsQuery, useGetModelConfigsQuery, } from 'services/api/endpoints/models'; import type { AnyModelConfig } from 'services/api/types'; import { BulkDeleteModelsModal } from './BulkDeleteModelsModal'; import { FetchingModelsLoader } from './FetchingModelsLoader'; +import { MissingModelsProvider } from './MissingModelsContext'; import { ModelListWrapper } from './ModelListWrapper'; const log = logger('models'); @@ -40,11 +42,30 @@ const ModelList = () => { const { isOpen, close } = useBulkDeleteModal(); const [isDeleting, setIsDeleting] = useState(false); - const { data, isLoading } = useGetModelConfigsQuery(); + const { data: allModelsData, isLoading: isLoadingAll } = useGetModelConfigsQuery(); + const { data: missingModelsData, isLoading: isLoadingMissing } = useGetMissingModelsQuery(); const [bulkDeleteModels] = useBulkDeleteModelsMutation(); + const data = filteredModelType === 'missing' ? missingModelsData : allModelsData; + const isLoading = filteredModelType === 'missing' ? isLoadingMissing : isLoadingAll; + const models = useMemo(() => { const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} }); + + // For missing models filter, show all models in a single category + if (filteredModelType === 'missing') { + const filtered = modelConfigs.filter( + (m) => + m.name.toLowerCase().includes(searchTerm.toLowerCase()) || + m.base.toLowerCase().includes(searchTerm.toLowerCase()) || + m.type.toLowerCase().includes(searchTerm.toLowerCase()) + ); + return { + total: filtered.length, + byCategory: [{ i18nKey: 'modelManager.missingFiles', configs: filtered }], + }; + } + const baseFilteredModelConfigs = modelsFilter(modelConfigs, searchTerm, filteredModelType); const byCategory: { i18nKey: string; configs: AnyModelConfig[] }[] = []; const total = baseFilteredModelConfigs.length; @@ -128,7 +149,7 @@ const ModelList = () => { }, [bulkDeleteModels, selectedModelKeys, dispatch, close, toast, t]); return ( - <> + @@ -152,7 +173,7 @@ const ModelList = () => { modelCount={selectedModelKeys.length} isDeleting={isDeleting} /> - + ); }; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx index 2442bd02162..1e6281f1c17 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx @@ -11,7 +11,11 @@ import { import { t } from 'i18next'; import { memo, useCallback, useMemo } from 'react'; import { PiCaretDownBold, PiTrashSimpleBold } from 'react-icons/pi'; -import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models'; +import { + modelConfigsAdapterSelectors, + useGetMissingModelsQuery, + useGetModelConfigsQuery, +} from 'services/api/endpoints/models'; import type { AnyModelConfig } from 'services/api/types'; import { useBulkDeleteModal } from './ModelList'; @@ -31,7 +35,8 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) => const filteredModelType = useAppSelector(selectFilteredModelType); const selectedModelKeys = useAppSelector(selectSelectedModelKeys); const searchTerm = useAppSelector(selectSearchTerm); - const { data } = useGetModelConfigsQuery(); + const { data: allModelsData } = useGetModelConfigsQuery(); + const { data: missingModelsData } = useGetMissingModelsQuery(); const bulkDeleteModal = useBulkDeleteModal(); const handleBulkDelete = useCallback(() => { @@ -40,10 +45,24 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) => // Calculate displayed (filtered) model keys const displayedModelKeys = useMemo(() => { + // Use missing models data when the filter is 'missing' + const data = filteredModelType === 'missing' ? missingModelsData : allModelsData; const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} }); + + // For missing models filter, only apply search term filter + if (filteredModelType === 'missing') { + const filtered = modelConfigs.filter( + (m) => + m.name.toLowerCase().includes(searchTerm.toLowerCase()) || + m.base.toLowerCase().includes(searchTerm.toLowerCase()) || + m.type.toLowerCase().includes(searchTerm.toLowerCase()) + ); + return filtered.map((m) => m.key); + } + const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType); return filteredModels.map((m) => m.key); - }, [data, searchTerm, filteredModelType]); + }, [allModelsData, missingModelsData, searchTerm, filteredModelType]); const { allSelected, someSelected } = useMemo(() => { if (displayedModelKeys.length === 0) { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx index 5719752ff01..9547046ba41 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx @@ -1,5 +1,5 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { chakra, Checkbox, Flex, Spacer, Text } from '@invoke-ai/ui-library'; +import { Badge, chakra, Checkbox, Flex, Spacer, Text, Tooltip } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { @@ -15,8 +15,10 @@ import { filesize } from 'filesize'; import type { ChangeEvent, MouseEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { PiWarningBold } from 'react-icons/pi'; import type { AnyModelConfig } from 'services/api/types'; +import { useIsModelMissing } from './MissingModelsContext'; import ModelImage from './ModelImage'; const StyledLabel = chakra('label'); @@ -58,6 +60,7 @@ const sx: SystemStyleObject = { const ModelListItem = ({ model }: ModelListItemProps) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); + const isMissing = useIsModelMissing(model.key); const selectIsSelected = useMemo( () => createSelector( @@ -139,6 +142,14 @@ const ModelListItem = ({ model }: ModelListItemProps) => { + {isMissing && ( + + + + {t('modelManager.missingFiles')} + + + )} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx index dcb22071482..5aa8e628869 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx @@ -1,11 +1,16 @@ -import { Button, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { Button, Flex, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import type { ModelCategoryData } from 'features/modelManagerV2/models'; import { MODEL_CATEGORIES, MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models'; +import type { ModelCategoryType } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { selectFilteredModelType, setFilteredModelType } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiFunnelBold } from 'react-icons/pi'; +import { PiFunnelBold, PiWarningBold } from 'react-icons/pi'; + +const isModelCategoryType = (type: string): type is ModelCategoryType => { + return type in MODEL_CATEGORIES; +}; export const ModelTypeFilter = memo(() => { const { t } = useTranslation(); @@ -16,13 +21,37 @@ export const ModelTypeFilter = memo(() => { dispatch(setFilteredModelType(null)); }, [dispatch]); + const setMissingFilter = useCallback(() => { + dispatch(setFilteredModelType('missing')); + }, [dispatch]); + + const getButtonLabel = () => { + if (filteredModelType === 'missing') { + return t('modelManager.missingFiles'); + } + if (filteredModelType && isModelCategoryType(filteredModelType)) { + return t(MODEL_CATEGORIES[filteredModelType].i18nKey); + } + return t('modelManager.allModels'); + }; + return ( }> - {filteredModelType ? t(MODEL_CATEGORIES[filteredModelType].i18nKey) : t('modelManager.allModels')} + {getButtonLabel()} {t('modelManager.allModels')} + + + + {t('modelManager.missingFiles')} + + {MODEL_CATEGORIES_AS_LIST.map((data) => ( ))} diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index 707352bcb39..da5afbcfe1a 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -290,6 +290,13 @@ export const modelsApi = api.injectEndpoints({ }); }, }), + getMissingModels: build.query, void>({ + query: () => ({ url: buildModelsUrl('missing') }), + providesTags: [{ type: 'ModelConfig', id: LIST_TAG }], + transformResponse: (response: GetModelConfigsResponse) => { + return modelConfigsAdapter.setAll(modelConfigsAdapter.getInitialState(), response.models); + }, + }), getStarterModels: build.query({ query: () => buildModelsUrl('starter_models'), providesTags: [{ type: 'ModelConfig', id: LIST_TAG }], @@ -357,6 +364,7 @@ export const modelsApi = api.injectEndpoints({ export const { useGetModelConfigsQuery, useGetModelConfigQuery, + useGetMissingModelsQuery, useDeleteModelsMutation, useBulkDeleteModelsMutation, useDeleteModelImageMutation, @@ -378,3 +386,4 @@ export const { } = modelsApi; export const selectModelConfigsQuery = modelsApi.endpoints.getModelConfigs.select(); +export const selectMissingModelsQuery = modelsApi.endpoints.getMissingModels.select(); diff --git a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts index b771dd78400..92722b7664c 100644 --- a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts +++ b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts @@ -4,7 +4,9 @@ import type { RootState } from 'app/store/store'; import { useMemo } from 'react'; import { modelConfigsAdapterSelectors, + selectMissingModelsQuery, selectModelConfigsQuery, + useGetMissingModelsQuery, useGetModelConfigsQuery, } from 'services/api/endpoints/models'; import type { AnyModelConfig } from 'services/api/types'; @@ -33,16 +35,24 @@ const buildModelsHook = (typeGuard: (config: AnyModelConfig) => config is T) => (filter: (config: T) => boolean = () => true) => { const result = useGetModelConfigsQuery(undefined); + const { data: missingModelsData } = useGetMissingModelsQuery(); + const modelConfigs = useMemo(() => { if (!result.data) { return EMPTY_ARRAY; } + // Get set of missing model keys to exclude from selection + const missingModelKeys = new Set( + modelConfigsAdapterSelectors.selectAll(missingModelsData ?? { ids: [], entities: {} }).map((m) => m.key) + ); + return modelConfigsAdapterSelectors .selectAll(result.data) .filter((config) => typeGuard(config)) + .filter((config) => !missingModelKeys.has(config.key)) .filter(filter); - }, [filter, result.data]); + }, [filter, result.data, missingModelsData]); return [modelConfigs, result] as const; }; @@ -75,7 +85,17 @@ const buildModelsSelector = if (!result.data) { return EMPTY_ARRAY; } - return modelConfigsAdapterSelectors.selectAll(result.data).filter(typeGuard); + + // Get set of missing model keys to exclude from selection + const missingResult = selectMissingModelsQuery(state); + const missingModelKeys = new Set( + modelConfigsAdapterSelectors.selectAll(missingResult.data ?? { ids: [], entities: {} }).map((m) => m.key) + ); + + return modelConfigsAdapterSelectors + .selectAll(result.data) + .filter(typeGuard) + .filter((config) => !missingModelKeys.has(config.key)); }; export const selectIPAdapterModels = buildModelsSelector(isIPAdapterModelConfig); export const selectGlobalRefImageModels = buildModelsSelector( diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 98e43bcb722..a8a47986462 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -39,6 +39,29 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v2/models/missing": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Missing Models + * @description Get models whose files are missing from disk. + * + * These are models that have database entries but their corresponding + * weight files have been deleted externally (not via Model Manager). + */ + get: operations["list_missing_models"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/models/get_by_attrs": { parameters: { query?: never; @@ -27204,6 +27227,26 @@ export interface operations { }; }; }; + list_missing_models: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of models with missing files */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelsList"]; + }; + }; + }; + }; get_model_records_by_attrs: { parameters: { query: { diff --git a/tests/app/services/model_install/test_missing_models.py b/tests/app/services/model_install/test_missing_models.py new file mode 100644 index 00000000000..e42c9e2f95e --- /dev/null +++ b/tests/app/services/model_install/test_missing_models.py @@ -0,0 +1,220 @@ +""" +Tests for missing model detection (_scan_for_missing_models) and bulk deletion. +""" + +import gc +from pathlib import Path + +import pytest + +from invokeai.app.services.config import InvokeAIAppConfig +from invokeai.app.services.model_install import ModelInstallServiceBase +from invokeai.app.services.model_records import UnknownModelException +from invokeai.backend.model_manager.configs.textual_inversion import TI_File_SD1_Config +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelSourceType, + ModelType, +) +from tests.backend.model_manager.model_manager_fixtures import * # noqa F403 + + +class TestScanForMissingModels: + """Tests for ModelInstallService._scan_for_missing_models().""" + + def test_no_missing_models( + self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig + ) -> None: + """When all registered models exist on disk, _scan_for_missing_models returns an empty list.""" + mm2_installer.register_path(embedding_file) + missing = mm2_installer._scan_for_missing_models() + assert len(missing) == 0 + + def test_detects_missing_model( + self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig + ) -> None: + """A model whose path does not exist on disk is reported as missing.""" + # Register a real model first, then add a fake one with a non-existent path + mm2_installer.register_path(embedding_file) + + fake_config = TI_File_SD1_Config( + key="missing-model-key-1", + path="/nonexistent/path/missing_model.safetensors", + name="MissingModel", + base=BaseModelType.StableDiffusion1, + type=ModelType.TextualInversion, + format=ModelFormat.EmbeddingFile, + hash="FAKEHASH1", + file_size=1024, + source="test/source", + source_type=ModelSourceType.Path, + ) + mm2_installer.record_store.add_model(fake_config) + + missing = mm2_installer._scan_for_missing_models() + assert len(missing) == 1 + assert missing[0].key == "missing-model-key-1" + + def test_mix_of_existing_and_missing( + self, + mm2_installer: ModelInstallServiceBase, + embedding_file: Path, + diffusers_dir: Path, + mm2_app_config: InvokeAIAppConfig, + ) -> None: + """With multiple models, only the ones with missing files are returned.""" + key_existing = mm2_installer.register_path(embedding_file) + mm2_installer.register_path(diffusers_dir) + + # Add two models with non-existent paths + fake1 = TI_File_SD1_Config( + key="missing-key-1", + path="/nonexistent/missing1.safetensors", + name="Missing1", + base=BaseModelType.StableDiffusion1, + type=ModelType.TextualInversion, + format=ModelFormat.EmbeddingFile, + hash="FAKEHASH_A", + file_size=1024, + source="test/source1", + source_type=ModelSourceType.Path, + ) + fake2 = TI_File_SD1_Config( + key="missing-key-2", + path="/nonexistent/missing2.safetensors", + name="Missing2", + base=BaseModelType.StableDiffusion1, + type=ModelType.TextualInversion, + format=ModelFormat.EmbeddingFile, + hash="FAKEHASH_B", + file_size=2048, + source="test/source2", + source_type=ModelSourceType.Path, + ) + mm2_installer.record_store.add_model(fake1) + mm2_installer.record_store.add_model(fake2) + + missing = mm2_installer._scan_for_missing_models() + missing_keys = {m.key for m in missing} + assert len(missing) == 2 + assert "missing-key-1" in missing_keys + assert "missing-key-2" in missing_keys + assert key_existing not in missing_keys + + def test_empty_store_returns_empty(self, mm2_installer: ModelInstallServiceBase) -> None: + """With no models registered, _scan_for_missing_models returns an empty list.""" + missing = mm2_installer._scan_for_missing_models() + assert len(missing) == 0 + + +class TestBulkDelete: + """Tests for bulk model deletion.""" + + def test_delete_installed_model( + self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig + ) -> None: + """Deleting an installed model removes it from the store and disk.""" + key = mm2_installer.install_path(embedding_file) + record = mm2_installer.record_store.get_model(key) + model_path = mm2_app_config.models_path / record.path + assert model_path.exists() + assert mm2_installer.record_store.exists(key) + + gc.collect() + mm2_installer.delete(key) + + with pytest.raises(UnknownModelException): + mm2_installer.record_store.get_model(key) + + def test_unregister_missing_model( + self, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig + ) -> None: + """Unregistering a model whose file is missing removes it from the DB.""" + fake_config = TI_File_SD1_Config( + key="missing-to-delete", + path="/nonexistent/path/gone.safetensors", + name="GoneModel", + base=BaseModelType.StableDiffusion1, + type=ModelType.TextualInversion, + format=ModelFormat.EmbeddingFile, + hash="FAKEHASH_GONE", + file_size=1024, + source="test/source", + source_type=ModelSourceType.Path, + ) + mm2_installer.record_store.add_model(fake_config) + assert mm2_installer.record_store.exists("missing-to-delete") + + # Unregister removes it from DB without touching disk + mm2_installer.unregister("missing-to-delete") + + with pytest.raises(UnknownModelException): + mm2_installer.record_store.get_model("missing-to-delete") + + def test_delete_unknown_key_raises(self, mm2_installer: ModelInstallServiceBase) -> None: + """Deleting a model with an unknown key raises UnknownModelException.""" + with pytest.raises(UnknownModelException): + mm2_installer.delete("nonexistent-key-12345") + + def test_scan_then_unregister_clears_missing( + self, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig + ) -> None: + """After unregistering all missing models, _scan_for_missing_models returns empty.""" + # Add two models with non-existent paths + for i in range(2): + config = TI_File_SD1_Config( + key=f"missing-bulk-{i}", + path=f"/nonexistent/bulk_{i}.safetensors", + name=f"BulkMissing{i}", + base=BaseModelType.StableDiffusion1, + type=ModelType.TextualInversion, + format=ModelFormat.EmbeddingFile, + hash=f"BULKHASH{i}", + file_size=1024, + source=f"test/bulk{i}", + source_type=ModelSourceType.Path, + ) + mm2_installer.record_store.add_model(config) + + missing = mm2_installer._scan_for_missing_models() + assert len(missing) == 2 + + # Unregister all missing (simulates bulk delete for missing models) + for model in missing: + mm2_installer.unregister(model.key) + + assert len(mm2_installer._scan_for_missing_models()) == 0 + + def test_bulk_unregister_does_not_affect_existing_models( + self, + mm2_installer: ModelInstallServiceBase, + embedding_file: Path, + mm2_app_config: InvokeAIAppConfig, + ) -> None: + """Unregistering missing models does not affect models that exist on disk.""" + existing_key = mm2_installer.register_path(embedding_file) + + fake_config = TI_File_SD1_Config( + key="missing-selective", + path="/nonexistent/selective.safetensors", + name="SelectiveMissing", + base=BaseModelType.StableDiffusion1, + type=ModelType.TextualInversion, + format=ModelFormat.EmbeddingFile, + hash="SELECTIVEHASH", + file_size=1024, + source="test/selective", + source_type=ModelSourceType.Path, + ) + mm2_installer.record_store.add_model(fake_config) + + # Only unregister the missing one + missing = mm2_installer._scan_for_missing_models() + assert len(missing) == 1 + for model in missing: + mm2_installer.unregister(model.key) + + # Existing model should still be there + assert mm2_installer.record_store.exists(existing_key) + assert len(mm2_installer._scan_for_missing_models()) == 0