fix(civitai): improve metadata parsing for nested structures, see #700

- Refactor metadata detection to handle nested "meta" objects
- Add support for lowercase "lora:" hash keys
- Extract metadata from nested "meta" field when present
- Update tests to verify nested metadata parsing
- Handle case-insensitive LORA hash detection

The changes ensure proper parsing of Civitai image metadata that may be wrapped in nested structures, improving compatibility with different API response formats.
This commit is contained in:
Will Miao
2025-11-26 13:46:08 +08:00
parent 5d4917c8d9
commit f8b9fa9b20
3 changed files with 97 additions and 9 deletions

View File

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

View File

@@ -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)

View File

@@ -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 = {