diff --git a/py/recipes/parsers/automatic.py b/py/recipes/parsers/automatic.py index f86d134d..19368214 100644 --- a/py/recipes/parsers/automatic.py +++ b/py/recipes/parsers/automatic.py @@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser): if model_hash_from_hashes: metadata["model_hash"] = model_hash_from_hashes - # Extract Lora hashes in alternative format + # Extract Lora hashes in alternative format. + # Run unconditionally (not just as fallback) so that + # non-empty hashes from Lora hashes fill in the gaps left + # by empty values in the Hashes JSON dict. Some WebUI + # builds write real hash values only to Lora hashes and + # leave the Hashes JSON values empty. lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section) - if not hashes_match and lora_hashes_match: + if lora_hashes_match: try: lora_hashes_str = lora_hashes_match.group(1) lora_hash_entries = lora_hashes_str.split(', ') - - # Initialize hashes dict if it doesn't exist - if "hashes" not in metadata: - metadata["hashes"] = {} - + # Parse each lora hash entry (format: "name: hash") for entry in lora_hash_entries: if ': ' in entry: lora_name, lora_hash = entry.split(': ', 1) - # Add as lora type in the same format as regular hashes - metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip() - + lora_hash = lora_hash.strip() + if not lora_hash: + # Skip entries without a hash value + continue + # Initialize hashes dict if it doesn't exist + if "hashes" not in metadata: + metadata["hashes"] = {} + # Add as lora type in the same format as + # regular hashes. Only override an + # existing entry if its value is empty + # (Lora hashes is the more reliable + # source when Hashes JSON has blanks). + key = f"lora:{lora_name}" + existing = metadata["hashes"].get(key, "") + if not existing: + metadata["hashes"][key] = lora_hash + # Remove lora hashes from params section params_section = params_section.replace(lora_hashes_match.group(0), '') except Exception as e: @@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser): # Only process lora or hypernet types if not hash_key.startswith(("lora:", "hypernet:")): continue + + # Skip entries without a hash value — they can't be + # resolved via CivitAI and would only produce a + # useless "Deleted" entry in the recipe. + if not lora_hash: + continue lora_type, lora_name = hash_key.split(':', 1) @@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser): # Try to get info from Civitai if metadata_provider: try: - if lora_hash: - # If we have hash, use it for lookup - civitai_info = await metadata_provider.get_model_by_hash(lora_hash) - else: - civitai_info = None + civitai_info = await metadata_provider.get_model_by_hash(lora_hash) populated_entry = await self.populate_lora_from_civitai( lora_entry, diff --git a/tests/services/test_automatic_metadata_parser.py b/tests/services/test_automatic_metadata_parser.py index c708ce73..68ed0511 100644 --- a/tests/services/test_automatic_metadata_parser.py +++ b/tests/services/test_automatic_metadata_parser.py @@ -64,6 +64,74 @@ async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeyp assert result["loras"] == [] +@pytest.mark.asyncio +async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypatch): + """When Hashes JSON has empty lora hashes but Lora hashes text field has + real ones, the real hashes should be used and those LoRAs resolved + correctly; entries with empty hashes in both sources should be skipped.""" + lora_version_info = { + "id": 947620, + "modelId": 98765, + "model": {"name": "cfg_scale_boost", "type": "LORA"}, + "name": "v1", + "images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}], + "baseModel": "illustrious", + "downloadUrl": "https://civitai.com/api/download/models/947620", + "files": [ + { + "type": "Model", + "primary": True, + "sizeKB": 1024, + "name": "cfg_scale_boost.safetensors", + "hashes": {"SHA256": "4605b2de07"}, + } + ], + } + + async def fake_metadata_provider(): + class Provider: + async def get_model_by_hash(self, model_hash): + assert model_hash == "4605b2de07" + return lora_version_info, None + + async def get_model_version_info(self, version_id): + raise AssertionError("get_model_version_info should not be called") + + return Provider() + + monkeypatch.setattr( + "py.recipes.parsers.automatic.get_default_metadata_provider", + fake_metadata_provider, + ) + + parser = AutomaticMetadataParser() + + metadata_text = ( + "a cyberpunk portrait \n" + "Negative prompt: low quality\n" + "Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 512x768, " + "Model hash: abc123, Model: test.safetensors, " + 'Lora hashes: "cfg_scale_boost: 4605b2de07, EmptyLora: ", ' + 'Hashes: {"model": "abc123", "lora:cfg_scale_boost": "", "lora:EmptyLora": "", "lora:UnusedLora": ""}' + ) + + result = await parser.parse_metadata(metadata_text) + + # cfg_scale_boost should be resolved (hash from Lora hashes overrode empty Hashes JSON) + loras = result.get("loras", []) + assert len(loras) == 1, f"Expected 1 LoRA, got {len(loras)}" + lora = loras[0] + assert lora["name"] == "cfg_scale_boost", f"Expected cfg_scale_boost, got {lora['name']}" + assert lora["hash"] == "4605b2de07", f"Expected hash 4605b2de07, got {lora['hash']}" + assert lora.get("isDeleted") in (None, False), f"LoRA should not be deleted" + assert lora["weight"] == 0.6, f"Expected weight 0.6, got {lora['weight']}" + + # EmptyLora and UnusedLora should be skipped (no hash in either source) + lora_names = [l["name"] for l in loras] + assert "EmptyLora" not in lora_names, "EmptyLora should have been skipped" + assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped" + + @pytest.mark.asyncio async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch): checkpoint_info = {