mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-23 22:22:11 -03:00
checkpoint
This commit is contained in:
@@ -6,8 +6,10 @@ from .routes.lora_routes import LoraRoutes
|
||||
from .routes.api_routes import ApiRoutes
|
||||
from .routes.recipe_routes import RecipeRoutes
|
||||
from .services.lora_scanner import LoraScanner
|
||||
from .services.recipe_scanner import RecipeScanner
|
||||
from .services.file_monitor import LoraFileMonitor
|
||||
from .services.lora_cache import LoraCache
|
||||
from .services.recipe_cache import RecipeCache
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -70,24 +72,27 @@ class LoraManager:
|
||||
app['lora_monitor'] = monitor
|
||||
|
||||
# Schedule cache initialization using the application's startup handler
|
||||
app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner))
|
||||
app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner, routes.recipe_scanner))
|
||||
|
||||
# Add cleanup
|
||||
app.on_shutdown.append(cls._cleanup)
|
||||
app.on_shutdown.append(ApiRoutes.cleanup)
|
||||
|
||||
@classmethod
|
||||
async def _schedule_cache_init(cls, scanner: LoraScanner):
|
||||
async def _schedule_cache_init(cls, scanner: LoraScanner, recipe_scanner: RecipeScanner):
|
||||
"""Schedule cache initialization in the running event loop"""
|
||||
try:
|
||||
# 创建低优先级的初始化任务
|
||||
asyncio.create_task(cls._initialize_cache(scanner), name='lora_cache_init')
|
||||
lora_task = asyncio.create_task(cls._initialize_lora_cache(scanner), name='lora_cache_init')
|
||||
|
||||
# Schedule recipe cache initialization with a delay to let lora scanner initialize first
|
||||
recipe_task = asyncio.create_task(cls._initialize_recipe_cache(recipe_scanner, delay=2), name='recipe_cache_init')
|
||||
except Exception as e:
|
||||
print(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
||||
logger.error(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _initialize_cache(cls, scanner: LoraScanner):
|
||||
"""Initialize cache in background"""
|
||||
async def _initialize_lora_cache(cls, scanner: LoraScanner):
|
||||
"""Initialize lora cache in background"""
|
||||
try:
|
||||
# 设置初始缓存占位
|
||||
scanner._cache = LoraCache(
|
||||
@@ -100,7 +105,26 @@ class LoraManager:
|
||||
# 分阶段加载缓存
|
||||
await scanner.get_cached_data(force_refresh=True)
|
||||
except Exception as e:
|
||||
print(f"LoRA Manager: Error initializing cache: {e}")
|
||||
logger.error(f"LoRA Manager: Error initializing lora cache: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _initialize_recipe_cache(cls, scanner: RecipeScanner, delay: float = 2.0):
|
||||
"""Initialize recipe cache in background with a delay"""
|
||||
try:
|
||||
# Wait for the specified delay to let lora scanner initialize first
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Set initial empty cache
|
||||
scanner._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
|
||||
# Force refresh to load the actual data
|
||||
await scanner.get_cached_data(force_refresh=True)
|
||||
except Exception as e:
|
||||
logger.error(f"LoRA Manager: Error initializing recipe cache: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _cleanup(cls, app):
|
||||
|
||||
@@ -38,6 +38,7 @@ class RecipeRoutes:
|
||||
app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail)
|
||||
app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
|
||||
app.router.add_post('/api/recipes/save', routes.save_recipe)
|
||||
app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe)
|
||||
|
||||
# Start cache initialization
|
||||
app.on_startup.append(routes._init_cache)
|
||||
@@ -435,8 +436,13 @@ class RecipeRoutes:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
# Add the new recipe directly to the cache instead of forcing a refresh
|
||||
cache = await self.recipe_scanner.get_cached_data()
|
||||
await cache.add_recipe(recipe_data)
|
||||
try:
|
||||
# Use a timeout to prevent deadlocks
|
||||
async with asyncio.timeout(5.0):
|
||||
cache = await self.recipe_scanner.get_cached_data()
|
||||
await cache.add_recipe(recipe_data)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout adding recipe to cache - will be picked up on next refresh")
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
@@ -447,4 +453,49 @@ class RecipeRoutes:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving recipe: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||
"""Delete a recipe by ID"""
|
||||
try:
|
||||
recipe_id = request.match_info['recipe_id']
|
||||
|
||||
# Get recipes directory
|
||||
recipes_dir = self.recipe_scanner.recipes_dir
|
||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||
return web.json_response({"error": "Recipes directory not found"}, status=404)
|
||||
|
||||
# Find recipe JSON file
|
||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_json_path):
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
|
||||
# Load recipe data to get image path
|
||||
with open(recipe_json_path, 'r', encoding='utf-8') as f:
|
||||
recipe_data = json.load(f)
|
||||
|
||||
# Get image path
|
||||
image_path = recipe_data.get('file_path')
|
||||
|
||||
# Delete recipe JSON file
|
||||
os.remove(recipe_json_path)
|
||||
logger.info(f"Deleted recipe JSON file: {recipe_json_path}")
|
||||
|
||||
# Delete recipe image if it exists
|
||||
if image_path and os.path.exists(image_path):
|
||||
os.remove(image_path)
|
||||
logger.info(f"Deleted recipe image: {image_path}")
|
||||
|
||||
# Remove from cache without forcing a full refresh
|
||||
try:
|
||||
# Use a timeout to prevent deadlocks
|
||||
async with asyncio.timeout(5.0):
|
||||
cache = await self.recipe_scanner.get_cached_data(force_refresh=False)
|
||||
await cache.remove_recipe(recipe_id)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout removing recipe from cache - will be picked up on next refresh")
|
||||
|
||||
return web.json_response({"success": True, "message": "Recipe deleted successfully"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting recipe: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -605,7 +605,7 @@ class LoraScanner:
|
||||
|
||||
# Update hash index with new path
|
||||
if 'sha256' in metadata:
|
||||
self._hash_index.add_entry(metadata['sha256'], new_path)
|
||||
self._hash_index.add_entry(metadata['sha256'].lower(), new_path)
|
||||
|
||||
# Update folders list
|
||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||
@@ -658,7 +658,27 @@ class LoraScanner:
|
||||
|
||||
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
|
||||
"""Get hash for a LoRA by its file path"""
|
||||
return self._hash_index.get_hash(file_path)
|
||||
return self._hash_index.get_hash(file_path)
|
||||
|
||||
def get_preview_url_by_hash(self, sha256: str) -> Optional[str]:
|
||||
"""Get preview static URL for a LoRA by its hash"""
|
||||
# Get the file path first
|
||||
file_path = self._hash_index.get_path(sha256.lower())
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
# Determine the preview file path (typically same name with different extension)
|
||||
base_name = os.path.splitext(file_path)[0]
|
||||
preview_extensions = ['.preview.png', '.preview.jpeg', '.preview.jpg', '.preview.mp4',
|
||||
'.png', '.jpeg', '.jpg', '.mp4']
|
||||
|
||||
for ext in preview_extensions:
|
||||
preview_path = f"{base_name}{ext}"
|
||||
if os.path.exists(preview_path):
|
||||
# Convert to static URL using config
|
||||
return config.get_preview_static_url(preview_path)
|
||||
|
||||
return None
|
||||
|
||||
# Add new method to get top tags
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
|
||||
@@ -58,4 +58,28 @@ class RecipeCache:
|
||||
"""
|
||||
async with self._lock:
|
||||
self.raw_data.append(recipe_data)
|
||||
await self.resort()
|
||||
await self.resort()
|
||||
|
||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||
"""Remove a recipe from the cache by ID
|
||||
|
||||
Args:
|
||||
recipe_id: The ID of the recipe to remove
|
||||
|
||||
Returns:
|
||||
bool: True if the recipe was found and removed, False otherwise
|
||||
"""
|
||||
# Find the recipe in raw_data
|
||||
recipe_index = next((i for i, recipe in enumerate(self.raw_data)
|
||||
if recipe.get('id') == recipe_id), None)
|
||||
|
||||
if recipe_index is None:
|
||||
return False
|
||||
|
||||
# Remove from raw_data
|
||||
self.raw_data.pop(recipe_index)
|
||||
|
||||
# Resort to update sorted lists
|
||||
await self.resort()
|
||||
|
||||
return True
|
||||
@@ -32,9 +32,12 @@ class RecipeScanner:
|
||||
self._cache: Optional[RecipeCache] = None
|
||||
self._initialization_lock = asyncio.Lock()
|
||||
self._initialization_task: Optional[asyncio.Task] = None
|
||||
self._is_initializing = False
|
||||
if lora_scanner:
|
||||
self._lora_scanner = lora_scanner
|
||||
self._initialized = True
|
||||
|
||||
# Initialization will be scheduled by LoraManager
|
||||
|
||||
@property
|
||||
def recipes_dir(self) -> str:
|
||||
@@ -51,86 +54,79 @@ class RecipeScanner:
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
||||
"""Get cached recipe data, refresh if needed"""
|
||||
async with self._initialization_lock:
|
||||
|
||||
# If cache is unitialized but needs to respond to request, return empty cache
|
||||
if self._cache is None and not force_refresh:
|
||||
return RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
|
||||
# If initializing, wait for completion
|
||||
if self._initialization_task and not self._initialization_task.done():
|
||||
try:
|
||||
await self._initialization_task
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe cache initialization failed: {e}")
|
||||
self._initialization_task = None
|
||||
|
||||
if (self._cache is None or force_refresh):
|
||||
|
||||
# Create new initialization task
|
||||
if not self._initialization_task or self._initialization_task.done():
|
||||
# First ensure the lora scanner is initialized
|
||||
if self._lora_scanner:
|
||||
await self._lora_scanner.get_cached_data()
|
||||
|
||||
self._initialization_task = asyncio.create_task(self._initialize_cache())
|
||||
|
||||
try:
|
||||
await self._initialization_task
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe cache initialization failed: {e}")
|
||||
# If cache already exists, continue using old cache
|
||||
if self._cache is None:
|
||||
raise # If no cache, raise exception
|
||||
|
||||
# If cache is already initialized and no refresh is needed, return it immediately
|
||||
if self._cache is not None and not force_refresh:
|
||||
return self._cache
|
||||
|
||||
async def _initialize_cache(self) -> None:
|
||||
"""Initialize or refresh the cache"""
|
||||
try:
|
||||
# Ensure lora scanner is fully initialized first
|
||||
if self._lora_scanner:
|
||||
logger.info("Recipe Manager: Waiting for lora scanner initialization to complete")
|
||||
|
||||
# Get the lora cache to ensure it's initialized
|
||||
lora_cache = await self._lora_scanner.get_cached_data()
|
||||
logger.info(f"Recipe Manager: Lora scanner initialized with {len(lora_cache.raw_data)} loras")
|
||||
|
||||
# Verify hash index is built
|
||||
if hasattr(self._lora_scanner, '_hash_index'):
|
||||
hash_index_size = len(self._lora_scanner._hash_index._hash_to_path) if hasattr(self._lora_scanner._hash_index, '_hash_to_path') else 0
|
||||
logger.info(f"Recipe Manager: Lora hash index contains {hash_index_size} entries")
|
||||
else:
|
||||
logger.warning("Recipe Manager: No lora hash index available")
|
||||
else:
|
||||
logger.warning("Recipe Manager: No lora scanner available")
|
||||
|
||||
# Scan for recipe data
|
||||
raw_data = await self.scan_all_recipes()
|
||||
|
||||
# Update cache
|
||||
self._cache = RecipeCache(
|
||||
raw_data=raw_data,
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
|
||||
# Resort cache
|
||||
await self._cache.resort()
|
||||
|
||||
self._initialization_task = None
|
||||
logger.info("Recipe Manager: Cache initialization completed")
|
||||
# If another initialization is already in progress, wait for it to complete
|
||||
if self._is_initializing and not force_refresh:
|
||||
logger.info("Initialization already in progress, returning current cache state")
|
||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||
|
||||
# Try to acquire the lock with a timeout to prevent deadlocks
|
||||
try:
|
||||
# Use a timeout for acquiring the lock
|
||||
async with asyncio.timeout(1.0):
|
||||
async with self._initialization_lock:
|
||||
# Check again after acquiring the lock
|
||||
if self._cache is not None and not force_refresh:
|
||||
return self._cache
|
||||
|
||||
# Mark as initializing to prevent concurrent initializations
|
||||
self._is_initializing = True
|
||||
|
||||
try:
|
||||
# First ensure the lora scanner is initialized
|
||||
if self._lora_scanner:
|
||||
try:
|
||||
logger.info("Recipe Manager: Waiting for lora scanner initialization")
|
||||
lora_cache = await asyncio.wait_for(
|
||||
self._lora_scanner.get_cached_data(),
|
||||
timeout=10.0
|
||||
)
|
||||
logger.info(f"Recipe Manager: Lora scanner initialized with {len(lora_cache.raw_data)} loras")
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Timeout waiting for lora scanner initialization")
|
||||
except Exception as e:
|
||||
logger.error(f"Error waiting for lora scanner: {e}")
|
||||
|
||||
# Scan for recipe data
|
||||
logger.info("Recipe Manager: Starting recipe scan")
|
||||
raw_data = await self.scan_all_recipes()
|
||||
|
||||
# Update cache
|
||||
self._cache = RecipeCache(
|
||||
raw_data=raw_data,
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
|
||||
# Resort cache
|
||||
await self._cache.resort()
|
||||
|
||||
logger.info(f"Recipe Manager: Cache initialization completed with {len(raw_data)} recipes")
|
||||
return self._cache
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True)
|
||||
# Create empty cache on error
|
||||
self._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
return self._cache
|
||||
finally:
|
||||
# Mark initialization as complete
|
||||
self._is_initializing = False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# If we can't acquire the lock in time, return the current cache or an empty one
|
||||
logger.warning("Timeout acquiring initialization lock - returning current cache state")
|
||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True)
|
||||
self._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||
|
||||
async def scan_all_recipes(self) -> List[Dict]:
|
||||
"""Scan all recipe JSON files and return metadata"""
|
||||
@@ -215,35 +211,6 @@ class RecipeScanner:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return None
|
||||
|
||||
def _create_basic_recipe_data(self, image_path: str) -> Dict:
|
||||
"""Create basic recipe data from file information"""
|
||||
file_name = os.path.basename(image_path)
|
||||
title = os.path.splitext(file_name)[0]
|
||||
|
||||
return {
|
||||
'file_path': image_path.replace(os.sep, '/'),
|
||||
'title': title,
|
||||
'file_name': file_name,
|
||||
'modified': os.path.getmtime(image_path),
|
||||
'created_date': os.path.getctime(image_path),
|
||||
'loras': []
|
||||
}
|
||||
|
||||
def _extract_created_date(self, user_comment: str) -> Optional[float]:
|
||||
"""Extract creation date from UserComment if present"""
|
||||
try:
|
||||
# Look for Created Date pattern
|
||||
created_date_match = re.search(r'Created Date: ([^,}]+)', user_comment)
|
||||
if created_date_match:
|
||||
date_str = created_date_match.group(1).strip()
|
||||
# Parse ISO format date
|
||||
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return dt.timestamp()
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting creation date: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def _update_lora_information(self, recipe_data: Dict) -> bool:
|
||||
"""Update LoRA information with hash and file_name
|
||||
|
||||
@@ -510,6 +477,7 @@ class RecipeScanner:
|
||||
for lora in item['loras']:
|
||||
if 'hash' in lora and lora['hash']:
|
||||
lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora['hash'].lower())
|
||||
lora['preview_url'] = self._lora_scanner.get_preview_url_by_hash(lora['hash'].lower())
|
||||
|
||||
result = {
|
||||
'items': paginated_items,
|
||||
|
||||
Reference in New Issue
Block a user