chore(routes): dedupe os import

This commit is contained in:
pixelpaws
2025-09-22 12:15:12 +08:00
parent c3b9c73541
commit b92e7aa446
3 changed files with 227 additions and 127 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -1,7 +1,6 @@
import os import os
import time import time
import base64 import base64
import jinja2
import numpy as np import numpy as np
from PIL import Image from PIL import Image
import io import io
@@ -12,20 +11,18 @@ import tempfile
import json import json
import asyncio import asyncio
import sys import sys
from .base_recipe_routes import BaseRecipeRoutes
from .recipe_route_registrar import RecipeRouteRegistrar
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..recipes import RecipeParserFactory from ..recipes import RecipeParserFactory
from ..utils.constants import CARD_PREVIEW_WIDTH 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 ..config import config
from ..services.downloader import get_downloader
# Check if running in standalone mode # Check if running in standalone mode
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" 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 # Only import MetadataRegistry in non-standalone mode
if not standalone_mode: if not standalone_mode:
# Import metadata_collector functions and classes conditionally # Import metadata_collector functions and classes conditionally
@@ -35,109 +32,33 @@ if not standalone_mode:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RecipeRoutes:
"""API route handlers for Recipe management"""
def __init__(self): class RecipeRoutes(BaseRecipeRoutes):
# Initialize service references as None, will be set during async init """API route handlers for Recipe management."""
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()
@classmethod @classmethod
def setup_routes(cls, app: web.Application): def setup_routes(cls, app: web.Application):
"""Register API routes""" """Register API routes using the declarative registrar."""
routes = cls() 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) async def render_page(self, request: web.Request) -> web.Response:
app.router.add_get('/api/lm/recipe/{recipe_id}', routes.get_recipe_detail) """Handle GET /loras/recipes request."""
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"""
try: try:
# Initialize services first await self.ensure_dependencies_ready()
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)
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语言 # 设置服务端i18n语言
server_i18n.set_locale(user_language) self.server_i18n.set_locale(user_language)
# 为模板环境添加i18n过滤器 # 为模板环境添加i18n过滤器
if not hasattr(self.template_env, '_i18n_filter_added'): 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 self.template_env._i18n_filter_added = True
# Skip initialization check and directly try to get cached data # Skip initialization check and directly try to get cached data
@@ -148,10 +69,10 @@ class RecipeRoutes:
rendered = template.render( rendered = template.render(
recipes=[], # Frontend will load recipes via API recipes=[], # Frontend will load recipes via API
is_initializing=False, is_initializing=False,
settings=settings, settings=self.settings,
request=request, request=request,
# 添加服务端翻译函数 # 添加服务端翻译函数
t=server_i18n.get_translation, t=self.server_i18n.get_translation,
) )
except Exception as cache_error: except Exception as cache_error:
logger.error(f"Error loading recipe cache data: {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') template = self.template_env.get_template('recipes.html')
rendered = template.render( rendered = template.render(
is_initializing=True, is_initializing=True,
settings=settings, settings=self.settings,
request=request, request=request,
# 添加服务端翻译函数 # 添加服务端翻译函数
t=server_i18n.get_translation, t=self.server_i18n.get_translation,
) )
logger.info("Recipe cache error, returning initialization page") logger.info("Recipe cache error, returning initialization page")
@@ -178,11 +99,11 @@ class RecipeRoutes:
status=500 status=500
) )
async def get_recipes(self, request: web.Request) -> web.Response: async def list_recipes(self, request: web.Request) -> web.Response:
"""API endpoint for getting paginated recipes""" """API endpoint for getting paginated recipes."""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Get query parameters with defaults # Get query parameters with defaults
page = int(request.query.get('page', '1')) page = int(request.query.get('page', '1'))
@@ -250,11 +171,11 @@ class RecipeRoutes:
logger.error(f"Error retrieving recipes: {e}", exc_info=True) logger.error(f"Error retrieving recipes: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
async def get_recipe_detail(self, request: web.Request) -> web.Response: async def get_recipe(self, request: web.Request) -> web.Response:
"""Get detailed information about a specific recipe""" """Get detailed information about a specific recipe."""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id'] recipe_id = request.match_info['recipe_id']
@@ -305,12 +226,12 @@ class RecipeRoutes:
from datetime import datetime from datetime import datetime
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
async def analyze_recipe_image(self, request: web.Request) -> web.Response: async def analyze_uploaded_image(self, request: web.Request) -> web.Response:
"""Analyze an uploaded image or URL for recipe metadata""" """Analyze an uploaded image or URL for recipe metadata."""
temp_path = None temp_path = None
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Check if request contains multipart data (image) or JSON data (url) # Check if request contains multipart data (image) or JSON data (url)
content_type = request.headers.get('Content-Type', '') content_type = request.headers.get('Content-Type', '')
@@ -480,7 +401,7 @@ class RecipeRoutes:
"""Analyze a local image file for recipe metadata""" """Analyze a local image file for recipe metadata"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Get JSON data from request # Get JSON data from request
data = await request.json() data = await request.json()
@@ -573,7 +494,7 @@ class RecipeRoutes:
"""Save a recipe to the recipes folder""" """Save a recipe to the recipes folder"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
reader = await request.multipart() reader = await request.multipart()
@@ -779,7 +700,7 @@ class RecipeRoutes:
"""Delete a recipe by ID""" """Delete a recipe by ID"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id'] recipe_id = request.match_info['recipe_id']
@@ -829,7 +750,7 @@ class RecipeRoutes:
"""Get top tags used in recipes""" """Get top tags used in recipes"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Get limit parameter with default # Get limit parameter with default
limit = int(request.query.get('limit', '20')) limit = int(request.query.get('limit', '20'))
@@ -864,7 +785,7 @@ class RecipeRoutes:
"""Get base models used in recipes""" """Get base models used in recipes"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Get all recipes from cache # Get all recipes from cache
cache = await self.recipe_scanner.get_cached_data() 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""" """Process a recipe image for sharing by adding metadata to EXIF"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id'] recipe_id = request.match_info['recipe_id']
@@ -957,7 +878,7 @@ class RecipeRoutes:
"""Serve a processed recipe image for download""" """Serve a processed recipe image for download"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id'] recipe_id = request.match_info['recipe_id']
@@ -1016,7 +937,7 @@ class RecipeRoutes:
"""Save a recipe from the LoRAs widget""" """Save a recipe from the LoRAs widget"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Get metadata using the metadata collector instead of workflow parsing # Get metadata using the metadata collector instead of workflow parsing
raw_metadata = get_metadata() 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""" """Generate recipe syntax for LoRAs in the recipe, looking up proper file names using hash_index"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id'] recipe_id = request.match_info['recipe_id']
@@ -1299,7 +1220,7 @@ class RecipeRoutes:
"""Update recipe metadata (name and tags)""" """Update recipe metadata (name and tags)"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id'] recipe_id = request.match_info['recipe_id']
data = await request.json() data = await request.json()
@@ -1329,7 +1250,7 @@ class RecipeRoutes:
"""Reconnect a deleted LoRA in a recipe to a local LoRA file""" """Reconnect a deleted LoRA in a recipe to a local LoRA file"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Parse request data # Parse request data
data = await request.json() data = await request.json()
@@ -1438,7 +1359,7 @@ class RecipeRoutes:
"""Get recipes that use a specific Lora""" """Get recipes that use a specific Lora"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
lora_hash = request.query.get('hash') lora_hash = request.query.get('hash')
@@ -1487,7 +1408,7 @@ class RecipeRoutes:
"""API endpoint for scanning and rebuilding the recipe cache""" """API endpoint for scanning and rebuilding the recipe cache"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Force refresh the recipe cache # Force refresh the recipe cache
logger.info("Manually triggering recipe cache rebuild") logger.info("Manually triggering recipe cache rebuild")
@@ -1508,7 +1429,7 @@ class RecipeRoutes:
"""Find all duplicate recipes based on fingerprints""" """Find all duplicate recipes based on fingerprints"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Get all duplicate recipes # Get all duplicate recipes
duplicate_groups = await self.recipe_scanner.find_all_duplicate_recipes() duplicate_groups = await self.recipe_scanner.find_all_duplicate_recipes()
@@ -1566,7 +1487,7 @@ class RecipeRoutes:
"""Delete multiple recipes by ID""" """Delete multiple recipes by ID"""
try: try:
# Ensure services are initialized # Ensure services are initialized
await self.init_services() await self.ensure_dependencies_ready()
# Parse request data # Parse request data
data = await request.json() data = await request.json()
@@ -1650,3 +1571,9 @@ class RecipeRoutes:
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}, status=500) }, 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