diff --git a/py/recipes/parsers/civitai_image.py b/py/recipes/parsers/civitai_image.py index 8c1e1f8c..41e1e9f2 100644 --- a/py/recipes/parsers/civitai_image.py +++ b/py/recipes/parsers/civitai_image.py @@ -23,13 +23,34 @@ class CivitaiApiMetadataParser(RecipeMetadataParser): """ if not metadata or not isinstance(metadata, dict): return False - - # Check for key markers specific to Civitai image metadata - return any([ - "resources" in metadata, - "civitaiResources" in metadata, - "additionalResources" in metadata - ]) + + def has_markers(payload: Dict[str, Any]) -> bool: + return any( + key in payload + for key in ( + "resources", + "civitaiResources", + "additionalResources", + ) + ) + + if has_markers(metadata): + return True + + hashes = metadata.get("hashes") + if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes): + return True + + nested_meta = metadata.get("meta") + if isinstance(nested_meta, dict): + if has_markers(nested_meta): + return True + + hashes = nested_meta.get("hashes") + if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes): + return True + + return False async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]: """Parse metadata from Civitai image format @@ -45,6 +66,26 @@ class CivitaiApiMetadataParser(RecipeMetadataParser): try: # Get metadata provider instead of using civitai_client directly metadata_provider = await get_default_metadata_provider() + + # Civitai image responses may wrap the actual metadata inside a "meta" key + if ( + isinstance(metadata, dict) + and "meta" in metadata + and isinstance(metadata["meta"], dict) + ): + inner_meta = metadata["meta"] + if any( + key in inner_meta + for key in ( + "resources", + "civitaiResources", + "additionalResources", + "hashes", + "prompt", + "negativePrompt", + ) + ): + metadata = inner_meta # Initialize result structure result = { @@ -62,8 +103,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser): lora_hashes = {} if "hashes" in metadata and isinstance(metadata["hashes"], dict): for key, hash_value in metadata["hashes"].items(): - if key.startswith("LORA:"): - lora_name = key.replace("LORA:", "") + key_str = str(key) + if key_str.lower().startswith("lora:"): + lora_name = key_str.split(":", 1)[1] lora_hashes[lora_name] = hash_value # Extract prompt and negative prompt diff --git a/py/services/recipes/analysis_service.py b/py/services/recipes/analysis_service.py index 77d80e34..b7c76afd 100644 --- a/py/services/recipes/analysis_service.py +++ b/py/services/recipes/analysis_service.py @@ -107,6 +107,12 @@ class RecipeAnalysisService: raise RecipeDownloadError("No image URL found in Civitai response") await self._download_image(image_url, temp_path) metadata = image_info.get("meta") if "meta" in image_info else None + if ( + isinstance(metadata, dict) + and "meta" in metadata + and isinstance(metadata["meta"], dict) + ): + metadata = metadata["meta"] else: await self._download_image(url, temp_path) diff --git a/tests/services/test_civitai_image_parser.py b/tests/services/test_civitai_image_parser.py index e222765b..f42782d8 100644 --- a/tests/services/test_civitai_image_parser.py +++ b/tests/services/test_civitai_image_parser.py @@ -60,6 +60,46 @@ async def test_parse_metadata_creates_loras_from_hashes(monkeypatch): } +@pytest.mark.asyncio +async def test_parse_metadata_handles_nested_meta_and_lowercase_hashes(monkeypatch): + async def fake_metadata_provider(): + return None + + monkeypatch.setattr( + "py.recipes.parsers.civitai_image.get_default_metadata_provider", + fake_metadata_provider, + ) + + parser = CivitaiApiMetadataParser() + + metadata = { + "id": 106706587, + "meta": { + "prompt": "An enigmatic silhouette", + "hashes": { + "model": "ee75fd24a4", + "lora:mj": "de49e1e98c", + "LORA:Another_Earth_2": "dc11b64a8b", + }, + "resources": [ + { + "hash": "ee75fd24a4", + "name": "stoiqoNewrealityFLUXSD35_f1DAlphaTwo", + "type": "model", + } + ], + }, + } + + assert parser.is_metadata_matching(metadata) + + result = await parser.parse_metadata(metadata) + + assert result["gen_params"]["prompt"] == "An enigmatic silhouette" + assert {l["name"] for l in result["loras"]} == {"mj", "Another_Earth_2"} + assert {l["hash"] for l in result["loras"]} == {"de49e1e98c", "dc11b64a8b"} + + @pytest.mark.asyncio async def test_parse_metadata_populates_checkpoint_and_rewrites_thumbnails(monkeypatch): checkpoint_info = {