fix(recipes): resolve stale LoRA hash on import and add hashInvalid state

- import: prefer A1111 Lora hashes (12-char AutoV3) over conflicting Hashes
  JSON values; recover the quote-wrapped AutoV3 from CivitAI image API meta;
  merge EXIF-parsed LoRAs when the API-only parse yields none (meta=null)
- rematch: treat entries whose hash failed CivitAI resolution (hashInvalid)
  as unresolved candidates; clear the flag on rematch/reconnect write-back
- download: persist hashInvalid and show a distinct toast when hash lookup
  returns "Model not found", so unresolvable entries become recoverable
- ui: add Unresolvable Hash badge styling and reconnect affordance
- i18n: translate the new keys across all 10 locales
This commit is contained in:
Will Miao
2026-08-28 22:24:07 +08:00
parent a7d65fe84a
commit 856c9a87ac
24 changed files with 757 additions and 28 deletions
@@ -166,6 +166,77 @@ async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypa
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
@pytest.mark.asyncio
async def test_parse_metadata_lora_hashes_override_conflicting_hashes_json(monkeypatch):
"""When Hashes JSON carries a non-empty but stale hash and the Lora
hashes text field carries the real 12-char AutoV3 hash, the Lora hashes
value must win: CivitAI is queried with it and the entry is resolved
instead of being poisoned by the stale hash."""
lora_version_info = {
"id": 359072,
"modelId": 320224,
"model": {"name": "Daphne Blake Cosplay (Scooby Doo)", "type": "LORA"},
"name": "v1.0",
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
"baseModel": "SD 1.5",
"downloadUrl": "https://civitai.com/api/download/models/359072",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 1024,
"name": "Daphne Blake Cosplay_v1.safetensors",
"hashes": {"SHA256": "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276"},
}
],
}
queried_hashes = []
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
queried_hashes.append(model_hash)
if model_hash == "e67ebd5e315f":
return lora_version_info, None
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
parser = AutomaticMetadataParser()
metadata_text = (
"woman, natural blonde hair, ice blue eyes, <lora:Daphne Blake Cosplay_v1:1> "
"daphne blake cosplay, upper body\n"
"Negative prompt: low quality\n"
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 4140408634, "
"Size: 512x768, Model hash: 3c8530cb22, Model: cyberrealistic_v33, "
'Lora hashes: "Daphne Blake Cosplay_v1: e67ebd5e315f", '
'Hashes: {"lora:Daphne Blake Cosplay_v1": "a2a12bfa01"}'
)
result = await parser.parse_metadata(metadata_text)
assert "e67ebd5e315f" in queried_hashes, (
f"CivitAI must be queried with the Lora hashes value, got {queried_hashes}"
)
assert "a2a12bfa01" not in queried_hashes, (
"the stale Hashes JSON value must never be used for CivitAI lookup"
)
loras = result.get("loras", [])
assert len(loras) == 1
lora = loras[0]
assert lora["hash"] == "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276"
assert lora["id"] == 359072
assert lora["modelId"] == 320224
assert lora.get("isDeleted") in (None, False)
@pytest.mark.asyncio
async def test_parse_metadata_resolves_local_lora_with_empty_hash(monkeypatch):
async def fake_metadata_provider():
@@ -898,3 +898,84 @@ async def test_local_cache_dedup_same_hash_produces_one_entry_on_miss(monkeypatc
assert provider.hash_calls == ["missdedup123"]
assert len(result["loras"]) == 1
@pytest.mark.asyncio
async def test_quote_wrapped_lora_hashes_override_stale_hash(monkeypatch):
"""CivitAI's image API meta parser mangles the A1111 'Lora hashes' text
field into a quote-wrapped dict entry ('"Daphne Blake Cosplay_v1":
"e67ebd5e315f"'). The recovered 12-char AutoV3 must override the stale
10-char value in the hashes dict, so the lora resolves instead of being
marked deleted."""
current_sha256 = (
"533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276"
)
class Provider:
def __init__(self):
self.hash_calls = []
async def get_model_version_info(self, version_id):
if version_id == "138176":
return {
"id": 138176,
"modelId": 15003,
"model": {"name": "CyberRealistic", "type": "checkpoint"},
"name": "v3.3",
"baseModel": "SD 1.5",
"files": [
{
"type": "Model",
"primary": True,
"name": "cyberrealistic_v33.safetensors",
"hashes": {"SHA256": "3c8530cb2239b686d23a94627e29883fe44a1605f31a777727b6709f80d11679"},
}
],
}, None
return None, "Model not found"
async def get_model_by_hash(self, model_hash):
self.hash_calls.append(model_hash)
if model_hash == "e67ebd5e315f":
return {
"id": 359072,
"modelId": 320224,
"model": {"name": "Daphne Blake Cosplay (Scooby Doo)", "type": "lora"},
"name": "v1.0",
"baseModel": "SD 1.5",
"downloadUrl": "https://civitai.com/api/download/359072",
"files": [
{
"type": "Model",
"primary": True,
"name": "Daphne Blake Cosplay_v1.safetensors",
"hashes": {"SHA256": current_sha256.upper()},
}
],
}, None
return None, "Model not found"
metadata = {
"prompt": "test",
"steps": 20,
"sampler": "DPM++ 2M Karras",
"hashes": {
"model": "3c8530cb22",
"lora:Daphne Blake Cosplay_v1": "a2a12bfa01",
},
'"Daphne Blake Cosplay_v1': 'e67ebd5e315f"',
"modelVersionIds": [138176],
"browsingLevel": 1,
}
provider = Provider()
result = await _parse_with_cache(monkeypatch, provider, metadata, local_cache={})
assert len(result["loras"]) == 1
lora = result["loras"][0]
assert lora["hash"] == current_sha256
assert lora["id"] == 359072
assert lora.get("isDeleted") in (None, False)
assert "e67ebd5e315f" in provider.hash_calls
assert "a2a12bfa01" not in provider.hash_calls
+61
View File
@@ -331,6 +331,49 @@ async def test_update_lora_entry_updates_cache_and_file(tmp_path: Path, recipe_s
assert cached_recipe["fingerprint"] == expected_fingerprint
async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
recipe_id = "hash-invalid-1"
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_data = {
"id": recipe_id,
"file_path": str(tmp_path / "image.png"),
"title": "Hash invalid",
"modified": 0.0,
"created_date": 0.0,
"loras": [
{
"file_name": "Daphne Blake Cosplay_v1",
"strength": 1.0,
"hash": "a2a12bfa01",
},
],
}
recipe_path.write_text(json.dumps(recipe_data))
await scanner.add_recipe(dict(recipe_data))
updated_recipe, updated_lora = await scanner.set_lora_entry_hash_invalid(
recipe_id, 0, True
)
assert updated_lora["hashInvalid"] is True
assert updated_recipe["loras"][0]["hashInvalid"] is True
with recipe_path.open("r", encoding="utf-8") as file_obj:
persisted = json.load(file_obj)
assert persisted["loras"][0]["hashInvalid"] is True
assert persisted["loras"][0]["hash"] == "a2a12bfa01"
cache = await scanner.get_cached_data()
cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id)
assert cached_recipe["loras"][0]["hashInvalid"] is True
_, cleared_lora = await scanner.set_lora_entry_hash_invalid(recipe_id, 0, False)
assert cleared_lora["hashInvalid"] is False
@pytest.mark.asyncio
async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner):
scanner, _ = recipe_scanner
@@ -2212,6 +2255,24 @@ async def test_is_rematch_candidate_rejects_non_dict(tmp_path: Path):
assert not scanner._is_rematch_candidate(malformed)
async def test_is_rematch_candidate_hash_invalid_passes(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
assert scanner._is_rematch_candidate(
{"hash": "abc", "file_name": "m.safetensors", "hashInvalid": True}
)
async def test_is_rematch_candidate_healthy_not_in_library_rejected(tmp_path: Path):
# A healthy entry whose hash is simply absent from the local library
# (recipe imported without downloading the model) must not become a
# candidate: its CivitAI-valid hash would be at risk of being
# overwritten by the imprecise filename fallback.
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
assert not scanner._is_rematch_candidate(
{"hash": "abc", "file_name": "m.safetensors", "hashInvalid": False}
)
# _match_rematch_entry — L1 hash-cache lookup
+180
View File
@@ -1313,3 +1313,183 @@ async def test_reconnect_lora_distinguishes_ambiguous_mismatched_and_missing(tmp
await service.reconnect_lora(
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="missing"
)
@pytest.mark.asyncio
async def test_mark_lora_hash_invalid_delegates_and_reports(tmp_path):
service = RecipePersistenceService(
exif_utils=DummyExifUtils(),
card_preview_width=512,
logger=logging.getLogger("test"),
)
class DummyScanner:
async def set_lora_entry_hash_invalid(self, recipe_id, lora_index, hash_invalid):
assert recipe_id == "r1"
assert lora_index == 0
assert hash_invalid is True
return (
{"id": "r1", "loras": [{"file_name": "m", "hashInvalid": True}]},
{"file_name": "m", "hashInvalid": True},
)
result = await service.mark_lora_hash_invalid(
recipe_scanner=DummyScanner(), recipe_id="r1", lora_index=0
)
assert result.payload["success"] is True
assert result.payload["recipe_id"] == "r1"
assert result.payload["hash_invalid"] is True
assert result.payload["updated_lora"]["hashInvalid"] is True
@pytest.mark.asyncio
async def test_mark_lora_hash_invalid_can_clear_flag(tmp_path):
service = RecipePersistenceService(
exif_utils=DummyExifUtils(),
card_preview_width=512,
logger=logging.getLogger("test"),
)
class DummyScanner:
async def set_lora_entry_hash_invalid(self, recipe_id, lora_index, hash_invalid):
assert hash_invalid is False
return (
{"id": "r1", "loras": [{"file_name": "m", "hashInvalid": False}]},
{"file_name": "m", "hashInvalid": False},
)
result = await service.mark_lora_hash_invalid(
recipe_scanner=DummyScanner(),
recipe_id="r1",
lora_index=0,
hash_invalid=False,
)
assert result.payload["hash_invalid"] is False
assert result.payload["updated_lora"]["hashInvalid"] is False
@pytest.mark.asyncio
async def test_analyze_remote_image_meta_null_keeps_exif_loras(tmp_path, monkeypatch):
"""When the CivitAI image API meta is null (only modelVersionIds
present), the EXIF-parsed LoRAs must be merged into the result — they
were previously dropped because the API-only parse yields a checkpoint
but no LoRAs."""
A1111_METADATA = (
"woman, natural blonde hair, ice blue eyes, <lora:Daphne Blake Cosplay_v1:1> daphne blake cosplay, upper body\n"
"Negative prompt: low quality\n"
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 4140408634, "
"Size: 512x768, Model hash: 3c8530cb22, Model: cyberrealistic_v33, "
'Lora hashes: "Daphne Blake Cosplay_v1: e67ebd5e315f", '
'Hashes: {"lora:Daphne Blake Cosplay_v1": "a2a12bfa01"}'
)
LORA_SHA256 = "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276"
class FakeExif:
def extract_image_metadata(self, path):
return A1111_METADATA
class FakeDownloader:
async def download_file(self, url, path, use_auth=False):
with open(path, "wb") as fh:
fh.write(b"fake-image")
return True, None
async def downloader_factory():
return FakeDownloader()
class FakeCivitaiClient:
async def get_image_info(self, image_id, source_url=None):
return {
"id": 7076441,
"url": "https://image.civitai.com/x/original=true/x.jpeg",
"type": "image",
"meta": None,
"modelVersionIds": [138176],
"browsingLevel": 1,
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
if version_id == "138176":
return {
"id": 138176,
"modelId": 15003,
"model": {"name": "CyberRealistic", "type": "checkpoint"},
"name": "v3.3",
"baseModel": "SD 1.5",
"files": [
{
"type": "Model",
"primary": True,
"name": "cyberrealistic_v33.safetensors",
"hashes": {"SHA256": "3c8530cb2239b686d23a94627e29883fe44a1605f31a777727b6709f80d11679"},
}
],
}, None
return None, "Model not found"
async def get_model_by_hash(self, model_hash):
if model_hash == "e67ebd5e315f":
return {
"id": 359072,
"modelId": 320224,
"model": {"name": "Daphne Blake Cosplay (Scooby Doo)", "type": "lora"},
"name": "v1.0",
"baseModel": "SD 1.5",
"downloadUrl": "https://civitai.com/api/download/359072",
"files": [
{
"type": "Model",
"primary": True,
"name": "Daphne Blake Cosplay_v1.safetensors",
"hashes": {"SHA256": LORA_SHA256.upper()},
}
],
}, None
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
class DummyScanner:
async def build_local_hash_cache(self):
return {}
async def find_recipes_by_fingerprint(self, fp):
return []
async def get_local_lora(self, name, base_model=None):
return None
async def get_local_lora_by_hash(self, hash_value):
return None
from py.recipes.factory import RecipeParserFactory
service = RecipeAnalysisService(
exif_utils=FakeExif(),
recipe_parser_factory=RecipeParserFactory(),
downloader_factory=downloader_factory,
logger=logging.getLogger("test"),
)
result = await service.analyze_remote_image(
url="https://civitai.red/images/7076441",
recipe_scanner=DummyScanner(),
civitai_client=FakeCivitaiClient(),
)
payload = result.payload
assert payload.get("error") is None
loras = payload.get("loras") or []
assert len(loras) == 1
assert loras[0]["hash"] == LORA_SHA256
assert loras[0].get("isDeleted") in (None, False)
assert "Daphne" in str(payload.get("gen_params", {}).get("prompt"))