diff --git a/locales/de.json b/locales/de.json index 89548379..01622d58 100644 --- a/locales/de.json +++ b/locales/de.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "Keine Modellbeschreibung verfügbar", "failedToLoad": "Fehler beim Laden der Modellbeschreibung", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "Modellbeschreibung bearbeiten", "validation": { "cannotBeEmpty": "Beschreibung darf nicht leer sein" @@ -2250,6 +2251,10 @@ "noReLicense": "Gleiche Berechtigungen erforderlich", "restrictionsLabel": "Lizenzbeschränkungen" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "Beispielbilder werden geladen...", "description": "Modellbeschreibung wird geladen...", diff --git a/locales/en.json b/locales/en.json index 841d1bf1..1969df63 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "No model description available", "failedToLoad": "Failed to load model description", + "offline": "The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "Edit model description", "validation": { "cannotBeEmpty": "Description cannot be empty" @@ -2250,6 +2251,10 @@ "noReLicense": "Same permissions required", "restrictionsLabel": "License restrictions" }, + "notices": { + "offline": "The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "Loading example images...", "description": "Loading model description...", diff --git a/locales/es.json b/locales/es.json index b821ad69..1891e142 100644 --- a/locales/es.json +++ b/locales/es.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "No hay descripción del modelo disponible", "failedToLoad": "Error al cargar la descripción del modelo", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "Editar descripción del modelo", "validation": { "cannotBeEmpty": "La descripción no puede estar vacía" @@ -2250,6 +2251,10 @@ "noReLicense": "Se requieren mismos permisos", "restrictionsLabel": "Restricciones de licencia" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "Cargando imágenes de ejemplo...", "description": "Cargando descripción del modelo...", diff --git a/locales/fr.json b/locales/fr.json index b231c3a2..ce6a118a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "Aucune description de modèle disponible", "failedToLoad": "Échec du chargement de la description du modèle", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "Modifier la description du modèle", "validation": { "cannotBeEmpty": "La description ne peut pas être vide" @@ -2250,6 +2251,10 @@ "noReLicense": "Mêmes autorisations requises", "restrictionsLabel": "Restrictions de licence" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "Chargement des images d'exemple...", "description": "Chargement de la description du modèle...", diff --git a/locales/he.json b/locales/he.json index 1e1a1c14..85d62622 100644 --- a/locales/he.json +++ b/locales/he.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "אין תיאור מודל זמין", "failedToLoad": "טעינת תיאור המודל נכשלה", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "ערוך תיאור מודל", "validation": { "cannotBeEmpty": "התיאור לא יכול להיות ריק" @@ -2250,6 +2251,10 @@ "noReLicense": "נדרשות אותן הרשאות", "restrictionsLabel": "הגבלות רישיון" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "טוען תמונות דוגמה...", "description": "טוען תיאור מודל...", diff --git a/locales/ja.json b/locales/ja.json index 54ecee35..9ecdafa3 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "モデルの説明がありません", "failedToLoad": "モデルの説明の読み込みに失敗しました", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "モデルの説明を編集", "validation": { "cannotBeEmpty": "説明を空にすることはできません" @@ -2250,6 +2251,10 @@ "noReLicense": "同じ権限が必要", "restrictionsLabel": "ライセンス制限" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "例画像を読み込み中...", "description": "モデル説明を読み込み中...", diff --git a/locales/ko.json b/locales/ko.json index 5f4bb98a..052d50cf 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "사용 가능한 모델 설명이 없습니다", "failedToLoad": "모델 설명 로딩 실패", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "모델 설명 편집", "validation": { "cannotBeEmpty": "설명은 비어있을 수 없습니다" @@ -2250,6 +2251,10 @@ "noReLicense": "동일한 권한 필요", "restrictionsLabel": "라이선스 제한" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "예시 이미지 로딩 중...", "description": "모델 설명 로딩 중...", diff --git a/locales/ru.json b/locales/ru.json index 4020a6eb..93e9365d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "Описание модели недоступно", "failedToLoad": "Не удалось загрузить описание модели", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "Редактировать описание модели", "validation": { "cannotBeEmpty": "Описание не может быть пустым" @@ -2250,6 +2251,10 @@ "noReLicense": "Требуются те же права", "restrictionsLabel": "Лицензионные ограничения" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "Загрузка примеров изображений...", "description": "Загрузка описания модели...", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 9fb02d0f..233c4fe2 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "暂无模型描述", "failedToLoad": "加载模型描述失败", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "编辑模型描述", "validation": { "cannotBeEmpty": "描述不能为空" @@ -2250,6 +2251,10 @@ "noReLicense": "需要相同权限", "restrictionsLabel": "许可证限制" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "正在加载示例图片...", "description": "正在加载模型描述...", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 07999c43..8eb7923e 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -2218,6 +2218,7 @@ "description": { "noDescription": "無模型描述", "failedToLoad": "載入模型描述失敗", + "offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.", "editTitle": "編輯模型描述", "validation": { "cannotBeEmpty": "描述不可為空" @@ -2250,6 +2251,10 @@ "noReLicense": "需要相同授權", "restrictionsLabel": "授權限制" }, + "notices": { + "offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.", + "partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable." + }, "loading": { "exampleImages": "載入範例圖片中...", "description": "載入模型描述中...", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index f82345bd..044367de 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -1720,9 +1720,24 @@ class ModelQueryHandler: file_path = request.query.get("file_path") if not file_path: return web.Response(text="File path is required", status=400) - metadata = await self._service.get_model_metadata(file_path) + metadata, partial, offline = await self._service.get_model_metadata_with_fallback( + file_path + ) if metadata is not None: - return web.json_response({"success": True, "metadata": metadata}) + return web.json_response( + { + "success": True, + "metadata": metadata, + # ``partial``: rebuilt from the cache because the + # .metadata.json could not be read (offline drive, + # unmounted share, missing/corrupt sidecar). + # ``offline``: the model path is not under a reachable + # root, so file actions will fail even when the sidecar + # itself is readable (centralized sidecar storage). + "partial": partial, + "offline": offline, + } + ) return web.json_response( { "success": False, diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 006a810e..8c807e79 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -22,7 +22,9 @@ from .model_query import ( ) from .settings_manager import get_settings_manager from .model_sources import source_group_key -from ..utils.civitai_utils import build_civitai_model_page_url +from ..utils.civitai_utils import build_civitai_model_page_url, decode_license_flags +from ..utils.root_paths import normalize_root_identity +from ..utils.sidecar_paths import is_centralized logger = logging.getLogger(__name__) @@ -979,7 +981,10 @@ class BaseModelService(ABC): return {} fields = ( - ["id", "modelId", "name", "trainedWords"] + # ``creator`` travels with the lightweight payload so the model modal + # can still show who published the model when the on-disk metadata + # (the only full-payload source) lives on an offline drive. + ["id", "modelId", "name", "creator", "trainedWords"] if minimal else [ "id", @@ -1242,6 +1247,119 @@ class BaseModelService(ABC): return self.filter_civitai_data(metadata.to_dict().get("civitai", {})) + async def get_model_metadata_with_fallback( + self, file_path: str + ) -> tuple[Optional[Dict[str, Any]], bool, bool]: + """Load model metadata, degrading to cached values when the disk is gone. + + Returns ``(metadata, partial, offline)``: + + * ``metadata`` — the on-disk CivitAI payload, or a payload rebuilt from + the in-memory/SQLite cache when ``.metadata.json`` cannot be read. + * ``partial`` — ``True`` when the payload came from the cache, i.e. it + only carries what the lightweight cache entry retains. + * ``offline`` — ``True`` when the file does not sit under a currently + reachable model root (switched-off drive, unmounted share). + + A model whose drive is offline is deliberately *not* treated as deleted: + the scanner keeps its cache entry, so the modal can still show the + creator, version name, trigger words, license permissions and ids. + """ + offline = self._is_model_path_offline(file_path) + if offline and not is_centralized(): + # Alongside storage keeps the sidecar next to the model, so an + # unreachable model path means an unreachable sidecar. Skip the + # stat() that can block for seconds on a dead network share and go + # straight to the cache: once the drive is reconnected the refresh + # menu / a scan admits the root and this reads the sidecar again. + metadata = None + else: + # Centralized storage can still serve the real payload while the + # model drive is off — the sidecar lives on the app's own disk. + metadata = await self.get_model_metadata(file_path) + if metadata is not None: + return metadata, False, offline + + entry = await self._find_cache_entry(file_path) + if entry is None: + return None, False, offline + + # Nothing CivitAI-shaped was ever cached for this model (e.g. a plain + # HuggingFace download): reporting an empty partial payload would only + # raise an "incomplete metadata" notice for a model that never had any, + # so keep the 404 the modal already handles. + cached_civitai = entry.get("civitai") + if not isinstance(cached_civitai, dict) or not cached_civitai: + return None, False, offline + + return self._cached_civitai_payload(entry), True, offline + + def _is_model_path_offline(self, file_path: str) -> bool: + """Return ``True`` when *file_path* is outside every reachable root. + + In-memory check only: the live root list is what the scanner admitted, + so a switched-off drive answers without a ``stat()`` that can block for + seconds on a dead network share. + """ + check = getattr(self.scanner, "is_file_path_available", None) + if not callable(check): + return False + try: + return not check(file_path) + except Exception: # pragma: no cover - defensive guard + return False + + async def _find_cache_entry(self, file_path: str) -> Optional[Dict[str, Any]]: + """Return the cached list entry for *file_path*, if there is one.""" + if not file_path: + return None + try: + cache = await self.scanner.get_cached_data() + except Exception: # pragma: no cover - defensive guard + return None + + candidate = str(file_path).replace("\\", "/") + raw_data = getattr(cache, "raw_data", None) or [] + for entry in raw_data: + if isinstance(entry, dict) and entry.get("file_path") == candidate: + return entry + + # The cache stores forward-slashed business paths; fold spelling, + # separators and (on Windows) case before giving up. + target = normalize_root_identity(file_path) + if not target: + return None + for entry in raw_data: + if not isinstance(entry, dict): + continue + if normalize_root_identity(entry.get("file_path", "")) == target: + return entry + return None + + def _cached_civitai_payload(self, entry: Dict[str, Any]) -> Dict[str, Any]: + """Rebuild a CivitAI payload from the lightweight cache entry. + + The cache keeps only the fields the grid needs, plus the license + permissions as a bitfield. Decoding that bitfield restores the + ``civitai.model`` section the modal renders its license icons from; + everything the cache never stored (description, example images, creator + avatar) stays absent rather than being invented. + """ + civitai = entry.get("civitai") + payload: Dict[str, Any] = dict(civitai) if isinstance(civitai, dict) else {} + + model_section = payload.get("model") + if isinstance(model_section, dict): + merged = dict(model_section) + merged.update(decode_license_flags(entry.get("license_flags"))) + payload["model"] = merged + + base_model = entry.get("base_model") + if base_model and not payload.get("baseModel"): + payload["baseModel"] = base_model + + return self.filter_civitai_data(payload) + async def get_model_description(self, file_path: str) -> Optional[str]: """Return the stored modelDescription field for a model.""" metadata, should_skip = await MetadataManager.load_metadata( diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index cc6dc622..813f8912 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -2826,6 +2826,27 @@ class ModelScanner: """Get model root directories""" raise NotImplementedError("Subclasses must implement get_model_roots") + def is_file_path_available(self, file_path: str) -> bool: + """Return whether *file_path* sits under a currently reachable root. + + Pure in-memory check: the live root list was filled when the library + was admitted, so a switched-off drive answers ``False`` without a + ``stat()`` that can block for seconds on a dead network share. A drive + plugged in after startup is admitted by :meth:`refresh_model_roots` + (run by the refresh menu and by scans), so this follows the same + "reachable" definition as the refresh scope list. + """ + if not file_path: + return False + candidate = normalize_root_identity(file_path) + if not candidate: + return False + for root in self.get_model_roots(): + prefix = _normalized_root_prefix(root) + if prefix and candidate.startswith(prefix): + return True + return False + async def get_all_folders(self) -> List[str]: """Return every known directory under the model roots. diff --git a/py/utils/civitai_utils.py b/py/utils/civitai_utils.py index 223b2754..f96af301 100644 --- a/py/utils/civitai_utils.py +++ b/py/utils/civitai_utils.py @@ -339,6 +339,41 @@ def resolve_license_info( return payload, build_license_flags(payload) +# Commercial permission bits, in the order ``_resolve_commercial_bits`` packs +# them after ``_COMMERCIAL_SHIFT``. The names match CivitAI's API values so the +# decoded payload renders through the exact same client-side code path. +_COMMERCIAL_FLAG_VALUES: tuple[tuple[int, str], ...] = ( + (1, "Image"), + (2, "RentCivit"), + (3, "Rent"), + (4, "Sell"), +) + + +def decode_license_flags(flags: Any) -> Dict[str, Any]: + """Decode a cached license bitfield back into a CivitAI license payload. + + Inverse of :func:`build_license_flags`. Used when the authoritative + ``.metadata.json`` is unreachable (offline drive) but the model modal still + has to render the license icons: the bitfield survives in the persistent + cache, the original ``civitai.model`` section does not. + """ + + try: + value = int(flags) + except (TypeError, ValueError): + value = build_license_flags({}) + + return { + "allowNoCredit": bool(value & (1 << 0)), + "allowCommercialUse": [ + name for bit, name in _COMMERCIAL_FLAG_VALUES if value & (1 << bit) + ], + "allowDerivatives": bool(value & (1 << 5)), + "allowDifferentLicense": bool(value & (1 << 6)), + } + + def _normalize_media_width(width: int | None) -> int | None: if width is None: return None diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index 4f4dcd24..90e3d01a 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -172,6 +172,37 @@ transform: translateY(-1px); } +/* Metadata provenance notice (offline drive / unreadable sidecar) */ +.modal-metadata-notice { + display: flex; + align-items: flex-start; + gap: var(--space-2); + margin-bottom: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid color-mix(in oklch, var(--color-info, #4dabf7) 35%, transparent); + border-left-width: 3px; + border-radius: var(--border-radius-sm); + background: color-mix(in oklch, var(--color-info, #4dabf7) 10%, transparent); + color: var(--text-color); + font-size: 0.85em; + line-height: 1.45; +} + +.modal-metadata-notice i { + margin-top: 2px; + flex-shrink: 0; + color: var(--color-info, #4dabf7); +} + +.modal-metadata-notice--offline { + border-color: color-mix(in oklch, var(--color-warning, #f59f00) 40%, transparent); + background: color-mix(in oklch, var(--color-warning, #f59f00) 12%, transparent); +} + +.modal-metadata-notice--offline i { + color: var(--color-warning, #f59f00); +} + /* Info Grid */ .info-grid { display: grid; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index a3616eab..71f08cd8 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -2245,6 +2245,20 @@ export class BaseModelApiClient { } async fetchModelMetadata(filePath) { + const result = await this.fetchModelMetadataWithStatus(filePath); + return result.metadata; + } + + /** + * Fetch the full CivitAI metadata together with its provenance. + * + * The backend degrades to the cached payload when `.metadata.json` cannot + * be read (offline drive, unmounted share, missing sidecar), so callers + * that render a full view need to know whether what they got is complete: + * + * @returns {Promise<{metadata: Object, partial: boolean, offline: boolean}>} + */ + async fetchModelMetadataWithStatus(filePath) { try { const params = new URLSearchParams({ file_path: filePath }); const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`); @@ -2256,7 +2270,11 @@ export class BaseModelApiClient { const data = await response.json(); if (data.success) { - return data.metadata; + return { + metadata: data.metadata || {}, + partial: Boolean(data.partial), + offline: Boolean(data.offline), + }; } else { throw new Error(data.error || `No metadata found for ${this.apiConfig.config.singularName}`); } diff --git a/static/js/components/shared/ModelDescription.js b/static/js/components/shared/ModelDescription.js index 4ca791ba..54079770 100644 --- a/static/js/components/shared/ModelDescription.js +++ b/static/js/components/shared/ModelDescription.js @@ -81,8 +81,19 @@ async function loadModelDescription() { } catch (error) { console.error('Error loading model description:', error); - const failedText = translate('modals.model.description.failedToLoad', {}, 'Failed to load model description'); - descriptionContent.innerHTML = `