Files
willmiao-ComfyUI-Lora-Manager/tests/services/test_model_metadata_fallback.py
T
Will Miao e21be66012 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.
2026-11-14 03:16:27 +08:00

244 lines
7.8 KiB
Python

"""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"