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
+6 -8
View File
@@ -146,15 +146,13 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# 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).
# Lora hashes carries the 12-char AutoV3
# hash (resolvable on CivitAI and the local
# autov3 index); the Hashes JSON value is
# only the 10-char AutoV2 prefix, so on
# conflict the Lora hashes value wins.
key = f"lora:{lora_name}"
existing = metadata["hashes"].get(key, "")
if not existing:
metadata["hashes"][key] = lora_hash
metadata["hashes"][key] = lora_hash
# Remove lora hashes from params section
params_section = params_section.replace(lora_hashes_match.group(0), '')
+21
View File
@@ -115,6 +115,27 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
):
metadata = inner_meta
# 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 12-char AutoV3 it carries is more reliable than the stale
# 10-char AutoV2 value in the "hashes" dict, so recover it and
# let it override the conflicting entry.
if isinstance(metadata, dict):
for key, hash_value in list(metadata.items()):
if (
isinstance(key, str)
and key.startswith('"')
and isinstance(hash_value, str)
and hash_value.endswith('"')
):
clean_name = key.strip('"').strip()
clean_hash = hash_value.strip('"').strip()
if clean_name and clean_hash:
hashes_dict = metadata.get("hashes")
if isinstance(hashes_dict, dict):
hashes_dict[f"lora:{clean_name}"] = clean_hash
# Initialize result structure
result: Dict[str, Any] = {
"base_model": None,
+45 -8
View File
@@ -113,6 +113,7 @@ class RecipeHandlerSet:
"update_recipe": self.management.update_recipe,
"record_recipe_open": self.management.record_recipe_open,
"reconnect_lora": self.management.reconnect_lora,
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
"find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk,
"bulk_delete": self.management.bulk_delete,
@@ -1592,6 +1593,35 @@ class RecipeManagementHandler:
self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def mark_lora_hash_invalid(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
for field in ("recipe_id", "lora_index"):
if field not in data:
raise RecipeValidationError(f"Missing required field: {field}")
result = await self._persistence_service.mark_lora_hash_invalid(
recipe_scanner=recipe_scanner,
recipe_id=data["recipe_id"],
lora_index=int(data["lora_index"]),
hash_invalid=bool(data.get("hash_invalid", True)),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception as exc:
self._logger.error(
"Error marking LoRA hash invalid: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def bulk_delete(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
@@ -2183,14 +2213,21 @@ class RecipeManagementHandler:
civitai_base_model = civitai_parsed.get("base_model")
if civitai_base_model and not metadata.get("base_model"):
metadata["base_model"] = civitai_base_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
# EXIF fills whatever the API-only parse left open — when the image
# API meta is null (only modelVersionIds present) the API parse
# yields a checkpoint but no LoRAs, while the image EXIF carries the
# full resource list.
if parsed_embedded:
if not metadata.get("loras"):
parsed_loras = parsed_embedded.get("loras")
if parsed_loras:
metadata["loras"] = parsed_loras
if not metadata.get("checkpoint"):
parsed_model = parsed_embedded.get("model")
if parsed_model:
metadata["checkpoint"] = parsed_model
if not metadata.get("base_model") and parsed_embedded.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"]
civitai_client = self._civitai_client_getter()
+3
View File
@@ -49,6 +49,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition(
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition(
+72 -3
View File
@@ -18,7 +18,11 @@ from ..utils.file_utils import calculate_autov3
from ..utils.recipe_open_stats import RecipeOpenStats
from .model_scanner import WEIGHT_FILE_EXTENSIONS
from .recipe_cache import RecipeCache
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
from .recipes.errors import (
RecipeNotFoundError,
RecipePersistenceError,
RecipeValidationError,
)
from .websocket_manager import ws_manager
from natsort import natsorted
import sys
@@ -241,11 +245,23 @@ class RecipeScanner:
return cache
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
"""Return True when a recipe entry is eligible for local re-matching."""
"""Return True when a recipe entry is eligible for local re-matching.
An entry counts as unresolved when its identity is known to be
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
identity fields (``hash``/``file_name``). A healthy entry whose
hash is simply not present in the local library is NOT a candidate:
it may be a recipe imported without downloading the model yet, and
its CivitAI-valid hash must never be overwritten by the imprecise
filename fallback.
"""
if not isinstance(entry, dict):
return False
unresolved = (
entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name")
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
has_identifier = (
entry.get("hash")
@@ -1262,6 +1278,7 @@ class RecipeScanner:
) -> None:
"""Write back a matched local model to a lora recipe entry."""
entry["isDeleted"] = False
entry["hashInvalid"] = False
# Only truthy hashes are written — pending/failed items carry an empty
# sha256 and an unconditional write would wipe a valid stored hash.
@@ -3661,6 +3678,7 @@ class RecipeScanner:
lora_entry = loras[lora_index]
lora_entry["isDeleted"] = False
lora_entry["hashInvalid"] = False
lora_entry["exclude"] = False
lora_entry["file_name"] = target_name
@@ -3712,6 +3730,57 @@ class RecipeScanner:
updated_lora = self._enrich_lora_entry(updated_lora)
return recipe_data, updated_lora
async def set_lora_entry_hash_invalid(
self,
recipe_id: str,
lora_index: int,
hash_invalid: bool,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Set the ``hashInvalid`` flag on a specific LoRA entry.
``hashInvalid`` records that the entry's hash could not be resolved
on CivitAI (e.g. a download attempt returned "Model not found").
Marking it makes the entry an unresolved rematch candidate without
touching its stored hash/file_name.
Returns:
The updated recipe data and the refreshed LoRA metadata.
"""
recipe_json_path = await self.get_recipe_json_path(recipe_id)
if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found")
async with self._mutation_lock:
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
recipe_data = json.load(file_obj)
loras = recipe_data.get("loras", [])
if lora_index >= len(loras):
raise RecipeNotFoundError("LoRA index out of range in recipe")
lora_entry = loras[lora_index]
if not isinstance(lora_entry, dict):
raise RecipeValidationError("LoRA entry is not a dict")
lora_entry["hashInvalid"] = bool(hash_invalid)
recipe_data["modified"] = time.time()
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
cache = await self.get_cached_data()
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
if not replaced:
await cache.add_recipe(recipe_data, resort=False)
self._schedule_resort()
if self._persistent_cache:
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
self._json_path_map[recipe_id] = recipe_json_path
updated_lora = self._enrich_lora_entry(dict(lora_entry))
return recipe_data, updated_lora
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
"""Return recipes that reference a given LoRA hash."""
+16
View File
@@ -270,6 +270,22 @@ class RecipeAnalysisService:
if merged_gp:
result.payload["gen_params"] = merged_gp
# The API-only parse (meta=null with only modelVersionIds)
# yields a checkpoint but no LoRAs; the image EXIF carries the
# full resource list. Fill the gaps the API parse left open.
if not result.payload.get("loras"):
exif_loras = exif_parsed_result.get("loras") or []
if exif_loras:
result.payload["loras"] = exif_loras
if not result.payload.get("checkpoint") and not result.payload.get("model"):
exif_checkpoint = exif_parsed_result.get("model") or exif_parsed_result.get(
"checkpoint"
)
if exif_checkpoint:
result.payload["checkpoint"] = exif_checkpoint
if not result.payload.get("base_model") and exif_parsed_result.get("base_model"):
result.payload["base_model"] = exif_parsed_result["base_model"]
if civitai_image_id and image_info and not result.payload.get("error"):
# Use the metadata dict we built (may contain modelVersionIds
# and browsingLevel from the API root level). Do NOT pass
@@ -470,6 +470,36 @@ class RecipePersistenceService:
}
)
async def mark_lora_hash_invalid(
self,
*,
recipe_scanner,
recipe_id: str,
lora_index: int,
hash_invalid: bool = True,
) -> PersistenceResult:
"""Mark a recipe LoRA entry's hash as unresolvable on CivitAI.
Called when a download attempt by hash returned "Model not found".
The flag makes the entry an unresolved rematch candidate without
altering its stored hash/file_name.
"""
recipe_data, updated_lora = await recipe_scanner.set_lora_entry_hash_invalid(
recipe_id,
lora_index,
hash_invalid=hash_invalid,
)
return PersistenceResult(
{
"success": True,
"recipe_id": recipe_id,
"hash_invalid": bool(hash_invalid),
"updated_lora": updated_lora,
}
)
async def bulk_delete(
self,
*,
@@ -793,6 +823,7 @@ class RecipePersistenceService:
"modelName": lora.get("name", ""),
"modelVersionName": lora.get("version", ""),
"isDeleted": lora.get("isDeleted", False),
"hashInvalid": lora.get("hashInvalid", False),
"exclude": lora.get("exclude", False),
}