diff --git a/py/routes/base_recipe_routes.py b/py/routes/base_recipe_routes.py new file mode 100644 index 00000000..e2b726da --- /dev/null +++ b/py/routes/base_recipe_routes.py @@ -0,0 +1,109 @@ +"""Base infrastructure shared across recipe routes.""" +from __future__ import annotations + +import logging +from typing import Callable, Mapping + +import jinja2 +from aiohttp import web + +from ..config import config +from ..services.server_i18n import server_i18n +from ..services.service_registry import ServiceRegistry +from ..services.settings_manager import settings +from .recipe_route_registrar import ROUTE_DEFINITIONS + +logger = logging.getLogger(__name__) + + +class BaseRecipeRoutes: + """Common dependency and startup wiring for recipe routes.""" + + _HANDLER_NAMES: tuple[str, ...] = tuple( + definition.handler_name for definition in ROUTE_DEFINITIONS + ) + + def __init__(self) -> None: + self.recipe_scanner = None + self.lora_scanner = None + self.civitai_client = None + self.settings = settings + self.server_i18n = server_i18n + self.template_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(config.templates_path), + autoescape=True, + ) + + self._i18n_registered = False + self._startup_hooks_registered = False + self._handler_mapping: dict[str, Callable] | None = None + + async def attach_dependencies(self, app: web.Application | None = None) -> None: + """Resolve shared services from the registry.""" + + await self._ensure_services() + self._ensure_i18n_filter() + + async def ensure_dependencies_ready(self) -> None: + """Ensure dependencies are available for request handlers.""" + + if self.recipe_scanner is None or self.civitai_client is None: + await self.attach_dependencies() + + def register_startup_hooks(self, app: web.Application) -> None: + """Register startup hooks once for dependency wiring.""" + + if self._startup_hooks_registered: + return + + app.on_startup.append(self.attach_dependencies) + app.on_startup.append(self.prewarm_cache) + self._startup_hooks_registered = True + + async def prewarm_cache(self, app: web.Application | None = None) -> None: + """Pre-load recipe and LoRA caches on startup.""" + + try: + await self.attach_dependencies(app) + + if self.lora_scanner is not None: + await self.lora_scanner.get_cached_data() + hash_index = getattr(self.lora_scanner, "_hash_index", None) + if hash_index is not None and hasattr(hash_index, "_hash_to_path"): + _ = len(hash_index._hash_to_path) + + if self.recipe_scanner is not None: + await self.recipe_scanner.get_cached_data(force_refresh=True) + except Exception as exc: + logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True) + + def to_route_mapping(self) -> Mapping[str, Callable]: + """Return a mapping of handler name to coroutine for registrar binding.""" + + if self._handler_mapping is None: + owner = self.get_handler_owner() + self._handler_mapping = { + name: getattr(owner, name) for name in self._HANDLER_NAMES + } + return self._handler_mapping + + # Internal helpers ------------------------------------------------- + + async def _ensure_services(self) -> None: + if self.recipe_scanner is None: + self.recipe_scanner = await ServiceRegistry.get_recipe_scanner() + self.lora_scanner = getattr(self.recipe_scanner, "_lora_scanner", None) + + if self.civitai_client is None: + self.civitai_client = await ServiceRegistry.get_civitai_client() + + def _ensure_i18n_filter(self) -> None: + if not self._i18n_registered: + self.template_env.filters["t"] = self.server_i18n.create_template_filter() + self._i18n_registered = True + + def get_handler_owner(self): + """Return the object supplying bound handler coroutines.""" + + return self + diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py new file mode 100644 index 00000000..471edf19 --- /dev/null +++ b/py/routes/recipe_route_registrar.py @@ -0,0 +1,64 @@ +"""Route registrar for recipe endpoints.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Mapping + +from aiohttp import web + + +@dataclass(frozen=True) +class RouteDefinition: + """Declarative definition for a recipe HTTP route.""" + + method: str + path: str + handler_name: str + + +ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( + RouteDefinition("GET", "/loras/recipes", "render_page"), + RouteDefinition("GET", "/api/lm/recipes", "list_recipes"), + RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"), + RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"), + RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"), + RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"), + RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), + RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), + RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"), + RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"), + RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"), + RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), + RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), + RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), + RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), + RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), + RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"), + RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"), + RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), +) + + +class RecipeRouteRegistrar: + """Bind declarative recipe definitions to an aiohttp router.""" + + _METHOD_MAP = { + "GET": "add_get", + "POST": "add_post", + "PUT": "add_put", + "DELETE": "add_delete", + } + + def __init__(self, app: web.Application) -> None: + self._app = app + + def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None: + for definition in ROUTE_DEFINITIONS: + handler = handler_lookup[definition.handler_name] + self._bind_route(definition.method, definition.path, handler) + + def _bind_route(self, method: str, path: str, handler: Callable) -> None: + add_method_name = self._METHOD_MAP[method.upper()] + add_method = getattr(self._app.router, add_method_name) + add_method(path, handler) + diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 21214d99..dcd0751a 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -1,7 +1,6 @@ import os import time import base64 -import jinja2 import numpy as np from PIL import Image import io @@ -12,20 +11,18 @@ import tempfile import json import asyncio import sys + +from .base_recipe_routes import BaseRecipeRoutes +from .recipe_route_registrar import RecipeRouteRegistrar from ..utils.exif_utils import ExifUtils from ..recipes import RecipeParserFactory from ..utils.constants import CARD_PREVIEW_WIDTH - -from ..services.settings_manager import settings -from ..services.server_i18n import server_i18n from ..config import config +from ..services.downloader import get_downloader # Check if running in standalone mode standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" -from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import -from ..services.downloader import get_downloader - # Only import MetadataRegistry in non-standalone mode if not standalone_mode: # Import metadata_collector functions and classes conditionally @@ -35,111 +32,35 @@ if not standalone_mode: logger = logging.getLogger(__name__) -class RecipeRoutes: - """API route handlers for Recipe management""" - def __init__(self): - # Initialize service references as None, will be set during async init - self.recipe_scanner = None - self.civitai_client = None - self.template_env = jinja2.Environment( - loader=jinja2.FileSystemLoader(config.templates_path), - autoescape=True - ) - - # Pre-warm the cache - self._init_cache_task = None - - async def init_services(self): - """Initialize services from ServiceRegistry""" - self.recipe_scanner = await ServiceRegistry.get_recipe_scanner() - self.civitai_client = await ServiceRegistry.get_civitai_client() +class RecipeRoutes(BaseRecipeRoutes): + """API route handlers for Recipe management.""" @classmethod def setup_routes(cls, app: web.Application): - """Register API routes""" + """Register API routes using the declarative registrar.""" + routes = cls() - app.router.add_get('/loras/recipes', routes.handle_recipes_page) + registrar = RecipeRouteRegistrar(app) + registrar.register_routes(routes.to_route_mapping()) + routes.register_startup_hooks(app) - app.router.add_get('/api/lm/recipes', routes.get_recipes) - app.router.add_get('/api/lm/recipe/{recipe_id}', routes.get_recipe_detail) - app.router.add_post('/api/lm/recipes/analyze-image', routes.analyze_recipe_image) - app.router.add_post('/api/lm/recipes/analyze-local-image', routes.analyze_local_image) - app.router.add_post('/api/lm/recipes/save', routes.save_recipe) - app.router.add_delete('/api/lm/recipe/{recipe_id}', routes.delete_recipe) - - # Add new filter-related endpoints - app.router.add_get('/api/lm/recipes/top-tags', routes.get_top_tags) - app.router.add_get('/api/lm/recipes/base-models', routes.get_base_models) - - # Add new sharing endpoints - app.router.add_get('/api/lm/recipe/{recipe_id}/share', routes.share_recipe) - app.router.add_get('/api/lm/recipe/{recipe_id}/share/download', routes.download_shared_recipe) - - # Add new endpoint for getting recipe syntax - app.router.add_get('/api/lm/recipe/{recipe_id}/syntax', routes.get_recipe_syntax) - - # Add new endpoint for updating recipe metadata (name, tags and source_path) - app.router.add_put('/api/lm/recipe/{recipe_id}/update', routes.update_recipe) - - # Add new endpoint for reconnecting deleted LoRAs - app.router.add_post('/api/lm/recipe/lora/reconnect', routes.reconnect_lora) - - # Add new endpoint for finding duplicate recipes - app.router.add_get('/api/lm/recipes/find-duplicates', routes.find_duplicates) - - # Add new endpoint for bulk deletion of recipes - app.router.add_post('/api/lm/recipes/bulk-delete', routes.bulk_delete) - - # Start cache initialization - app.on_startup.append(routes._init_cache) - - app.router.add_post('/api/lm/recipes/save-from-widget', routes.save_recipe_from_widget) - - # Add route to get recipes for a specific Lora - app.router.add_get('/api/lm/recipes/for-lora', routes.get_recipes_for_lora) - - # Add new endpoint for scanning and rebuilding the recipe cache - app.router.add_get('/api/lm/recipes/scan', routes.scan_recipes) - - async def _init_cache(self, app): - """Initialize cache on startup""" + async def render_page(self, request: web.Request) -> web.Response: + """Handle GET /loras/recipes request.""" try: - # Initialize services first - await self.init_services() - - # Now that services are initialized, get the lora scanner - lora_scanner = self.recipe_scanner._lora_scanner - - # Get lora cache to ensure it's initialized - lora_cache = await lora_scanner.get_cached_data() - - # Verify hash index is built - if hasattr(lora_scanner, '_hash_index'): - hash_index_size = len(lora_scanner._hash_index._hash_to_path) if hasattr(lora_scanner._hash_index, '_hash_to_path') else 0 - - # Now that lora scanner is initialized, initialize recipe cache - await self.recipe_scanner.get_cached_data(force_refresh=True) - except Exception as e: - logger.error(f"Error pre-warming recipe cache: {e}", exc_info=True) + await self.ensure_dependencies_ready() - async def handle_recipes_page(self, request: web.Request) -> web.Response: - """Handle GET /loras/recipes request""" - try: - # Ensure services are initialized - await self.init_services() - # 获取用户语言设置 - user_language = settings.get('language', 'en') - + user_language = self.settings.get('language', 'en') + # 设置服务端i18n语言 - server_i18n.set_locale(user_language) - + self.server_i18n.set_locale(user_language) + # 为模板环境添加i18n过滤器 if not hasattr(self.template_env, '_i18n_filter_added'): - self.template_env.filters['t'] = server_i18n.create_template_filter() + self._ensure_i18n_filter() self.template_env._i18n_filter_added = True - + # Skip initialization check and directly try to get cached data try: # Recipe scanner will initialize cache if needed @@ -148,10 +69,10 @@ class RecipeRoutes: rendered = template.render( recipes=[], # Frontend will load recipes via API is_initializing=False, - settings=settings, + settings=self.settings, request=request, # 添加服务端翻译函数 - t=server_i18n.get_translation, + t=self.server_i18n.get_translation, ) except Exception as cache_error: logger.error(f"Error loading recipe cache data: {cache_error}") @@ -159,10 +80,10 @@ class RecipeRoutes: template = self.template_env.get_template('recipes.html') rendered = template.render( is_initializing=True, - settings=settings, + settings=self.settings, request=request, # 添加服务端翻译函数 - t=server_i18n.get_translation, + t=self.server_i18n.get_translation, ) logger.info("Recipe cache error, returning initialization page") @@ -178,11 +99,11 @@ class RecipeRoutes: status=500 ) - async def get_recipes(self, request: web.Request) -> web.Response: - """API endpoint for getting paginated recipes""" + async def list_recipes(self, request: web.Request) -> web.Response: + """API endpoint for getting paginated recipes.""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get query parameters with defaults page = int(request.query.get('page', '1')) @@ -250,11 +171,11 @@ class RecipeRoutes: logger.error(f"Error retrieving recipes: {e}", exc_info=True) return web.json_response({"error": str(e)}, status=500) - async def get_recipe_detail(self, request: web.Request) -> web.Response: - """Get detailed information about a specific recipe""" + async def get_recipe(self, request: web.Request) -> web.Response: + """Get detailed information about a specific recipe.""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -305,12 +226,12 @@ class RecipeRoutes: from datetime import datetime return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') - async def analyze_recipe_image(self, request: web.Request) -> web.Response: - """Analyze an uploaded image or URL for recipe metadata""" + async def analyze_uploaded_image(self, request: web.Request) -> web.Response: + """Analyze an uploaded image or URL for recipe metadata.""" temp_path = None try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Check if request contains multipart data (image) or JSON data (url) content_type = request.headers.get('Content-Type', '') @@ -480,7 +401,7 @@ class RecipeRoutes: """Analyze a local image file for recipe metadata""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get JSON data from request data = await request.json() @@ -573,7 +494,7 @@ class RecipeRoutes: """Save a recipe to the recipes folder""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() reader = await request.multipart() @@ -779,7 +700,7 @@ class RecipeRoutes: """Delete a recipe by ID""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -829,7 +750,7 @@ class RecipeRoutes: """Get top tags used in recipes""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get limit parameter with default limit = int(request.query.get('limit', '20')) @@ -864,7 +785,7 @@ class RecipeRoutes: """Get base models used in recipes""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get all recipes from cache cache = await self.recipe_scanner.get_cached_data() @@ -895,7 +816,7 @@ class RecipeRoutes: """Process a recipe image for sharing by adding metadata to EXIF""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -957,7 +878,7 @@ class RecipeRoutes: """Serve a processed recipe image for download""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -1016,7 +937,7 @@ class RecipeRoutes: """Save a recipe from the LoRAs widget""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get metadata using the metadata collector instead of workflow parsing raw_metadata = get_metadata() @@ -1216,7 +1137,7 @@ class RecipeRoutes: """Generate recipe syntax for LoRAs in the recipe, looking up proper file names using hash_index""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -1299,7 +1220,7 @@ class RecipeRoutes: """Update recipe metadata (name and tags)""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] data = await request.json() @@ -1329,7 +1250,7 @@ class RecipeRoutes: """Reconnect a deleted LoRA in a recipe to a local LoRA file""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Parse request data data = await request.json() @@ -1438,7 +1359,7 @@ class RecipeRoutes: """Get recipes that use a specific Lora""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() lora_hash = request.query.get('hash') @@ -1487,7 +1408,7 @@ class RecipeRoutes: """API endpoint for scanning and rebuilding the recipe cache""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Force refresh the recipe cache logger.info("Manually triggering recipe cache rebuild") @@ -1508,7 +1429,7 @@ class RecipeRoutes: """Find all duplicate recipes based on fingerprints""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get all duplicate recipes duplicate_groups = await self.recipe_scanner.find_all_duplicate_recipes() @@ -1566,7 +1487,7 @@ class RecipeRoutes: """Delete multiple recipes by ID""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Parse request data data = await request.json() @@ -1650,3 +1571,9 @@ class RecipeRoutes: 'success': False, 'error': str(e) }, status=500) + + # Legacy method aliases retained for compatibility with existing imports. + handle_recipes_page = render_page + get_recipes = list_recipes + get_recipe_detail = get_recipe + analyze_recipe_image = analyze_uploaded_image