Compare commits

...

2 Commits

Author SHA1 Message Date
Will Miao
d916375abe fix(checkpoint): populate hash index from pre-computed metadata to prevent repeated hash re-calculation (#1002) 2026-07-20 12:24:54 +08:00
Will Miao
57983df4bd fix(recipe): resolve recipe metadata update bugs in cache sort, allowed fields, and bulk API routing
- Use safe .get() in RecipeCache._resort_locked instead of itemgetter to prevent KeyError when recipe missing created_date; align sort key with _sort_cache_sync (prefer modified, fallback created_date, fallback 0)
- Add base_model to allowed_fields in persistence_service.update_recipe() so the field passes validation
- Route bulk base model updates through updateRecipeMetadata() on recipes page instead of generic saveModelMetadata(), matching existing isRecipesPage pattern used in setBulkFavorites and saveBulkTags
2026-07-20 11:20:06 +08:00
4 changed files with 40 additions and 4 deletions

View File

@@ -114,6 +114,13 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed" and metadata.hash_status == "completed"
and metadata.sha256 and metadata.sha256
): ):
# Ensure the in-memory hash index is populated even when
# the hash was already computed and persisted to the metadata
# file. Without this, usage tracking (and any other caller
# that queries get_hash_by_filename first) will miss on every
# lookup and keep calling back into this method, creating a
# tight loop that never populates the index.
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
async with self._hash_calculation_lock: async with self._hash_calculation_lock:
@@ -125,6 +132,7 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed" and metadata.hash_status == "completed"
and metadata.sha256 and metadata.sha256
): ):
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
task = self._hash_calculation_tasks.get(real_path) task = self._hash_calculation_tasks.get(real_path)
@@ -175,6 +183,9 @@ class CheckpointScanner(ModelScanner):
# Check if hash is already calculated # Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256: if metadata.hash_status == "completed" and metadata.sha256:
# Populate the in-memory hash index even for pre-computed
# hashes, mirroring the fix in calculate_hash_for_model.
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
# Update status to calculating # Update status to calculating
@@ -193,6 +204,20 @@ class CheckpointScanner(ModelScanner):
# Update hash index # Update hash index
self._hash_index.add_entry(sha256.lower(), file_path) self._hash_index.add_entry(sha256.lower(), file_path)
# Update the in-memory cache entry so that subsequent
# _persist_current_cache / _save_persistent_cache calls
# write the hash back to the SQLite models table. Without
# this the hash only lives in the metadata file and the
# in-memory hash index, both of which are lost across
# restarts, causing the same re-computation loop on the
# next session.
if self._cache is not None and self._cache.raw_data:
for entry in self._cache.raw_data:
if entry.get("file_path") == file_path:
entry["sha256"] = sha256.lower()
entry["hash_status"] = "completed"
break
logger.info(f"Hash calculated for checkpoint: {file_path}") logger.info(f"Hash calculated for checkpoint: {file_path}")
return sha256 return sha256

View File

@@ -1,7 +1,6 @@
import asyncio import asyncio
from typing import Iterable, List, Dict, Optional from typing import Iterable, List, Dict, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted from natsort import natsorted
@@ -149,5 +148,10 @@ class RecipeCache:
) )
if not name_only: if not name_only:
self.sorted_by_date = sorted( self.sorted_by_date = sorted(
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True self.raw_data,
key=lambda x: (
x.get("modified", x.get("created_date", 0)),
x.get("file_path", ""),
),
reverse=True,
) )

View File

@@ -216,11 +216,12 @@ class RecipePersistenceService:
"preview_nsfw_level", "preview_nsfw_level",
"favorite", "favorite",
"gen_params", "gen_params",
"base_model",
) )
if not any(key in updates for key in allowed_fields): if not any(key in updates for key in allowed_fields):
raise RecipeValidationError( raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)" "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params or base_model)"
) )
if "gen_params" in updates and not isinstance(updates["gen_params"], dict): if "gen_params" in updates and not isinstance(updates["gen_params"], dict):

View File

@@ -1665,13 +1665,19 @@ export class BulkManager {
cancelled = true; cancelled = true;
}); });
const isRecipesPage = state.currentPageType === 'recipes';
for (const filepath of state.selectedModels) { for (const filepath of state.selectedModels) {
if (cancelled) { if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info'); showToast('toast.api.operationCancelled', {}, 'info');
break; break;
} }
try { try {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel }); if (isRecipesPage) {
await updateRecipeMetadata(filepath, { base_model: newBaseModel });
} else {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
}
successCount++; successCount++;
} catch (error) { } catch (error) {
errorCount++; errorCount++;