feat(recipes): add local-only recipe rematch to scanner

This commit is contained in:
Will Miao
2026-08-09 11:30:43 +08:00
parent 27027c4497
commit 64da845a58
3 changed files with 2002 additions and 1 deletions

View File

@@ -11,8 +11,10 @@ import os
import time import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from ..config import config from ..config import config
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
from ..utils.file_utils import calculate_autov3
from .recipe_cache import RecipeCache from .recipe_cache import RecipeCache
from .recipes.errors import RecipeNotFoundError from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
from natsort import natsorted from natsort import natsorted
import sys import sys
import re import re
@@ -26,6 +28,12 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Rematch type-gate alias map: Civitai model types are lowercased before the
# VALID_CHECKPOINT_SUB_TYPES membership check, and raw "DiffusionModel" would
# lowercase to "diffusionmodel", which is not a valid sub-type. Map it
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
class RecipeScanner: class RecipeScanner:
"""Service for scanning and managing recipe images""" """Service for scanning and managing recipe images"""
@@ -99,6 +107,13 @@ class RecipeScanner:
self._local_hash_cache: dict[str, dict[str, Any]] | None = None self._local_hash_cache: dict[str, dict[str, Any]] | None = None
self._local_hash_cache_versions: tuple[int, int] | None = None self._local_hash_cache_versions: tuple[int, int] | None = None
self._local_hash_cache_lock = asyncio.Lock() self._local_hash_cache_lock = asyncio.Lock()
# Computed autov3 map (absent/None-autov3 items only), rebuilt only
# when either model scanner's cache_version changes — the
# safetensors headers are read once per library scan, not once per
# recipe. Mirrors the build_local_hash_cache version pattern.
self._rematch_autov3_cache: dict[str, dict[str, Any]] | None = None
self._rematch_autov3_versions: tuple[int, int] | None = None
self._rematch_autov3_lock = asyncio.Lock()
self._initialized = True self._initialized = True
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]: async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
@@ -145,6 +160,125 @@ class RecipeScanner:
self._local_hash_cache_versions = versions self._local_hash_cache_versions = versions
return cache return cache
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
"""Return True when a recipe entry is eligible for local re-matching."""
if not isinstance(entry, dict):
return False
unresolved = (
entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name")
)
has_identifier = (
entry.get("hash") or entry.get("modelVersionId") or entry.get("id")
)
return bool(unresolved and has_identifier)
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
"""Build a version-cached map of computed AutoV3 hashes to local items.
Only absent/``None`` autov3 items are computed; ``''`` is the terminal
"checked but unavailable" state and is never recomputed. The dict is
reused while both scanners' cache_version values are unchanged, so the
safetensors headers are read once per library scan rather than once per
recipe. Computed values are lookup keys only — never persisted, never
written to items.
"""
async with self._rematch_autov3_lock:
lora_scanner = self._lora_scanner
checkpoint_scanner = self._checkpoint_scanner
versions = (
lora_scanner.cache_version if lora_scanner is not None else 0,
checkpoint_scanner.cache_version
if checkpoint_scanner is not None
else 0,
)
if (
self._rematch_autov3_cache is not None
and self._rematch_autov3_versions == versions
):
return self._rematch_autov3_cache
cache: dict[str, dict[str, Any]] = {}
for scanner in (lora_scanner, checkpoint_scanner):
if scanner is None:
continue
data = await scanner.get_cached_data()
for item in data.raw_data:
if not isinstance(item, dict):
continue
if "autov3" in item and item.get("autov3") is not None:
continue
file_path = item.get("file_path")
if not file_path:
continue
computed = await asyncio.to_thread(calculate_autov3, file_path)
key = (computed or "").lower()
if key:
cache[key] = item
self._rematch_autov3_cache = cache
self._rematch_autov3_versions = versions
return cache
async def _match_rematch_entry(
self,
entry: dict[str, Any],
local_cache: dict[str, Any],
autov3_cache: dict[str, Any],
*,
is_checkpoint: bool,
) -> Optional[dict[str, Any]]:
"""Match a recipe entry against local models across three levels.
L1 looks the stored hash up in the type-blind local hash cache; L2
falls back to the version index via ``modelVersionId`` or ``id``; L3
resolves 12-char hashes through the computed AutoV3 cache. Matched
items are type-verified against the entry kind before being returned.
"""
entry_hash = (entry.get("hash") or "").lower()
item = local_cache.get(entry_hash)
if item is None:
version_id = entry.get("modelVersionId") or entry.get("id")
if version_id is not None:
if is_checkpoint:
item = self._get_checkpoint_from_version_index(str(version_id))
else:
item = self._get_lora_from_version_index(str(version_id))
if item is None and len(entry_hash) == 12:
item = autov3_cache.get(entry_hash)
if item is None:
return None
# Type gate: the L1 cache merges lora and checkpoint items and is
# type-blind, so a match must be verified against the entry kind.
sub_type = (item.get("sub_type") or "").lower()
if sub_type:
valid = (
VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
)
if sub_type not in valid:
return None
else:
civitai_type = (
(item.get("civitai") or {}).get("model", {}) or {}
).get("type", "")
if civitai_type:
normalized = civitai_type.lower()
if is_checkpoint:
normalized = _CHECKPOINT_MODEL_TYPE_ALIASES.get(
normalized, normalized
)
valid = VALID_CHECKPOINT_SUB_TYPES
else:
valid = VALID_LORA_TYPES
if normalized not in valid:
return None
return item
def on_library_changed(self) -> None: def on_library_changed(self) -> None:
"""Reset cached state when the active library changes.""" """Reset cached state when the active library changes."""
@@ -411,6 +545,369 @@ class RecipeScanner:
return False return False
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache + computed autov3 cache) are built
BEFORE acquiring the mutation lock — both are read-only snapshots and
the version-cached hash dict would otherwise rebuild mid-run if a scan
bumps a scanner's cache_version while we hold the lock.
Args:
recipe_id: ID of the recipe to rematch
Returns:
Dict summary of the rematch result (success/rematched/skipped).
Raises RecipeNotFoundError when the recipe is missing.
"""
local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache()
async with self._mutation_lock:
# Get raw recipe from cache directly to avoid formatted fields
cache = await self.get_cached_data()
recipe = next(
(r for r in cache.raw_data if str(r.get("id", "")) == recipe_id), None
)
if not recipe:
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
try:
rematched, _errors = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache
)
except RecipePersistenceError as exc:
return {
"success": False,
"errors": 1,
"rematched": 0,
"skipped": 0,
"recipe": recipe,
"error": str(exc),
}
if rematched == 0:
return {
"success": True,
"rematched": 0,
"skipped": 1,
"recipe": recipe,
}
# Enriched re-fetch so the frontend receives file_url/preview fields.
return {
"success": True,
"rematched": rematched,
"skipped": 0,
"recipe": await self.get_recipe_by_id(recipe_id),
}
async def _rematch_single_recipe(
self,
recipe: Dict[str, Any],
local_cache: dict[str, dict[str, Any]],
autov3_cache: dict[str, dict[str, Any]],
) -> Tuple[int, int]:
"""Rematch a single recipe's lora/checkpoint entries against local models.
Shared per-recipe helper used by ``rematch_recipe_by_id`` and the bulk
rematch entry points. Mutates the recipe dict in place, recomputes the
fingerprint and persists via ``_save_recipe_persistently`` when any
entry changed. ``_schedule_resort`` is deliberately NOT called here —
it is hoisted to the public entry points.
Args:
recipe: The recipe dictionary to rematch (modified in-place)
local_cache: L1 hash cache snapshot (build_local_hash_cache)
autov3_cache: L3 computed-autov3 cache snapshot
Returns:
Tuple of (rematched_entries, errors). The errors element is always
0 on a normal return — a persistence failure RAISES
``RecipePersistenceError`` so callers can count it.
Raises:
RecipePersistenceError: when the recipe changed but
``_save_recipe_persistently`` returned False.
"""
rematched = 0
# Lora entries
loras = recipe.get("loras", [])
if isinstance(loras, list):
for entry in loras:
if not self._is_rematch_candidate(entry):
continue
item = await self._match_rematch_entry(
entry, local_cache, autov3_cache, is_checkpoint=False
)
if item is None:
continue
self._write_rematch_lora_entry(entry, item)
rematched += 1
# Checkpoint entry (dict only — legacy string checkpoints are skipped
# silently since ``entry.get`` on a str would raise AttributeError).
checkpoint = recipe.get("checkpoint")
if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint):
item = await self._match_rematch_entry(
checkpoint, local_cache, autov3_cache, is_checkpoint=True
)
if item is not None:
self._write_rematch_checkpoint_entry(checkpoint, item)
rematched += 1
if rematched == 0:
return (0, 0)
from ..utils.utils import calculate_recipe_fingerprint
recipe["fingerprint"] = calculate_recipe_fingerprint(recipe.get("loras", []))
saved = await self._save_recipe_persistently(recipe)
if not saved:
raise RecipePersistenceError(
f"Failed to persist recipe {recipe.get('id')} after rematch"
)
self._update_fts_index_for_recipe(recipe, "update")
return (rematched, 0)
async def rematch_all_recipes(
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache + computed autov3 cache) are built
ONCE before the loop — both are read-only and the version-cached hash
dict would otherwise rebuild mid-run if a scan bumps a scanner's
cache_version while the mutation lock is held. ``_schedule_resort`` is
called exactly once after the loop: it spawns an asyncio task per call,
so per-recipe calls would race one resort task per recipe.
Args:
progress_callback: Optional callback for progress updates
(started/processing/cancelled/completed events).
Returns:
Dict summary of the rematch run
(success/status/rematched/skipped/errors/total).
"""
if progress_callback:
await progress_callback({"status": "started"})
# Match snapshots built once and shared by every recipe in the loop.
local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache()
async with self._mutation_lock:
cache = await self.get_cached_data()
all_recipes = list(cache.raw_data)
total = len(all_recipes)
rematched_count = 0
skipped_count = 0
errors_count = 0
for i, recipe in enumerate(all_recipes):
if self.is_cancelled():
logger.info("Recipe rematch cancelled by user")
if progress_callback:
await progress_callback(
{
"status": "cancelled",
"current": i,
"total": total,
"rematched": rematched_count,
"skipped": skipped_count,
"errors": errors_count,
}
)
return {
"success": False,
"status": "cancelled",
"rematched": rematched_count,
"skipped": skipped_count,
"errors": errors_count,
"total": total,
}
try:
# Report progress
if progress_callback:
await progress_callback(
{
"status": "processing",
"current": i + 1,
"total": total,
"recipe_name": recipe.get("name", "Unknown"),
}
)
rematched, _ = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache
)
if rematched > 0:
rematched_count += 1
else:
skipped_count += 1
except Exception as exc:
logger.error(
f"Error rematching recipe {recipe.get('file_path')}: {exc}"
)
errors_count += 1
# Hoisted to one call — _schedule_resort spawns an asyncio task
# per call, so per-recipe calls would race 5k resort tasks.
self._schedule_resort()
# Final progress update
if progress_callback:
await progress_callback(
{
"status": "completed",
"rematched": rematched_count,
"skipped": skipped_count,
"errors": errors_count,
"total": total,
}
)
return {
"success": True,
"rematched": rematched_count,
"skipped": skipped_count,
"errors": errors_count,
"total": total,
}
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
"""Rematch a set of recipes by their IDs.
Iterates ``rematch_recipe_by_id`` over each id: not-found ids are
counted as skipped, and unexpected per-recipe exceptions are counted as
errors with the loop continuing so partial results are never lost.
Persist failures are already converted to the by_id return shape and
are counted via its ``errors`` field only — never double-counted here.
Args:
recipe_ids: List of recipe ids to rematch.
Returns:
Dict summary of the bulk run
(success/total/rematched/skipped/errors/recipes).
"""
total = len(recipe_ids)
rematched = 0
skipped = 0
errors = 0
recipes: List[Dict[str, Any]] = []
for recipe_id in recipe_ids:
try:
result = await self.rematch_recipe_by_id(recipe_id)
if result.get("success"):
rematched += result.get("rematched", 0)
skipped += result.get("skipped", 0)
if result.get("recipe"):
recipes.append(result["recipe"])
else:
errors += result.get("errors", 0)
except RecipeNotFoundError:
skipped += 1
except Exception as exc:
logger.error(f"Error rematching recipe {recipe_id}: {exc}")
errors += 1
self._schedule_resort()
return {
"success": True,
"total": total,
"rematched": rematched,
"skipped": skipped,
"errors": errors,
"recipes": recipes,
}
def _write_rematch_lora_entry(
self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None:
"""Write back a matched local model to a lora recipe entry."""
entry["isDeleted"] = False
# Only truthy hashes are written — pending/failed items carry an empty
# sha256 and an unconditional write would wipe a valid stored hash.
new_hash = (item.get("sha256") or "").lower()
if new_hash:
entry["hash"] = new_hash
if item.get("file_name"):
entry["file_name"] = item["file_name"]
civitai = item.get("civitai")
if isinstance(civitai, dict):
if civitai.get("id") is not None:
entry["modelVersionId"] = civitai["id"]
# modelName comes from the item, NOT civitai.model.name — the slim
# civitai payload drops model.name entirely.
if item.get("model_name"):
entry["modelName"] = item["model_name"]
if civitai.get("name"):
entry["modelVersionName"] = civitai["name"]
def _write_rematch_checkpoint_entry(
self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None:
"""Write back a matched local model to a checkpoint recipe entry.
Follows the pinned stored key set: parser-style entries carry
name/version/id/type/baseModel/file_name/hash; widget-style entries
additionally carry modelName/modelVersionName. Keys are only updated
when they already exist on the entry (or written fresh for the
identifier key when neither identifier form exists).
"""
entry["isDeleted"] = False
new_hash = (item.get("sha256") or "").lower()
if new_hash:
entry["hash"] = new_hash
if item.get("file_name"):
entry["file_name"] = item["file_name"]
civitai = item.get("civitai")
civ_name = civitai.get("name") if isinstance(civitai, dict) else None
civ_id = civitai.get("id") if isinstance(civitai, dict) else None
item_name = item.get("model_name")
item_base_model = item.get("base_model")
# Backfill name/version/baseModel only when the entry already has them.
if "name" in entry and item_name:
entry["name"] = item_name
if "version" in entry and civ_name:
entry["version"] = civ_name
if "baseModel" in entry and item_base_model:
entry["baseModel"] = item_base_model
# Widget-style entries (modelName/modelVersionName) get stale values
# refreshed; parser-style entries never gain them.
if "modelName" in entry and item_name:
entry["modelName"] = item_name
if "modelVersionName" in entry and civ_name:
entry["modelVersionName"] = civ_name
# Identifier key updated per the entry's existing convention.
if civ_id is not None:
if "modelVersionId" in entry:
entry["modelVersionId"] = civ_id
elif "id" in entry:
entry["id"] = civ_id
else:
entry["modelVersionId"] = civ_id
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool: async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
"""Helper to save a recipe to both JSON and EXIF metadata.""" """Helper to save a recipe to both JSON and EXIF metadata."""
recipe_id = recipe.get("id") recipe_id = recipe.get("id")

View File

@@ -20,3 +20,12 @@ class RecipeDownloadError(RecipeServiceError):
class RecipeConflictError(RecipeServiceError): class RecipeConflictError(RecipeServiceError):
"""Raised when a conflicting recipe state is detected.""" """Raised when a conflicting recipe state is detected."""
class RecipePersistenceError(RecipeServiceError):
"""Raised when a rematched recipe cannot be persisted to disk.
Raised by the recipe rematch path when ``_save_recipe_persistently``
returns False (JSON/EXIF/SQLite write failure). Callers translate it
into a ``success: False`` summary with an ``error`` message key.
"""

File diff suppressed because it is too large Load Diff