From 4ba72a7bd3ba51d3f54ecda652de231bf4bb4282 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 29 Jan 2026 01:33:11 +0100 Subject: [PATCH 1/4] feat(model_manager): add missing models filter to Model Manager Adds the ability to view and manage orphaned model database entries where the underlying files have been deleted externally. Changes: - Add GET /v2/models/missing API endpoint to list models with missing files - Add "Missing Files" filter option to Model Manager type filter dropdown - Display "Missing Files" badge on models with missing files in the list - Automatically exclude missing models from model selection dropdowns to prevent users from selecting unavailable models for generation --- invokeai/app/api/routers/model_manager.py | 22 ++++++++++ invokeai/frontend/web/public/locales/en.json | 2 + .../web/src/features/modelManagerV2/models.ts | 6 +-- .../store/modelManagerV2Slice.ts | 5 ++- .../MissingModelsContext.tsx | 32 ++++++++++++++ .../subpanels/ModelManagerPanel/ModelList.tsx | 27 ++++++++++-- .../ModelManagerPanel/ModelListItem.tsx | 13 +++++- .../ModelManagerPanel/ModelTypeFilter.tsx | 35 +++++++++++++-- .../web/src/services/api/endpoints/models.ts | 9 ++++ .../src/services/api/hooks/modelsByType.ts | 24 ++++++++++- .../frontend/web/src/services/api/schema.ts | 43 +++++++++++++++++++ 11 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/MissingModelsContext.tsx 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 f819ae10cea..19f05ef1d8d 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -972,6 +972,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/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 a99021bc9d0..5f9bab79452 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; @@ -27201,6 +27224,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: { From 9690afa00404c3b096437e4f1b982df84f9768bd Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 29 Jan 2026 17:15:58 +0100 Subject: [PATCH 2/4] fix(ui): enable Select All checkbox for missing models filter The Select All checkbox was disabled when the missing models filter was active because the bulk actions component didn't use the missing models query data. Now it correctly uses useGetMissingModelsQuery when the filter is set to 'missing'. --- .../ModelListBulkActions.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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) { From e0631c5e616c187c4080d0927b7fb06e37c9a56e Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 1 Feb 2026 00:42:14 +0100 Subject: [PATCH 3/4] test(model_manager): add tests for missing model detection and bulk delete Tests _scan_for_missing_models and the unregister/delete workflow for models whose files have been removed externally. --- .../model_install/test_missing_models.py | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 tests/app/services/model_install/test_missing_models.py 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..5ed6c731625 --- /dev/null +++ b/tests/app/services/model_install/test_missing_models.py @@ -0,0 +1,221 @@ +""" +Tests for missing model detection (_scan_for_missing_models) and bulk deletion. +""" + +import gc +import shutil +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 From bb26b586a9c28403d997940588101c2203749f8b Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 1 Feb 2026 00:47:36 +0100 Subject: [PATCH 4/4] Chore Ruff check --- tests/app/services/model_install/test_missing_models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/app/services/model_install/test_missing_models.py b/tests/app/services/model_install/test_missing_models.py index 5ed6c731625..e42c9e2f95e 100644 --- a/tests/app/services/model_install/test_missing_models.py +++ b/tests/app/services/model_install/test_missing_models.py @@ -3,7 +3,6 @@ """ import gc -import shutil from pathlib import Path import pytest