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
+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),
}