fix(modal): keep cached metadata when the model drive is offline
A model on a switched-off drive lost its creator and license icons in the
modal even though "search by creator" found it: the list payload is
trimmed to id/modelId/name/trainedWords, and the only full-payload source
was the .metadata.json next to the model file.
- carry `creator` in the lightweight list payload so the card/modal
fallback keeps who published the model;
- add a cache fallback to GET /api/lm/{type}/metadata: when the sidecar
cannot be read (offline drive, unmounted share, missing/corrupt file),
rebuild the CivitAI payload from the cache entry and decode
`license_flags` back into the permission fields the icons render from.
The response reports `partial` and `offline` so callers can explain the
degradation instead of silently dropping fields;
- skip the stat() for paths outside every live root in alongside storage
so a dead share cannot stall the modal; centralized storage still reads
the real sidecar and serves the full payload while the drive is off;
- merge the fetched payload into the list data instead of replacing it
(a partial response must never drop cached fields), and show an
offline/partial notice plus a description-tab explanation.
This commit is contained in:
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "טוען תיאור מודל...",
|
||||
|
||||
@@ -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": "モデル説明を読み込み中...",
|
||||
|
||||
@@ -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": "모델 설명 로딩 중...",
|
||||
|
||||
@@ -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": "Загрузка описания модели...",
|
||||
|
||||
@@ -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": "正在加载模型描述...",
|
||||
|
||||
@@ -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": "載入模型描述中...",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 = `<div class="no-description">${failedText}</div>`;
|
||||
// The description lives in `.metadata.json`, next to the model file. If
|
||||
// that disk is offline the generic failure text hides the real cause,
|
||||
// so say what is going on (the modal already knows the provenance).
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
const offline = modalElement?.dataset.metadataOffline === 'true';
|
||||
const message = offline
|
||||
? translate(
|
||||
'modals.model.description.offline',
|
||||
{},
|
||||
'The description is stored with the model file, which is on an offline disk. Reconnect it to read the description.'
|
||||
)
|
||||
: translate('modals.model.description.failedToLoad', {}, 'Failed to load model description');
|
||||
descriptionContent.innerHTML = `<div class="no-description">${message}</div>`;
|
||||
} finally {
|
||||
// Hide loading state
|
||||
descriptionLoading?.classList.add('hidden');
|
||||
|
||||
@@ -490,12 +490,19 @@ export async function showModelModal(model, modelType) {
|
||||
cleanupNavigationShortcuts();
|
||||
detachModalHandlers(modalId);
|
||||
|
||||
// Fetch complete civitai metadata
|
||||
// Fetch complete civitai metadata. The backend falls back to the cached
|
||||
// payload when the sidecar is unreadable (offline drive, missing file), so
|
||||
// merge instead of replacing: a partial payload must never drop fields the
|
||||
// list response already carried (creator, ids, trigger words).
|
||||
let completeCivitaiData = model.civitai || {};
|
||||
let metadataPartial = false;
|
||||
let metadataOffline = false;
|
||||
if (model.file_path) {
|
||||
try {
|
||||
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
||||
completeCivitaiData = fullMetadata || model.civitai || {};
|
||||
const result = await getModelApiClient().fetchModelMetadataWithStatus(model.file_path);
|
||||
metadataPartial = Boolean(result?.partial);
|
||||
metadataOffline = Boolean(result?.offline);
|
||||
completeCivitaiData = { ...(model.civitai || {}), ...(result?.metadata || {}) };
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch complete metadata, using existing data:', error);
|
||||
// Continue with existing data if fetch fails
|
||||
@@ -507,6 +514,22 @@ export async function showModelModal(model, modelType) {
|
||||
...model,
|
||||
civitai: completeCivitaiData
|
||||
};
|
||||
// A model on a switched-off drive keeps its cached metadata, but nothing on
|
||||
// disk can be read or written until it is reconnected. Say so instead of
|
||||
// leaving the user wondering why the creator/description/examples are gone.
|
||||
const offlineNotice = metadataOffline
|
||||
? `
|
||||
<div class="modal-metadata-notice modal-metadata-notice--offline" role="status">
|
||||
<i class="fas fa-plug-circle-xmark" aria-hidden="true"></i>
|
||||
<span>${translate('modals.model.notices.offline', {}, 'The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.')}</span>
|
||||
</div>`
|
||||
: (metadataPartial
|
||||
? `
|
||||
<div class="modal-metadata-notice" role="status">
|
||||
<i class="fas fa-circle-info" aria-hidden="true"></i>
|
||||
<span>${translate('modals.model.notices.partial', {}, 'The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable.')}</span>
|
||||
</div>`
|
||||
: '');
|
||||
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
|
||||
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
||||
// Centralized sidecar storage moves .metadata.json into a mirror tree, so
|
||||
@@ -838,6 +861,7 @@ export async function showModelModal(model, modelType) {
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
${offlineNotice}
|
||||
<div class="info-section">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
@@ -1013,6 +1037,10 @@ export async function showModelModal(model, modelType) {
|
||||
const activeModalElement = document.getElementById(modalId);
|
||||
if (activeModalElement) {
|
||||
activeModalElement.dataset.filePath = modelWithFullData.file_path || '';
|
||||
// Metadata provenance: the description tab reads this to explain why the
|
||||
// text is unavailable instead of showing a generic "failed to load".
|
||||
activeModalElement.dataset.metadataPartial = metadataPartial ? 'true' : 'false';
|
||||
activeModalElement.dataset.metadataOffline = metadataOffline ? 'true' : 'false';
|
||||
// Store usage_tips for LoRA models
|
||||
if (modelType === 'loras' && modelWithFullData.usage_tips) {
|
||||
activeModalElement.dataset.usageTips = modelWithFullData.usage_tips;
|
||||
|
||||
@@ -285,6 +285,9 @@ class MockModelService:
|
||||
async def get_model_metadata(self, *_args, **_kwargs): # pragma: no cover
|
||||
return {}
|
||||
|
||||
async def get_model_metadata_with_fallback(self, *_args, **_kwargs): # pragma: no cover
|
||||
return {}, False, False
|
||||
|
||||
async def get_model_description(self, *_args, **_kwargs): # pragma: no cover
|
||||
return ""
|
||||
|
||||
|
||||
@@ -177,10 +177,10 @@ describe('Model metadata interactions keep file path in sync', () => {
|
||||
|
||||
it('uses the latest file path when saving notes', async () => {
|
||||
const saveModelMetadata = vi.fn().mockResolvedValue({ success: true });
|
||||
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
|
||||
const fetchModelMetadataWithStatus = vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false });
|
||||
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata,
|
||||
fetchModelMetadataWithStatus,
|
||||
saveModelMetadata,
|
||||
});
|
||||
|
||||
@@ -209,10 +209,10 @@ describe('Model metadata interactions keep file path in sync', () => {
|
||||
});
|
||||
|
||||
it('shows recipes tab for checkpoint modals and loads linked recipes by hash', async () => {
|
||||
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
|
||||
const fetchModelMetadataWithStatus = vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false });
|
||||
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata,
|
||||
fetchModelMetadataWithStatus,
|
||||
saveModelMetadata: vi.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('Model modal hash rendering', () => {
|
||||
copyToClipboard.mockReset();
|
||||
calculateModelHash = vi.fn();
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue(null),
|
||||
fetchModelMetadataWithStatus: vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false }),
|
||||
saveModelMetadata: vi.fn(),
|
||||
calculateModelHash,
|
||||
});
|
||||
|
||||
@@ -112,9 +112,9 @@ describe('Model modal license rendering', () => {
|
||||
});
|
||||
|
||||
it('handles aggregated commercial strings without extra restrictions (classic style)', async () => {
|
||||
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
|
||||
const fetchModelMetadataWithStatus = vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false });
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata,
|
||||
fetchModelMetadataWithStatus,
|
||||
saveModelMetadata: vi.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
MODAL_MODULE,
|
||||
API_FACTORY,
|
||||
UI_HELPERS_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
SHOWCASE_MODULE,
|
||||
MODEL_TAGS_MODULE,
|
||||
UTILS_MODULE,
|
||||
TRIGGER_WORDS_MODULE,
|
||||
PRESET_TAGS_MODULE,
|
||||
MODEL_VERSIONS_MODULE,
|
||||
RECIPE_TAB_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
MODAL_MODULE: new URL('../../../static/js/components/shared/ModelModal.js', import.meta.url).pathname,
|
||||
API_FACTORY: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
||||
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
|
||||
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
|
||||
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
|
||||
PRESET_TAGS_MODULE: new URL('../../../static/js/components/shared/PresetTags.js', import.meta.url).pathname,
|
||||
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
|
||||
RECIPE_TAB_MODULE: new URL('../../../static/js/components/shared/RecipeTab.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
openCivitai: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: vi.fn((id, html) => {
|
||||
document.body.innerHTML = `<div id="${id}">${html}</div>`;
|
||||
}),
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(SHOWCASE_MODULE, () => ({
|
||||
scrollToTop: vi.fn(),
|
||||
loadExampleImages: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_TAGS_MODULE, () => ({
|
||||
setupTagEditMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(UTILS_MODULE, async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
renderCompactTags: vi.fn(() => ''),
|
||||
setupTagTooltip: vi.fn(),
|
||||
formatFileSize: vi.fn(() => '1 MB'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(TRIGGER_WORDS_MODULE, () => ({
|
||||
renderTriggerWords: vi.fn(() => ''),
|
||||
setupTriggerWordsEditMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(PRESET_TAGS_MODULE, () => ({
|
||||
parsePresets: vi.fn(() => ({})),
|
||||
renderPresetTags: vi.fn(() => ''),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_VERSIONS_MODULE, () => ({
|
||||
initVersionsTab: vi.fn(() => ({
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(RECIPE_TAB_MODULE, () => ({
|
||||
loadRecipesForModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback || ''),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeModel(overrides = {}) {
|
||||
return {
|
||||
model_name: 'Offline Model',
|
||||
file_path: 'models/offline.safetensors',
|
||||
file_name: 'offline.safetensors',
|
||||
civitai: {
|
||||
modelId: 7,
|
||||
name: 'v1.0',
|
||||
creator: { username: 'artist' },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Model modal metadata fallback', () => {
|
||||
let getModelApiClient;
|
||||
let fetchModelMetadataWithStatus;
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = '';
|
||||
({ getModelApiClient } = await import(API_FACTORY));
|
||||
getModelApiClient.mockReset();
|
||||
fetchModelMetadataWithStatus = vi.fn();
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadataWithStatus,
|
||||
saveModelMetadata: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
async function renderModal(model) {
|
||||
const { showModelModal } = await import(MODAL_MODULE);
|
||||
await showModelModal(model, 'loras');
|
||||
return document.getElementById('modelModal');
|
||||
}
|
||||
|
||||
it('merges the cached payload into the list-level civitai data', async () => {
|
||||
fetchModelMetadataWithStatus.mockResolvedValue({
|
||||
metadata: {
|
||||
name: 'v2.0',
|
||||
creator: { username: 'artist' },
|
||||
},
|
||||
partial: true,
|
||||
offline: true,
|
||||
});
|
||||
|
||||
const modal = await renderModal(makeModel());
|
||||
|
||||
expect(modal.querySelector('.version-name-content').textContent).toBe('v2.0');
|
||||
expect(modal.querySelector('.creator-username').textContent).toBe('artist');
|
||||
// The list payload's model id must survive a partial response.
|
||||
expect(modal.querySelector('.civitai-id-entry .model-hash-value').textContent).toBe('7');
|
||||
});
|
||||
|
||||
it('shows the offline notice and flags the modal for the description tab', async () => {
|
||||
fetchModelMetadataWithStatus.mockResolvedValue({
|
||||
metadata: { name: 'v2.0' },
|
||||
partial: true,
|
||||
offline: true,
|
||||
});
|
||||
|
||||
const modal = await renderModal(makeModel());
|
||||
|
||||
const notice = modal.querySelector('.modal-metadata-notice');
|
||||
expect(notice).not.toBeNull();
|
||||
expect(notice.classList.contains('modal-metadata-notice--offline')).toBe(true);
|
||||
expect(notice.textContent).toContain('offline');
|
||||
expect(modal.dataset.metadataOffline).toBe('true');
|
||||
expect(modal.dataset.metadataPartial).toBe('true');
|
||||
});
|
||||
|
||||
it('shows the partial notice when the sidecar is unreadable but the disk is online', async () => {
|
||||
fetchModelMetadataWithStatus.mockResolvedValue({
|
||||
metadata: {},
|
||||
partial: true,
|
||||
offline: false,
|
||||
});
|
||||
|
||||
const modal = await renderModal(makeModel());
|
||||
|
||||
const notice = modal.querySelector('.modal-metadata-notice');
|
||||
expect(notice).not.toBeNull();
|
||||
expect(notice.classList.contains('modal-metadata-notice--offline')).toBe(false);
|
||||
expect(notice.textContent).toContain('could not be read');
|
||||
expect(modal.dataset.metadataOffline).toBe('false');
|
||||
});
|
||||
|
||||
it('renders no notice for complete metadata', async () => {
|
||||
fetchModelMetadataWithStatus.mockResolvedValue({
|
||||
metadata: { name: 'v1.0', description: 'About' },
|
||||
partial: false,
|
||||
offline: false,
|
||||
});
|
||||
|
||||
const modal = await renderModal(makeModel());
|
||||
|
||||
expect(modal.querySelector('.modal-metadata-notice')).toBeNull();
|
||||
expect(modal.dataset.metadataOffline).toBe('false');
|
||||
});
|
||||
|
||||
it('keeps the list data when the metadata request fails outright', async () => {
|
||||
fetchModelMetadataWithStatus.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const modal = await renderModal(makeModel());
|
||||
|
||||
expect(modal.querySelector('.creator-username').textContent).toBe('artist');
|
||||
expect(modal.querySelector('.modal-metadata-notice')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe('Model modal sidecar location entry', () => {
|
||||
getModelApiClient.mockReset();
|
||||
showToast.mockReset();
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue(null),
|
||||
fetchModelMetadataWithStatus: vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false }),
|
||||
saveModelMetadata: vi.fn(),
|
||||
});
|
||||
originalMode = state.global.settings.sidecar_storage_mode;
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('Model modal source links (#1094)', () => {
|
||||
const { getModelApiClient } = await import(API_FACTORY);
|
||||
getModelApiClient.mockReset();
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue(null),
|
||||
fetchModelMetadataWithStatus: vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false }),
|
||||
saveModelMetadata: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Route contract for the degraded ``/metadata`` response.
|
||||
|
||||
The model modal relies on the cached payload the service returns when the
|
||||
sidecar is unreadable, together with the ``partial`` / ``offline`` flags that
|
||||
tell the UI why the payload is incomplete.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from py.routes.handlers.model_handlers import ModelQueryHandler
|
||||
|
||||
|
||||
class DummyRequest:
|
||||
def __init__(self, query=None):
|
||||
self.query = query or {}
|
||||
|
||||
|
||||
class StubService:
|
||||
model_type = "lora"
|
||||
|
||||
def __init__(self, result=None, error=None):
|
||||
self._result = result
|
||||
self._error = error
|
||||
self.calls = []
|
||||
|
||||
async def get_model_metadata_with_fallback(self, file_path):
|
||||
self.calls.append(file_path)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._result
|
||||
|
||||
|
||||
def make_handler(service):
|
||||
return ModelQueryHandler(service=service, logger=logging.getLogger("test"))
|
||||
|
||||
|
||||
async def test_returns_partial_payload_with_provenance_flags():
|
||||
service = StubService(
|
||||
result=(
|
||||
{
|
||||
"id": 42,
|
||||
"modelId": 7,
|
||||
"name": "v2.0",
|
||||
"creator": {"username": "artist"},
|
||||
"model": {"type": "LORA", "allowNoCredit": False},
|
||||
},
|
||||
True,
|
||||
True,
|
||||
)
|
||||
)
|
||||
response = await make_handler(service).get_model_metadata(
|
||||
DummyRequest({"file_path": "models/offline.safetensors"})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["partial"] is True
|
||||
assert payload["offline"] is True
|
||||
assert payload["metadata"]["creator"] == {"username": "artist"}
|
||||
assert service.calls == ["models/offline.safetensors"]
|
||||
|
||||
|
||||
async def test_full_metadata_reports_no_degradation():
|
||||
service = StubService(result=({"name": "v1.0"}, False, False))
|
||||
response = await make_handler(service).get_model_metadata(
|
||||
DummyRequest({"file_path": "models/online.safetensors"})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["partial"] is False
|
||||
assert payload["offline"] is False
|
||||
|
||||
|
||||
async def test_missing_everywhere_still_404s():
|
||||
service = StubService(result=(None, False, True))
|
||||
response = await make_handler(service).get_model_metadata(
|
||||
DummyRequest({"file_path": "models/gone.safetensors"})
|
||||
)
|
||||
|
||||
assert response.status == 404
|
||||
|
||||
|
||||
async def test_missing_file_path_is_rejected():
|
||||
service = StubService(result=({}, False, False))
|
||||
response = await make_handler(service).get_model_metadata(DummyRequest({}))
|
||||
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
async def test_service_failure_maps_to_500():
|
||||
service = StubService(error=RuntimeError("boom"))
|
||||
response = await make_handler(service).get_model_metadata(
|
||||
DummyRequest({"file_path": "models/offline.safetensors"})
|
||||
)
|
||||
|
||||
assert response.status == 500
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Cached-metadata fallback for the model modal.
|
||||
|
||||
When the model's disk is offline the authoritative ``.metadata.json`` cannot be
|
||||
read, but the scanner still holds the cache entry. The modal must keep showing
|
||||
what the cache retains (creator, version name, trigger words, license
|
||||
permissions) instead of falling back to the stripped list payload.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.lora_service import LoraService
|
||||
from py.utils.civitai_utils import build_license_flags
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
|
||||
def make_service(entries, *, path_available=True):
|
||||
scanner = Mock()
|
||||
scanner.get_cached_data = AsyncMock(
|
||||
return_value=SimpleNamespace(raw_data=list(entries))
|
||||
)
|
||||
scanner.is_file_path_available = Mock(return_value=path_available)
|
||||
return LoraService(scanner)
|
||||
|
||||
|
||||
def make_entry(file_path):
|
||||
return {
|
||||
"file_path": file_path,
|
||||
"file_name": "artist_style",
|
||||
"model_name": "Artist Style",
|
||||
"base_model": "Illustrious",
|
||||
"license_flags": build_license_flags(
|
||||
{
|
||||
"allowNoCredit": False,
|
||||
"allowCommercialUse": ["Image", "Rent"],
|
||||
"allowDerivatives": False,
|
||||
"allowDifferentLicense": True,
|
||||
}
|
||||
),
|
||||
"civitai": {
|
||||
"id": 42,
|
||||
"modelId": 7,
|
||||
"name": "v2.0",
|
||||
"creator": {"username": "artist"},
|
||||
"trainedWords": ["artist style"],
|
||||
"model": {"type": "LORA"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def offline_model(tmp_path):
|
||||
"""A cache entry whose model file and sidecar are on an unreachable root."""
|
||||
file_path = str(tmp_path / "removable" / "artist_style.safetensors")
|
||||
return file_path, make_entry(file_path)
|
||||
|
||||
|
||||
async def test_metadata_falls_back_to_cache_entry(offline_model):
|
||||
file_path, entry = offline_model
|
||||
service = make_service([entry], path_available=False)
|
||||
|
||||
metadata, partial, offline = await service.get_model_metadata_with_fallback(file_path)
|
||||
|
||||
assert partial is True
|
||||
assert offline is True
|
||||
assert metadata["creator"] == {"username": "artist"}
|
||||
assert metadata["name"] == "v2.0"
|
||||
assert metadata["id"] == 42
|
||||
assert metadata["modelId"] == 7
|
||||
assert metadata["trainedWords"] == ["artist style"]
|
||||
assert metadata["baseModel"] == "Illustrious"
|
||||
|
||||
|
||||
async def test_fallback_decodes_license_permissions(offline_model):
|
||||
file_path, entry = offline_model
|
||||
service = make_service([entry], path_available=False)
|
||||
|
||||
metadata, _partial, _offline = await service.get_model_metadata_with_fallback(file_path)
|
||||
|
||||
assert metadata["model"] == {
|
||||
"type": "LORA",
|
||||
"allowNoCredit": False,
|
||||
"allowCommercialUse": ["Image", "Rent"],
|
||||
"allowDerivatives": False,
|
||||
"allowDifferentLicense": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_fallback_does_not_invent_license_data_without_civitai_model(tmp_path):
|
||||
file_path = str(tmp_path / "huggingface" / "flux.safetensors")
|
||||
entry = {
|
||||
"file_path": file_path,
|
||||
"file_name": "flux",
|
||||
"model_name": "Flux",
|
||||
"base_model": "Flux",
|
||||
"license_flags": 127,
|
||||
"civitai": {
|
||||
"trainedWords": ["trigger"],
|
||||
"creator": {"username": "someone"},
|
||||
},
|
||||
}
|
||||
service = make_service([entry], path_available=False)
|
||||
|
||||
metadata, partial, _offline = await service.get_model_metadata_with_fallback(file_path)
|
||||
|
||||
assert partial is True
|
||||
assert "model" not in metadata
|
||||
assert metadata["creator"] == {"username": "someone"}
|
||||
|
||||
|
||||
async def test_fallback_matches_forward_slashed_cache_path(offline_model):
|
||||
file_path, entry = offline_model
|
||||
service = make_service([entry], path_available=False)
|
||||
|
||||
# The API echoes the path as the card sent it (host separators on Windows).
|
||||
windows_style = file_path.replace("/", "\\")
|
||||
|
||||
metadata, partial, _offline = await service.get_model_metadata_with_fallback(
|
||||
windows_style
|
||||
)
|
||||
|
||||
assert partial is True
|
||||
assert metadata["name"] == "v2.0"
|
||||
|
||||
|
||||
async def test_unknown_path_still_reports_missing_metadata(tmp_path):
|
||||
service = make_service([], path_available=True)
|
||||
|
||||
metadata, partial, offline = await service.get_model_metadata_with_fallback(
|
||||
str(tmp_path / "elsewhere" / "nope.safetensors")
|
||||
)
|
||||
|
||||
assert metadata is None
|
||||
assert partial is False
|
||||
assert offline is False
|
||||
|
||||
|
||||
async def test_entry_without_civitai_data_reports_missing_metadata(tmp_path):
|
||||
file_path = str(tmp_path / "plain" / "hf-model.safetensors")
|
||||
entry = {
|
||||
"file_path": file_path,
|
||||
"file_name": "hf-model",
|
||||
"model_name": "HF Model",
|
||||
"base_model": "Flux",
|
||||
"civitai": None,
|
||||
}
|
||||
service = make_service([entry], path_available=False)
|
||||
|
||||
metadata, partial, offline = await service.get_model_metadata_with_fallback(file_path)
|
||||
|
||||
assert metadata is None
|
||||
assert partial is False
|
||||
assert offline is True
|
||||
|
||||
|
||||
async def test_sidecar_payload_wins_when_readable(tmp_path, offline_model):
|
||||
file_path, entry = offline_model
|
||||
sidecar = tmp_path / "removable" / "artist_style.metadata.json"
|
||||
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
||||
sidecar.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"file_name": "artist_style",
|
||||
"model_name": "Artist Style",
|
||||
"file_path": file_path,
|
||||
"size": 1234,
|
||||
"modified": 111.0,
|
||||
"sha256": "a" * 64,
|
||||
"base_model": "Illustrious",
|
||||
"preview_url": "",
|
||||
"civitai": {"name": "v3.0", "creator": {"username": "artist"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scanner_service = make_service([entry], path_available=True)
|
||||
scanner_service.scanner.sync_cache_from_metadata = AsyncMock(return_value=False)
|
||||
|
||||
metadata, partial, offline = await scanner_service.get_model_metadata_with_fallback(
|
||||
file_path
|
||||
)
|
||||
|
||||
assert partial is False
|
||||
assert offline is False
|
||||
assert metadata["name"] == "v3.0"
|
||||
|
||||
|
||||
async def test_offline_path_skips_the_disk_read_in_alongside_mode(
|
||||
offline_model, monkeypatch
|
||||
):
|
||||
"""A dead share must not be stat()ed just to open the modal."""
|
||||
file_path, entry = offline_model
|
||||
load_metadata = AsyncMock(return_value=(None, False))
|
||||
monkeypatch.setattr(
|
||||
"py.services.base_model_service.MetadataManager.load_metadata", load_metadata
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.services.base_model_service.is_centralized", lambda: False
|
||||
)
|
||||
|
||||
service = make_service([entry], path_available=False)
|
||||
metadata, partial, _offline = await service.get_model_metadata_with_fallback(file_path)
|
||||
|
||||
load_metadata.assert_not_awaited()
|
||||
assert partial is True
|
||||
assert metadata["creator"] == {"username": "artist"}
|
||||
|
||||
|
||||
async def test_centralized_storage_still_reads_the_sidecar_when_drive_is_offline(
|
||||
offline_model, monkeypatch
|
||||
):
|
||||
"""Centralized sidecars sit on the app disk, so the real payload survives."""
|
||||
file_path, entry = offline_model
|
||||
sidecar_payload = LoraMetadata(
|
||||
file_name="artist_style",
|
||||
model_name="Artist Style",
|
||||
file_path=file_path,
|
||||
size=1234,
|
||||
modified=111.0,
|
||||
sha256="a" * 64,
|
||||
base_model="Illustrious",
|
||||
preview_url="",
|
||||
civitai={"name": "v3.0", "creator": {"username": "artist"}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.services.base_model_service.MetadataManager.load_metadata",
|
||||
AsyncMock(return_value=(sidecar_payload, False)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.services.base_model_service.is_centralized", lambda: True
|
||||
)
|
||||
service = make_service([entry], path_available=False)
|
||||
service.scanner.sync_cache_from_metadata = AsyncMock(return_value=False)
|
||||
|
||||
metadata, partial, offline = await service.get_model_metadata_with_fallback(file_path)
|
||||
|
||||
assert partial is False
|
||||
assert offline is True
|
||||
assert metadata["name"] == "v3.0"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Decoding tests for the cached license bitfield.
|
||||
|
||||
The persistent cache keeps license permissions as a single integer. When the
|
||||
on-disk metadata is unreachable (offline drive) the modal renders its license
|
||||
icons from that integer, so the decode must reproduce the exact CivitAI payload
|
||||
shape ``build_license_flags`` encoded.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.civitai_utils import (
|
||||
build_license_flags,
|
||||
decode_license_flags,
|
||||
resolve_license_payload,
|
||||
)
|
||||
|
||||
PERMUTATIONS = [
|
||||
{
|
||||
"allowNoCredit": True,
|
||||
"allowCommercialUse": ["Sell"],
|
||||
"allowDerivatives": True,
|
||||
"allowDifferentLicense": True,
|
||||
},
|
||||
{
|
||||
"allowNoCredit": False,
|
||||
"allowCommercialUse": ["Image", "RentCivit", "Rent", "Sell"],
|
||||
"allowDerivatives": True,
|
||||
"allowDifferentLicense": True,
|
||||
},
|
||||
{
|
||||
"allowNoCredit": False,
|
||||
"allowCommercialUse": [],
|
||||
"allowDerivatives": False,
|
||||
"allowDifferentLicense": False,
|
||||
},
|
||||
{
|
||||
"allowNoCredit": True,
|
||||
"allowCommercialUse": ["Image", "Rent"],
|
||||
"allowDerivatives": False,
|
||||
"allowDifferentLicense": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", PERMUTATIONS)
|
||||
def test_decode_round_trips_build_license_flags(payload):
|
||||
assert decode_license_flags(build_license_flags(payload)) == payload
|
||||
|
||||
|
||||
def test_decode_maps_each_commercial_bit_to_its_civitai_value():
|
||||
base = {
|
||||
"allowNoCredit": False,
|
||||
"allowDerivatives": False,
|
||||
"allowDifferentLicense": False,
|
||||
}
|
||||
|
||||
for value in ("Image", "RentCivit", "Rent", "Sell"):
|
||||
decoded = decode_license_flags(build_license_flags({**base, "allowCommercialUse": [value]}))
|
||||
assert decoded["allowCommercialUse"] == [value]
|
||||
|
||||
|
||||
def test_decode_keeps_full_permission_combination():
|
||||
flags = build_license_flags(
|
||||
{
|
||||
"allowNoCredit": True,
|
||||
"allowCommercialUse": ["Image", "RentCivit", "Rent", "Sell"],
|
||||
"allowDerivatives": True,
|
||||
"allowDifferentLicense": True,
|
||||
}
|
||||
)
|
||||
|
||||
assert flags == 127
|
||||
assert decode_license_flags(flags) == {
|
||||
"allowNoCredit": True,
|
||||
"allowCommercialUse": ["Image", "RentCivit", "Rent", "Sell"],
|
||||
"allowDerivatives": True,
|
||||
"allowDifferentLicense": True,
|
||||
}
|
||||
|
||||
|
||||
def test_decode_falls_back_to_civitai_defaults_for_invalid_input():
|
||||
expected = decode_license_flags(build_license_flags({}))
|
||||
|
||||
assert decode_license_flags(None) == expected
|
||||
assert decode_license_flags("not-a-number") == expected
|
||||
assert expected == resolve_license_payload({})
|
||||
Reference in New Issue
Block a user