mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
a17399d667
Recipes imported from CivitAI image URLs can contain 0 LoRAs: the backend
only sees the REST image API + EXIF, while the complete generation data
lives in the image page's internal trpc payload (see
docs/recipe-civitai-image-no-metadata.md). When the companion
lm-civitai-extension is installed with a valid license, re-import (single
and bulk) of CivitAI-image-sourced recipes is now delegated to the
extension via DOM CustomEvents; the extension scrapes the image page with
the user's session and calls back into the reimport endpoint with the
full metadata payload. Without the extension (or with an invalid license)
the native path runs unchanged.
- POST /api/lm/recipe/{id}/reimport accepts optional payload params
(image_url/name/resources/gen_params/base_model/tags); the payload path
reuses the import-remote engine with reimport semantics (user-edit
carryover, delete-after-save), and malformed/failed payloads fall back
to the legacy URL import. Response gains loras_count.
- The endpoint also accepts GET: the extension is GET-only by convention
(documented in AGENTS.md).
- New static/js/utils/extensionReimportBridge.js (probeExtension /
delegateReimport / getCivitaiImageInfo) wired into RecipeContextMenu
and BulkManager with silent native fallback.
- i18n: toast.recipes.reimportingViaExtension added and translated in
all 9 locales.
3394 lines
145 KiB
Python
3394 lines
145 KiB
Python
"""Dedicated handler objects for recipe-related routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import asyncio
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
|
|
|
|
from aiohttp import web
|
|
|
|
from ...config import config
|
|
from ...services.server_i18n import server_i18n as default_server_i18n
|
|
from ...services.settings_manager import SettingsManager, get_settings_manager
|
|
from ...services.recipes import (
|
|
RecipeAnalysisService,
|
|
RecipeDownloadError,
|
|
RecipeNotFoundError,
|
|
RecipePersistenceService,
|
|
RecipeSharingService,
|
|
RecipeValidationError,
|
|
)
|
|
from ...services.metadata_service import get_default_metadata_provider
|
|
from ...services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
|
|
from ...utils.civitai_utils import (
|
|
build_civitai_image_page_url,
|
|
extract_civitai_image_id,
|
|
extract_civitai_image_id_from_cdn_url,
|
|
rewrite_preview_url,
|
|
)
|
|
from ...utils.constants import NSFW_LEVELS
|
|
from ...utils.exif_utils import ExifUtils
|
|
from ...utils.recipe_open_stats import RecipeOpenStats
|
|
from ...recipes.merger import GenParamsMerger
|
|
from ...recipes.enrichment import RecipeEnricher
|
|
from ...services.websocket_manager import ws_manager as default_ws_manager
|
|
from ...services.batch_import_service import BatchImportService
|
|
|
|
Logger = logging.Logger
|
|
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
|
RecipeScannerGetter = Callable[[], Any]
|
|
CivitaiClientGetter = Callable[[], Any]
|
|
|
|
|
|
class PromptServerProtocol(Protocol):
|
|
"""Subset of PromptServer used by the recipe workflow handler."""
|
|
|
|
instance: "PromptServerProtocol"
|
|
|
|
def send_sync(
|
|
self, event: str, payload: dict[str, Any] | None = None, sid: str | None = None
|
|
) -> None: # pragma: no cover - protocol
|
|
...
|
|
|
|
# Cap concurrent preview-dimension reads across requests. With a cold LRU
|
|
# cache one page can touch up to page_size image files; 16 balances SSD and
|
|
# HDD throughput without starving the event loop.
|
|
_DIMS_READ_SEMAPHORE = asyncio.Semaphore(16)
|
|
|
|
|
|
async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
|
|
"""Read preview dimensions off the event loop under the concurrency cap.
|
|
|
|
PIL I/O runs in a worker thread so it never blocks the event loop, and the
|
|
semaphore bounds how many files are opened at once even when many list
|
|
requests land together.
|
|
"""
|
|
async with _DIMS_READ_SEMAPHORE:
|
|
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RecipeHandlerSet:
|
|
"""Group of handlers providing recipe route implementations."""
|
|
|
|
page_view: "RecipePageView"
|
|
listing: "RecipeListingHandler"
|
|
query: "RecipeQueryHandler"
|
|
management: "RecipeManagementHandler"
|
|
analysis: "RecipeAnalysisHandler"
|
|
sharing: "RecipeSharingHandler"
|
|
batch_import: "BatchImportHandler"
|
|
workflow: "RecipeWorkflowHandler"
|
|
|
|
def to_route_mapping(
|
|
self,
|
|
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
|
"""Expose handler coroutines keyed by registrar handler names."""
|
|
|
|
return {
|
|
"render_page": self.page_view.render_page,
|
|
"list_recipes": self.listing.list_recipes,
|
|
"get_recipe": self.listing.get_recipe,
|
|
"import_remote_recipe": self.management.import_remote_recipe,
|
|
"analyze_uploaded_image": self.analysis.analyze_uploaded_image,
|
|
"analyze_local_image": self.analysis.analyze_local_image,
|
|
"save_recipe": self.management.save_recipe,
|
|
"delete_recipe": self.management.delete_recipe,
|
|
"get_top_tags": self.query.get_top_tags,
|
|
"search_tags": self.query.search_tags,
|
|
"get_base_models": self.query.get_base_models,
|
|
"get_roots": self.query.get_roots,
|
|
"get_folders": self.query.get_folders,
|
|
"get_folder_tree": self.query.get_folder_tree,
|
|
"get_unified_folder_tree": self.query.get_unified_folder_tree,
|
|
"share_recipe": self.sharing.share_recipe,
|
|
"download_shared_recipe": self.sharing.download_shared_recipe,
|
|
"get_recipe_syntax": self.query.get_recipe_syntax,
|
|
"update_recipe": self.management.update_recipe,
|
|
"record_recipe_open": self.management.record_recipe_open,
|
|
"reconnect_lora": self.management.reconnect_lora,
|
|
"restore_lora": self.management.restore_lora,
|
|
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
|
|
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
|
|
"reconnect_checkpoint": self.management.reconnect_checkpoint,
|
|
"restore_checkpoint": self.management.restore_checkpoint,
|
|
"get_checkpoint_reconnect_suggestions": self.management.get_checkpoint_reconnect_suggestions,
|
|
"mark_checkpoint_hash_invalid": self.management.mark_checkpoint_hash_invalid,
|
|
"find_duplicates": self.query.find_duplicates,
|
|
"move_recipes_bulk": self.management.move_recipes_bulk,
|
|
"bulk_delete": self.management.bulk_delete,
|
|
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
|
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
|
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
|
|
"scan_recipes": self.query.scan_recipes,
|
|
"move_recipe": self.management.move_recipe,
|
|
"rematch_recipes": self.management.rematch_recipes,
|
|
"cancel_rematch": self.management.cancel_rematch,
|
|
"rematch_recipe": self.management.rematch_recipe,
|
|
"rematch_recipes_bulk": self.management.rematch_recipes_bulk,
|
|
"get_rematch_progress": self.management.get_rematch_progress,
|
|
"start_batch_import": self.batch_import.start_batch_import,
|
|
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
|
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
|
"start_directory_import": self.batch_import.start_directory_import,
|
|
"browse_directory": self.batch_import.browse_directory,
|
|
"check_image_exists": self.management.check_image_exists,
|
|
"import_from_url": self.management.import_from_url,
|
|
"create_from_example": self.management.create_from_example,
|
|
"reimport_recipe": self.management.reimport_recipe,
|
|
"send_recipe_workflow": self.workflow.send_recipe_workflow,
|
|
}
|
|
|
|
|
|
class RecipePageView:
|
|
"""Render the recipe shell page."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
|
settings_service: SettingsManager,
|
|
server_i18n=default_server_i18n,
|
|
template_env,
|
|
template_name: str,
|
|
recipe_scanner_getter: RecipeScannerGetter,
|
|
logger: Logger,
|
|
) -> None:
|
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
|
self._settings = settings_service
|
|
self._server_i18n = server_i18n
|
|
self._template_env = template_env
|
|
self._template_name = template_name
|
|
self._recipe_scanner_getter = recipe_scanner_getter
|
|
self._logger = logger
|
|
|
|
async def render_page(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None: # pragma: no cover - defensive guard
|
|
raise RuntimeError("Recipe scanner not available")
|
|
|
|
user_language = self._settings.get("language", "en")
|
|
self._server_i18n.set_locale(user_language)
|
|
|
|
# While the initial scan is running, show the initialization
|
|
# screen (same as the model pages) instead of an empty grid; the
|
|
# page reloads itself when the scanner broadcasts completion.
|
|
is_initializing = (
|
|
recipe_scanner._cache is None or recipe_scanner.is_initializing()
|
|
)
|
|
|
|
try:
|
|
if not is_initializing:
|
|
await recipe_scanner.get_cached_data(force_refresh=False)
|
|
rendered = self._template_env.get_template(self._template_name).render(
|
|
recipes=[],
|
|
is_initializing=is_initializing,
|
|
settings=self._settings,
|
|
request=request,
|
|
t=self._server_i18n.get_translation,
|
|
)
|
|
except Exception as cache_error: # pragma: no cover - logging path
|
|
self._logger.error("Error loading recipe cache data: %s", cache_error)
|
|
rendered = self._template_env.get_template(self._template_name).render(
|
|
is_initializing=True,
|
|
settings=self._settings,
|
|
request=request,
|
|
t=self._server_i18n.get_translation,
|
|
)
|
|
return web.Response(text=rendered, content_type="text/html")
|
|
except Exception as exc: # pragma: no cover - logging path
|
|
self._logger.error("Error handling recipes request: %s", exc, exc_info=True)
|
|
return web.Response(text="Error loading recipes page", status=500)
|
|
|
|
|
|
class RecipeListingHandler:
|
|
"""Provide listing and detail APIs for recipes."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
|
recipe_scanner_getter: RecipeScannerGetter,
|
|
logger: Logger,
|
|
) -> None:
|
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
|
self._recipe_scanner_getter = recipe_scanner_getter
|
|
self._logger = logger
|
|
|
|
async def list_recipes(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
page = int(request.query.get("page", "1"))
|
|
page_size = int(request.query.get("page_size", "20"))
|
|
sort_by = request.query.get("sort_by", "date")
|
|
search = request.query.get("search")
|
|
folder = request.query.get("folder")
|
|
recursive = request.query.get("recursive", "true").lower() == "true"
|
|
|
|
search_options = {
|
|
"title": request.query.get("search_title", "true").lower() == "true",
|
|
"tags": request.query.get("search_tags", "true").lower() == "true",
|
|
"lora_name": request.query.get("search_lora_name", "true").lower()
|
|
== "true",
|
|
"lora_model": request.query.get("search_lora_model", "true").lower()
|
|
== "true",
|
|
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
|
}
|
|
|
|
filters: Dict[str, Any] = {}
|
|
base_models = request.query.get("base_models")
|
|
if base_models:
|
|
filters["base_model"] = base_models.split(",")
|
|
|
|
if request.query.get("favorite", "false").lower() == "true":
|
|
filters["favorite"] = True
|
|
|
|
tag_filters: Dict[str, str] = {}
|
|
legacy_tags = request.query.get("tags")
|
|
if legacy_tags:
|
|
for tag in legacy_tags.split(","):
|
|
tag = tag.strip()
|
|
if tag:
|
|
tag_filters[tag] = "include"
|
|
|
|
include_tags = request.query.getall("tag_include", [])
|
|
for tag in include_tags:
|
|
if tag:
|
|
tag_filters[tag] = "include"
|
|
|
|
exclude_tags = request.query.getall("tag_exclude", [])
|
|
for tag in exclude_tags:
|
|
if tag:
|
|
tag_filters[tag] = "exclude"
|
|
|
|
if tag_filters:
|
|
filters["tags"] = tag_filters
|
|
|
|
lora_availability = {
|
|
status.strip()
|
|
for status in request.query.get("lora_availability", "").split(",")
|
|
if status.strip() in ("ready", "missing", "deleted")
|
|
}
|
|
if lora_availability:
|
|
filters["lora_availability"] = lora_availability
|
|
|
|
lora_hash = request.query.get("lora_hash")
|
|
checkpoint_hash = request.query.get("checkpoint_hash")
|
|
|
|
result = await recipe_scanner.get_paginated_data(
|
|
page=page,
|
|
page_size=page_size,
|
|
sort_by=sort_by,
|
|
search=search,
|
|
filters=filters,
|
|
search_options=search_options,
|
|
lora_hash=lora_hash,
|
|
checkpoint_hash=checkpoint_hash,
|
|
folder=folder,
|
|
recursive=recursive,
|
|
)
|
|
|
|
items = result.get("items", [])
|
|
for item in items:
|
|
file_path = item.get("file_path")
|
|
if file_path:
|
|
item["file_url"] = self.format_recipe_file_url(file_path)
|
|
else:
|
|
item.setdefault("file_url", "/loras_static/images/no-preview.png")
|
|
item.setdefault("loras", [])
|
|
item.setdefault("base_model", "")
|
|
|
|
# Batch preview dimension reads with asyncio.gather. The previous
|
|
# loop awaited asyncio.to_thread once per item, so a page_size=100
|
|
# request submitted 100 sequential thread calls (50-300ms cold-page
|
|
# latency). gather runs them concurrently while the semaphore caps
|
|
# disk opens; dimensions stay omitted (not null) when a preview has
|
|
# no readable size (video, missing file).
|
|
to_read = [
|
|
(i, item.get("file_path"))
|
|
for i, item in enumerate(items)
|
|
if item.get("file_path")
|
|
]
|
|
if to_read:
|
|
dims_list = await asyncio.gather(
|
|
*(_read_preview_dims(path) for _, path in to_read)
|
|
)
|
|
for (idx, _), dims in zip(to_read, dims_list):
|
|
if dims:
|
|
item = items[idx]
|
|
item["width"], item["height"] = dims
|
|
|
|
return web.json_response(result)
|
|
except Exception as exc:
|
|
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def get_recipe(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
|
|
|
if not recipe:
|
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
|
|
|
# Expose the on-disk recipe JSON path so the modal can offer
|
|
# "open file location" without guessing the storage layout.
|
|
recipe = dict(recipe)
|
|
try:
|
|
json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
|
except Exception: # pragma: no cover - details must still load
|
|
json_path = None
|
|
if json_path:
|
|
recipe["recipe_json_path"] = json_path
|
|
|
|
return web.json_response(recipe)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error retrieving recipe details: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
def format_recipe_file_url(self, file_path: str) -> str:
|
|
try:
|
|
normalized_path = os.path.normpath(file_path)
|
|
static_url = config.get_preview_static_url(normalized_path)
|
|
if static_url:
|
|
return static_url
|
|
except Exception as exc: # pragma: no cover - logging path
|
|
self._logger.error(
|
|
"Error formatting recipe file URL: %s", exc, exc_info=True
|
|
)
|
|
return "/loras_static/images/no-preview.png"
|
|
|
|
return "/loras_static/images/no-preview.png"
|
|
|
|
|
|
class RecipeQueryHandler:
|
|
"""Provide read-only insights on recipe data."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
|
recipe_scanner_getter: RecipeScannerGetter,
|
|
format_recipe_file_url: Callable[[str], str],
|
|
logger: Logger,
|
|
) -> None:
|
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
|
self._recipe_scanner_getter = recipe_scanner_getter
|
|
self._format_recipe_file_url = format_recipe_file_url
|
|
self._logger = logger
|
|
|
|
async def get_top_tags(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
limit = int(request.query.get("limit", "20"))
|
|
if limit < 0:
|
|
limit = 20
|
|
elif limit > 200:
|
|
limit = 20
|
|
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
|
|
|
sorted_tags = [
|
|
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
|
]
|
|
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
|
|
return web.json_response({"success": True, "tags": sorted_tags[:limit]})
|
|
except Exception as exc:
|
|
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def search_tags(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
query = request.query.get("q", "")
|
|
limit = int(request.query.get("limit", "20"))
|
|
if limit < 0:
|
|
limit = 20
|
|
elif limit > 200:
|
|
limit = 20
|
|
|
|
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
|
normalized_query = (query or "").strip().lower()
|
|
if not normalized_query:
|
|
sorted_tags = [
|
|
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
|
]
|
|
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
|
|
return web.json_response(
|
|
{"success": True, "tags": sorted_tags[: (limit if limit > 0 else 20)]}
|
|
)
|
|
|
|
matched = [
|
|
{"tag": tag, "count": count}
|
|
for tag, count in tag_counts.items()
|
|
if normalized_query in tag.lower()
|
|
]
|
|
matched.sort(key=lambda entry: entry["count"], reverse=True)
|
|
if limit == 0:
|
|
result = matched
|
|
else:
|
|
result = matched[:limit]
|
|
return web.json_response({"success": True, "tags": result})
|
|
except Exception as exc:
|
|
self._logger.error("Error searching recipe tags: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def _get_recipe_tag_counts(self, recipe_scanner) -> Dict[str, int]:
|
|
"""Compute tag->count mapping from cached recipe data."""
|
|
cache = await recipe_scanner.get_cached_data()
|
|
tag_counts: Dict[str, int] = {}
|
|
for recipe in getattr(cache, "raw_data", []):
|
|
for tag in recipe.get("tags", []) or []:
|
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
return tag_counts
|
|
|
|
async def get_base_models(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
limit = int(request.query.get("limit", "20"))
|
|
cache = await recipe_scanner.get_cached_data()
|
|
|
|
base_model_counts: Dict[str, int] = {}
|
|
unknown_count = 0
|
|
for recipe in getattr(cache, "raw_data", []):
|
|
base_model = recipe.get("base_model")
|
|
if base_model:
|
|
base_model_counts[base_model] = (
|
|
base_model_counts.get(base_model, 0) + 1
|
|
)
|
|
else:
|
|
unknown_count += 1
|
|
|
|
sorted_models = [
|
|
{"name": model, "count": count}
|
|
for model, count in base_model_counts.items()
|
|
]
|
|
if unknown_count:
|
|
# Synthetic "Unknown" bucket for recipes whose base model could
|
|
# not be determined. `value` carries the filter marker so the
|
|
# UI can display "Unknown" without colliding with real base
|
|
# model strings.
|
|
sorted_models.append(
|
|
{
|
|
"name": "Unknown",
|
|
"value": UNKNOWN_BASE_MODEL_FILTER,
|
|
"count": unknown_count,
|
|
}
|
|
)
|
|
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
|
|
if limit > 0:
|
|
sorted_models = sorted_models[:limit]
|
|
return web.json_response({"success": True, "base_models": sorted_models})
|
|
except Exception as exc:
|
|
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_roots(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else []
|
|
return web.json_response({"success": True, "roots": roots})
|
|
except Exception as exc:
|
|
self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_folders(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
folders = await recipe_scanner.get_folders()
|
|
return web.json_response({"success": True, "folders": folders})
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error retrieving recipe folders: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_folder_tree(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
folder_tree = await recipe_scanner.get_folder_tree()
|
|
return web.json_response({"success": True, "tree": folder_tree})
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error retrieving recipe folder tree: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
folder_tree = await recipe_scanner.get_folder_tree()
|
|
return web.json_response({"success": True, "tree": folder_tree})
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error retrieving unified recipe folder tree: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
lora_hash = request.query.get("hash")
|
|
if not lora_hash:
|
|
return web.json_response(
|
|
{"success": False, "error": "Lora hash is required"}, status=400
|
|
)
|
|
|
|
matching_recipes = await recipe_scanner.get_recipes_for_lora(lora_hash)
|
|
return web.json_response({"success": True, "recipes": matching_recipes})
|
|
except Exception as exc:
|
|
self._logger.error("Error getting recipes for Lora: %s", exc)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_recipes_for_checkpoint(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
checkpoint_hash = request.query.get("hash")
|
|
if not checkpoint_hash:
|
|
return web.json_response(
|
|
{"success": False, "error": "Checkpoint hash is required"},
|
|
status=400,
|
|
)
|
|
|
|
matching_recipes = await recipe_scanner.get_recipes_for_checkpoint(
|
|
checkpoint_hash
|
|
)
|
|
return web.json_response({"success": True, "recipes": matching_recipes})
|
|
except Exception as exc:
|
|
self._logger.error("Error getting recipes for checkpoint: %s", exc)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def scan_recipes(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
full_rebuild = request.query.get("full_rebuild", "true").lower() == "true"
|
|
self._logger.info(
|
|
"Manually triggering recipe cache %s",
|
|
"full rebuild" if full_rebuild else "refresh",
|
|
)
|
|
await recipe_scanner.get_cached_data(force_refresh=True)
|
|
return web.json_response(
|
|
{"success": True, "message": "Recipe cache refreshed successfully"}
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error("Error refreshing recipe cache: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def find_duplicates(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
include_prompt = (
|
|
request.query.get("include_prompt", "false").lower() in ("1", "true")
|
|
)
|
|
fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes(
|
|
include_prompt=include_prompt
|
|
)
|
|
url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
|
|
|
|
# Assemble the response directly from the cached recipe summaries.
|
|
# Resolving each id via get_recipe_by_id would re-read every recipe
|
|
# JSON from disk — thousands of blocking reads on the event loop
|
|
# for large libraries — while all required fields already live in
|
|
# the cache.
|
|
cache = await recipe_scanner.get_cached_data()
|
|
recipes_by_id = {
|
|
str(recipe.get("id", "")): recipe for recipe in cache.raw_data
|
|
}
|
|
|
|
response_data = []
|
|
|
|
def append_groups(
|
|
groups: Dict[str, List[Any]], group_type: str
|
|
) -> None:
|
|
for group_key, recipe_ids in groups.items():
|
|
if len(recipe_ids) <= 1:
|
|
continue
|
|
|
|
recipes = []
|
|
for recipe_id in recipe_ids:
|
|
recipe = recipes_by_id.get(str(recipe_id))
|
|
if recipe is None:
|
|
continue
|
|
recipes.append(
|
|
{
|
|
"id": recipe.get("id"),
|
|
"title": recipe.get("title"),
|
|
"file_url": recipe.get("file_url")
|
|
or self._format_recipe_file_url(
|
|
recipe.get("file_path", "")
|
|
),
|
|
"modified": recipe.get("modified"),
|
|
"created_date": recipe.get("created_date"),
|
|
"lora_count": len(recipe.get("loras", [])),
|
|
}
|
|
)
|
|
|
|
if len(recipes) >= 2:
|
|
recipes.sort(
|
|
key=lambda entry: entry.get("modified") or 0,
|
|
reverse=True,
|
|
)
|
|
response_data.append(
|
|
{
|
|
"type": group_type,
|
|
"key": f"g-{len(response_data) + 1}",
|
|
"fingerprint": group_key,
|
|
"count": len(recipes),
|
|
"recipes": recipes,
|
|
}
|
|
)
|
|
|
|
append_groups(fingerprint_groups, "fingerprint")
|
|
append_groups(url_groups, "source_path")
|
|
|
|
response_data.sort(key=lambda entry: entry["count"], reverse=True)
|
|
return web.json_response(
|
|
{"success": True, "duplicate_groups": response_data}
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error finding duplicate recipes: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_recipe_syntax(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
try:
|
|
syntax_parts = await recipe_scanner.get_recipe_syntax_tokens(recipe_id)
|
|
except RecipeNotFoundError:
|
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
|
|
|
if not syntax_parts:
|
|
return web.json_response(
|
|
{"error": "No LoRAs found in this recipe"}, status=400
|
|
)
|
|
|
|
return web.json_response(
|
|
{"success": True, "syntax": " ".join(syntax_parts)}
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
|
|
class RecipeManagementHandler:
|
|
"""Handle create/update/delete style recipe operations."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
|
recipe_scanner_getter: RecipeScannerGetter,
|
|
logger: Logger,
|
|
persistence_service: RecipePersistenceService,
|
|
analysis_service: RecipeAnalysisService,
|
|
downloader_factory,
|
|
civitai_client_getter: CivitaiClientGetter,
|
|
ws_manager=default_ws_manager,
|
|
) -> None:
|
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
|
self._recipe_scanner_getter = recipe_scanner_getter
|
|
self._logger = logger
|
|
self._persistence_service = persistence_service
|
|
self._analysis_service = analysis_service
|
|
self._downloader_factory = downloader_factory
|
|
self._civitai_client_getter = civitai_client_getter
|
|
self._ws_manager = ws_manager
|
|
self._import_semaphore = asyncio.Semaphore(2)
|
|
|
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
reader = await request.multipart()
|
|
payload = await self._parse_save_payload(reader)
|
|
|
|
result = await self._persistence_service.save_recipe(
|
|
recipe_scanner=recipe_scanner,
|
|
image_bytes=payload["image_bytes"],
|
|
image_base64=payload["image_base64"],
|
|
name=payload["name"],
|
|
tags=payload["tags"],
|
|
metadata=payload["metadata"],
|
|
extension=payload.get("extension"),
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def rematch_recipes(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
return web.json_response(
|
|
{"success": False, "error": "Recipe scanner unavailable"},
|
|
status=503,
|
|
)
|
|
|
|
# Mutual exclusion: a global rematch cannot start while a rematch
|
|
# is already running — both mutate recipes under the same
|
|
# mutation lock.
|
|
if self._ws_manager.is_recipe_rematch_running():
|
|
return web.json_response(
|
|
{"success": False, "error": "Recipe rematch already in progress"},
|
|
status=409,
|
|
)
|
|
|
|
recipe_scanner.reset_cancellation()
|
|
|
|
async def progress_callback(data):
|
|
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
|
|
|
# Run in background to avoid timeout
|
|
async def run_rematch():
|
|
try:
|
|
await recipe_scanner.rematch_all_recipes(
|
|
progress_callback=progress_callback
|
|
)
|
|
except Exception as e:
|
|
self._logger.error(
|
|
f"Error in recipe rematch task: {e}", exc_info=True
|
|
)
|
|
await self._ws_manager.broadcast_recipe_rematch_progress(
|
|
{"status": "error", "error": str(e)}
|
|
)
|
|
finally:
|
|
# Keep the final status for a while so the UI can see it
|
|
await asyncio.sleep(5)
|
|
self._ws_manager.cleanup_recipe_rematch_progress()
|
|
|
|
asyncio.create_task(run_rematch())
|
|
|
|
return web.json_response(
|
|
{"success": True, "message": "Recipe rematch started"}
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error("Error starting recipe rematch: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def cancel_rematch(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
return web.json_response(
|
|
{"success": False, "error": "Recipe scanner unavailable"},
|
|
status=503,
|
|
)
|
|
|
|
recipe_scanner.cancel_task()
|
|
return web.json_response(
|
|
{"success": True, "message": "Cancellation requested"}
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error("Error cancelling recipe rematch: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def rematch_recipes_bulk(self, request: web.Request) -> web.Response:
|
|
"""Rematch deleted resources for multiple recipes by their IDs.
|
|
|
|
Accepts a JSON body with a "recipe_ids" array. The per-recipe loop is
|
|
delegated to the scanner's rematch_recipes_bulk; this handler only
|
|
parses the request and returns the scanner's summary.
|
|
"""
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
return web.json_response(
|
|
{"success": False, "error": "Recipe scanner unavailable"},
|
|
status=503,
|
|
)
|
|
|
|
# A bulk rematch must not queue behind a running global rematch's
|
|
# mutation lock.
|
|
if self._ws_manager.is_recipe_rematch_running():
|
|
return web.json_response(
|
|
{"success": False, "error": "Recipe rematch already in progress"},
|
|
status=409,
|
|
)
|
|
|
|
data = await request.json()
|
|
recipe_ids = data.get("recipe_ids", [])
|
|
if not recipe_ids:
|
|
return web.json_response(
|
|
{"success": False, "error": "recipe_ids are required"},
|
|
status=400,
|
|
)
|
|
|
|
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
|
|
return web.json_response(result)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error performing bulk rematch: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response(
|
|
{"success": False, "error": str(exc)}, status=500
|
|
)
|
|
|
|
async def rematch_recipe(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
return web.json_response(
|
|
{"success": False, "error": "Recipe scanner unavailable"},
|
|
status=503,
|
|
)
|
|
|
|
# Reject per-recipe rematches while a global run is in progress so
|
|
# they do not queue behind the mutation lock.
|
|
if self._ws_manager.is_recipe_rematch_running():
|
|
return web.json_response(
|
|
{"success": False, "error": "Recipe rematch already in progress"},
|
|
status=409,
|
|
)
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
|
|
return web.json_response(result)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error rematching single recipe: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_rematch_progress(self, request: web.Request) -> web.Response:
|
|
try:
|
|
progress = self._ws_manager.get_recipe_rematch_progress()
|
|
if progress:
|
|
return web.json_response({"success": True, "progress": progress})
|
|
return web.json_response(
|
|
{"success": False, "message": "No rematch in progress"}, status=404
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error("Error getting rematch progress: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def reimport_recipe(self, request: web.Request) -> web.Response:
|
|
"""Delete a recipe and re-import it from its source.
|
|
|
|
Gives the recipe a fresh start: URL-sourced recipes re-download the
|
|
image from CivitAI; local ones re-parse the saved recipe image. Both
|
|
use the original embedded generation metadata (the appended recipe
|
|
metadata block is ignored) with the current parser, and re-resolve
|
|
LoRAs / checkpoint. User edits (title, tags, favorite) are carried
|
|
over from the old recipe.
|
|
"""
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
old_recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
|
if not old_recipe:
|
|
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
|
|
|
old_file_path = old_recipe.get("file_path", "")
|
|
old_folder = os.path.dirname(old_file_path) if old_file_path else None
|
|
|
|
source_path = old_recipe.get("source_path") or ""
|
|
image_id = extract_civitai_image_id(source_path) if source_path else None
|
|
|
|
# Local re-import sources: an explicit local source_path, or — when
|
|
# no usable source_path was recorded (drag & drop / file-picker
|
|
# imports, or a dangling path left by an earlier re-import) — the
|
|
# recipe's own saved image, which still carries the original
|
|
# embedded generation metadata next to the recipe metadata block.
|
|
# In the fallback case nothing is persisted as source_path: the
|
|
# recipe's own previous preview is not an external source, and it
|
|
# is deleted together with the old recipe below.
|
|
local_source = None
|
|
persisted_source_path = ""
|
|
if not image_id and source_path and os.path.isfile(source_path):
|
|
local_source = source_path
|
|
persisted_source_path = source_path
|
|
elif (
|
|
not image_id
|
|
and not source_path.startswith(("http://", "https://"))
|
|
and old_file_path
|
|
and os.path.isfile(old_file_path)
|
|
):
|
|
local_source = old_file_path
|
|
|
|
if not image_id and not local_source:
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"error": (
|
|
"Recipe has no re-importable source (no source URL "
|
|
"and no accessible local image). "
|
|
"Use repair or manual import instead."
|
|
),
|
|
},
|
|
status=400,
|
|
)
|
|
|
|
user_edits: dict[str, Any] = {}
|
|
for key in ("title", "tags", "favorite", "preview_nsfw_level"):
|
|
if key in old_recipe and old_recipe[key] is not None:
|
|
user_edits[key] = old_recipe[key]
|
|
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
|
|
del user_edits["tags"]
|
|
|
|
if local_source:
|
|
return await self._do_reimport_from_local(
|
|
local_source,
|
|
recipe_scanner,
|
|
recipe_id=recipe_id,
|
|
target_dir=old_folder,
|
|
user_edits=user_edits,
|
|
old_title=old_recipe.get("title", ""),
|
|
persisted_source_path=persisted_source_path,
|
|
)
|
|
|
|
# Optional caller-supplied metadata payload (companion browser
|
|
# extension re-import). Only honored for CivitAI image page
|
|
# sources; everything else uses the native URL import below.
|
|
params = request.rel_url.query
|
|
payload_image_url = params.get("image_url")
|
|
payload_name = params.get("name")
|
|
payload_resources = params.get("resources")
|
|
has_import_payload = bool(
|
|
payload_image_url and payload_name and payload_resources
|
|
)
|
|
|
|
import_response: web.Response | None = None
|
|
if has_import_payload and image_id:
|
|
try:
|
|
async with self._import_semaphore:
|
|
import_response = await self._import_remote_recipe_impl(
|
|
image_url=payload_image_url,
|
|
name=payload_name,
|
|
resources_raw=payload_resources,
|
|
gen_params_raw=params.get("gen_params"),
|
|
tags_raw=params.get("tags"),
|
|
base_model=params.get("base_model", "") or "",
|
|
source_path=source_path,
|
|
target_dir=old_folder,
|
|
)
|
|
except RecipeValidationError as exc:
|
|
# Malformed resources/gen_params JSON: treat as "no
|
|
# payload" and use the legacy URL re-import.
|
|
self._logger.warning(
|
|
"Ignoring malformed re-import payload for recipe %s "
|
|
"(%s); falling back to source URL re-import",
|
|
recipe_id,
|
|
exc,
|
|
)
|
|
except Exception as exc:
|
|
self._logger.warning(
|
|
"Payload-based re-import failed for recipe %s: %s; "
|
|
"falling back to source URL re-import",
|
|
recipe_id,
|
|
exc,
|
|
)
|
|
|
|
if import_response is None:
|
|
async with self._import_semaphore:
|
|
import_response = await self._do_import_from_url(
|
|
source_path,
|
|
recipe_scanner,
|
|
target_dir=old_folder,
|
|
)
|
|
|
|
await self._persistence_service.delete_recipe(
|
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
|
)
|
|
|
|
body_bytes = import_response.body
|
|
if not body_bytes:
|
|
raise RuntimeError("Re-import returned an empty response")
|
|
import_body = json.loads(body_bytes.decode())
|
|
new_recipe_id = import_body.get("recipe_id")
|
|
|
|
if new_recipe_id and user_edits:
|
|
try:
|
|
await self._persistence_service.update_recipe(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=new_recipe_id,
|
|
updates=user_edits,
|
|
)
|
|
except Exception as exc:
|
|
self._logger.warning(
|
|
"Re-import succeeded but failed to carry over "
|
|
"user edits for new recipe %s: %s",
|
|
new_recipe_id,
|
|
exc,
|
|
)
|
|
|
|
response_body: Dict[str, Any] = {
|
|
"success": True,
|
|
"old_recipe_id": recipe_id,
|
|
"recipe_id": new_recipe_id,
|
|
"source_path": source_path,
|
|
}
|
|
loras_count = await self._count_recipe_loras(
|
|
recipe_scanner, new_recipe_id
|
|
)
|
|
if loras_count is not None:
|
|
response_body["loras_count"] = loras_count
|
|
|
|
return web.json_response(response_body)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
|
except RecipeDownloadError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error reimporting recipe: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
# 1. Parse Parameters
|
|
params = request.rel_url.query
|
|
image_url = params.get("image_url")
|
|
name = params.get("name")
|
|
resources_raw = params.get("resources")
|
|
|
|
if not image_url:
|
|
raise RecipeValidationError("Missing required field: image_url")
|
|
if not name:
|
|
raise RecipeValidationError("Missing required field: name")
|
|
if not resources_raw:
|
|
raise RecipeValidationError("Missing required field: resources")
|
|
|
|
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
|
async with self._import_semaphore:
|
|
return await self._import_remote_recipe_impl(
|
|
image_url=image_url,
|
|
name=name,
|
|
resources_raw=resources_raw,
|
|
gen_params_raw=params.get("gen_params"),
|
|
tags_raw=params.get("tags"),
|
|
base_model=params.get("base_model", "") or "",
|
|
source_path=params.get("source_path") or image_url,
|
|
)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeDownloadError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error importing recipe from remote source: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def _import_remote_recipe_impl(
|
|
self,
|
|
*,
|
|
image_url: str,
|
|
name: str,
|
|
resources_raw: str,
|
|
gen_params_raw: Optional[str],
|
|
tags_raw: Optional[str],
|
|
base_model: str,
|
|
source_path: str,
|
|
target_dir: str | None = None,
|
|
) -> web.Response:
|
|
"""Payload-based remote import engine shared by import-remote and the
|
|
extension-driven re-import path.
|
|
|
|
Parses the caller-supplied payloads and delegates to
|
|
:meth:`_do_import_remote_recipe`. Raises ``RecipeValidationError`` on
|
|
malformed payloads so callers can decide how to handle them (the
|
|
re-import path falls back to the legacy URL import).
|
|
"""
|
|
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
|
gen_params_request = self._parse_gen_params(gen_params_raw)
|
|
|
|
self._logger.info(
|
|
"Remote recipe import received: url=%s, lora_count=%d",
|
|
image_url,
|
|
len(lora_entries),
|
|
)
|
|
self._logger.debug(
|
|
" gen_params_keys=%s, checkpoint_keys=%s",
|
|
sorted(gen_params_request.keys()) if gen_params_request else [],
|
|
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
|
)
|
|
|
|
return await self._do_import_remote_recipe(
|
|
image_url=image_url,
|
|
name=name,
|
|
lora_entries=lora_entries,
|
|
checkpoint_entry=checkpoint_entry,
|
|
gen_params_request=gen_params_request,
|
|
tags=self._parse_tags(tags_raw),
|
|
base_model=base_model,
|
|
source_path=source_path,
|
|
target_dir=target_dir,
|
|
)
|
|
|
|
async def _do_import_remote_recipe(
|
|
self,
|
|
*,
|
|
image_url: str,
|
|
name: str,
|
|
lora_entries: list[Any],
|
|
checkpoint_entry: Dict[str, Any] | None,
|
|
gen_params_request: Dict[str, Any] | None,
|
|
tags: list[Any],
|
|
base_model: str,
|
|
source_path: str,
|
|
target_dir: str | None = None,
|
|
) -> web.Response:
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
metadata: Dict[str, Any] = {
|
|
"base_model": base_model,
|
|
"loras": lora_entries,
|
|
"gen_params": gen_params_request or {},
|
|
"source_path": source_path,
|
|
}
|
|
|
|
if checkpoint_entry:
|
|
metadata["checkpoint"] = checkpoint_entry
|
|
if not metadata["base_model"]:
|
|
base_model_from_metadata = (
|
|
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
|
)
|
|
if base_model_from_metadata:
|
|
metadata["base_model"] = base_model_from_metadata
|
|
|
|
# Download image
|
|
(
|
|
image_bytes,
|
|
extension,
|
|
civitai_meta_raw,
|
|
model_version_id,
|
|
_original_image_url,
|
|
) = await self._download_remote_media(image_url)
|
|
|
|
# Build a version-cached map of local model hashes to cache items so
|
|
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
|
# exist on disk. Built once and shared by every parse pass below.
|
|
local_cache = await recipe_scanner.build_local_hash_cache()
|
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
|
|
|
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
|
embedded_gen_params = {}
|
|
parsed_embedded = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
suffix=extension, delete=False
|
|
) as temp_img:
|
|
temp_img.write(image_bytes)
|
|
temp_img_path = temp_img.name
|
|
|
|
try:
|
|
raw_embedded = await asyncio.to_thread(
|
|
ExifUtils.extract_image_metadata, temp_img_path
|
|
)
|
|
if raw_embedded:
|
|
parser = (
|
|
self._analysis_service._recipe_parser_factory.create_parser(
|
|
raw_embedded
|
|
)
|
|
)
|
|
if parser:
|
|
if isinstance(parser, CivitaiApiMetadataParser):
|
|
parsed_embedded = await parser.parse_metadata(
|
|
raw_embedded,
|
|
recipe_scanner=recipe_scanner,
|
|
local_cache=local_cache,
|
|
)
|
|
else:
|
|
parsed_embedded = await parser.parse_metadata(
|
|
raw_embedded, recipe_scanner=recipe_scanner
|
|
)
|
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
|
embedded_gen_params = parsed_embedded["gen_params"]
|
|
else:
|
|
embedded_gen_params = {"raw_metadata": raw_embedded}
|
|
finally:
|
|
if os.path.exists(temp_img_path):
|
|
os.unlink(temp_img_path)
|
|
except Exception as exc:
|
|
self._logger.warning(
|
|
"Failed to extract embedded metadata during import: %s", exc
|
|
)
|
|
|
|
# Parse CivitAI API meta to discover all resources from modelVersionIds
|
|
# (modelVersionIds is injected at root level by _download_remote_media).
|
|
# Run unconditionally — EXIF parsing may succeed for gen_params but miss
|
|
# LoRAs since modelVersionIds is NOT embedded in the image EXIF.
|
|
civitai_parsed = None
|
|
if civitai_meta_raw:
|
|
civitai_inner_meta = civitai_meta_raw
|
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
|
# modelVersionIds lives at outer meta level; propagate after unwrap
|
|
_mvids = civitai_meta_raw.get("modelVersionIds")
|
|
if _mvids and isinstance(civitai_inner_meta, dict):
|
|
civitai_inner_meta["modelVersionIds"] = _mvids
|
|
if isinstance(civitai_inner_meta, dict):
|
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
|
civitai_inner_meta
|
|
)
|
|
if parser:
|
|
if isinstance(parser, CivitaiApiMetadataParser):
|
|
civitai_parsed = await parser.parse_metadata(
|
|
civitai_inner_meta,
|
|
recipe_scanner=recipe_scanner,
|
|
local_cache=local_cache,
|
|
)
|
|
else:
|
|
civitai_parsed = await parser.parse_metadata(
|
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
|
)
|
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
|
# Merge: API gen_params override EXIF at field level,
|
|
# EXIF fills in fields the API doesn't have.
|
|
embedded_gen_params = {
|
|
**(embedded_gen_params or {}),
|
|
**civitai_parsed["gen_params"],
|
|
}
|
|
|
|
if embedded_gen_params:
|
|
metadata["gen_params"] = embedded_gen_params
|
|
|
|
# Merge LoRAs: prefer frontend resources, supplement with CivitAI modelVersionIds
|
|
if civitai_parsed:
|
|
civitai_loras = civitai_parsed.get("loras", [])
|
|
if civitai_loras and not metadata.get("loras"):
|
|
metadata["loras"] = civitai_loras
|
|
civitai_model = civitai_parsed.get("model")
|
|
if civitai_model and not metadata.get("checkpoint"):
|
|
metadata["checkpoint"] = civitai_model
|
|
civitai_base_model = civitai_parsed.get("base_model")
|
|
if civitai_base_model and not metadata.get("base_model"):
|
|
metadata["base_model"] = civitai_base_model
|
|
elif parsed_embedded:
|
|
parsed_loras = parsed_embedded.get("loras")
|
|
if parsed_loras and not metadata.get("loras"):
|
|
metadata["loras"] = parsed_loras
|
|
parsed_model = parsed_embedded.get("model")
|
|
if parsed_model and not metadata.get("checkpoint"):
|
|
metadata["checkpoint"] = parsed_model
|
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
|
metadata["base_model"] = parsed_embedded["base_model"]
|
|
|
|
# Extract preview_nsfw_level from the CivitAI API response
|
|
# (injected into civitai_meta_raw by _download_remote_media).
|
|
if isinstance(civitai_meta_raw, dict):
|
|
bl = civitai_meta_raw.get("browsingLevel")
|
|
if isinstance(bl, int) and bl > 0:
|
|
metadata["preview_nsfw_level"] = bl
|
|
|
|
civitai_client = self._civitai_client_getter()
|
|
await RecipeEnricher.enrich_recipe(
|
|
recipe=metadata,
|
|
civitai_client=civitai_client,
|
|
request_params=gen_params_request,
|
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
|
prefetched_model_version_id=model_version_id,
|
|
)
|
|
|
|
result = await self._persistence_service.save_recipe(
|
|
recipe_scanner=recipe_scanner,
|
|
image_bytes=image_bytes,
|
|
image_base64=None,
|
|
name=name,
|
|
tags=tags,
|
|
metadata=metadata,
|
|
extension=extension,
|
|
target_dir=target_dir,
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
|
|
async def delete_recipe(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
result = await self._persistence_service.delete_recipe(
|
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error deleting recipe: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def update_recipe(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
data = await request.json()
|
|
result = await self._persistence_service.update_recipe(
|
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id, updates=data
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def record_recipe_open(self, request: web.Request) -> web.Response:
|
|
"""Record that a recipe's detail modal was opened.
|
|
|
|
Lightweight fire-and-forget endpoint backing the "Recently Opened"
|
|
sort. It only writes the timestamp into the separate open-stats file
|
|
— recipe JSON and EXIF are never touched.
|
|
"""
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
# Skip recording opens for recipes the scanner no longer knows.
|
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
|
if not recipe_json_path:
|
|
return web.json_response(
|
|
{"success": False, "error": "Recipe not found"}, status=404
|
|
)
|
|
|
|
RecipeOpenStats().record_open(recipe_id)
|
|
return web.json_response({"success": True})
|
|
except Exception as exc:
|
|
self._logger.error("Error recording recipe open: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def move_recipe(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
recipe_id = data.get("recipe_id")
|
|
target_path = data.get("target_path")
|
|
if not recipe_id or not target_path:
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"error": "recipe_id and target_path are required",
|
|
},
|
|
status=400,
|
|
)
|
|
|
|
result = await self._persistence_service.move_recipe(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=str(recipe_id),
|
|
target_path=str(target_path),
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def move_recipes_bulk(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
recipe_ids = data.get("recipe_ids") or []
|
|
target_path = data.get("target_path")
|
|
if not recipe_ids or not target_path:
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"error": "recipe_ids and target_path are required",
|
|
},
|
|
status=400,
|
|
)
|
|
|
|
result = await self._persistence_service.move_recipes_bulk(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_ids=recipe_ids,
|
|
target_path=str(target_path),
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
for field in ("recipe_id", "lora_index", "target_name"):
|
|
if field not in data:
|
|
raise RecipeValidationError(f"Missing required field: {field}")
|
|
|
|
result = await self._persistence_service.reconnect_lora(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=data["recipe_id"],
|
|
lora_index=int(data["lora_index"]),
|
|
target_name=data["target_name"],
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def restore_lora(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
for field in ("recipe_id", "lora_index"):
|
|
if field not in data:
|
|
raise RecipeValidationError(f"Missing required field: {field}")
|
|
|
|
result = await self._persistence_service.restore_lora(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=data["recipe_id"],
|
|
lora_index=int(data["lora_index"]),
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error restoring LoRA: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def get_reconnect_suggestions(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info.get("recipe_id")
|
|
lora_index_raw = request.match_info.get("lora_index")
|
|
if not recipe_id or lora_index_raw is None:
|
|
raise RecipeValidationError("recipe_id and lora_index are required")
|
|
try:
|
|
lora_index = int(lora_index_raw)
|
|
except (TypeError, ValueError):
|
|
raise RecipeValidationError("lora_index must be an integer")
|
|
|
|
result = await self._persistence_service.get_reconnect_suggestions(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=recipe_id,
|
|
lora_index=lora_index,
|
|
query=request.query.get("query") or None,
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error suggesting reconnect candidates: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def mark_lora_hash_invalid(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
for field in ("recipe_id", "lora_index"):
|
|
if field not in data:
|
|
raise RecipeValidationError(f"Missing required field: {field}")
|
|
|
|
result = await self._persistence_service.mark_lora_hash_invalid(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=data["recipe_id"],
|
|
lora_index=int(data["lora_index"]),
|
|
hash_invalid=bool(data.get("hash_invalid", True)),
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error marking LoRA hash invalid: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def reconnect_checkpoint(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
for field in ("recipe_id", "target_name"):
|
|
if field not in data:
|
|
raise RecipeValidationError(f"Missing required field: {field}")
|
|
|
|
result = await self._persistence_service.reconnect_checkpoint(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=data["recipe_id"],
|
|
target_name=data["target_name"],
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error reconnecting checkpoint: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def restore_checkpoint(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
if "recipe_id" not in data:
|
|
raise RecipeValidationError("Missing required field: recipe_id")
|
|
|
|
result = await self._persistence_service.restore_checkpoint(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=data["recipe_id"],
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error restoring checkpoint: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def get_checkpoint_reconnect_suggestions(
|
|
self, request: web.Request
|
|
) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info.get("recipe_id")
|
|
if not recipe_id:
|
|
raise RecipeValidationError("recipe_id is required")
|
|
|
|
result = await self._persistence_service.get_checkpoint_reconnect_suggestions(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=recipe_id,
|
|
query=request.query.get("query") or None,
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error suggesting checkpoint reconnect candidates: %s",
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def mark_checkpoint_hash_invalid(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
if "recipe_id" not in data:
|
|
raise RecipeValidationError("Missing required field: recipe_id")
|
|
|
|
result = await self._persistence_service.mark_checkpoint_hash_invalid(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=data["recipe_id"],
|
|
hash_invalid=bool(data.get("hash_invalid", True)),
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error marking checkpoint hash invalid: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def bulk_delete(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
recipe_ids = data.get("recipe_ids", [])
|
|
result = await self._persistence_service.bulk_delete(
|
|
recipe_scanner=recipe_scanner, recipe_ids=recipe_ids
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error performing bulk delete: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def save_recipe_from_widget(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
analysis = await self._analysis_service.analyze_widget_metadata(
|
|
recipe_scanner=recipe_scanner
|
|
)
|
|
metadata = analysis.payload.get("metadata")
|
|
image_bytes = analysis.payload.get("image_bytes")
|
|
if not metadata or image_bytes is None:
|
|
raise RecipeValidationError("Unable to extract metadata from widget")
|
|
|
|
result = await self._persistence_service.save_recipe_from_widget(
|
|
recipe_scanner=recipe_scanner,
|
|
metadata=metadata,
|
|
image_bytes=image_bytes,
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error saving recipe from widget: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def _parse_save_payload(self, reader) -> dict[str, Any]:
|
|
image_bytes: Optional[bytes] = None
|
|
image_base64: Optional[str] = None
|
|
name: Optional[str] = None
|
|
tags: list[str] = []
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
extension: Optional[str] = None
|
|
|
|
while True:
|
|
field = await reader.next()
|
|
if field is None:
|
|
break
|
|
if field.name == "image":
|
|
image_chunks = bytearray()
|
|
while True:
|
|
chunk = await field.read_chunk()
|
|
if not chunk:
|
|
break
|
|
image_chunks.extend(chunk)
|
|
image_bytes = bytes(image_chunks)
|
|
elif field.name == "image_base64":
|
|
image_base64 = await field.text()
|
|
elif field.name == "name":
|
|
name = await field.text()
|
|
elif field.name == "tags":
|
|
tags_text = await field.text()
|
|
try:
|
|
parsed_tags = json.loads(tags_text)
|
|
tags = parsed_tags if isinstance(parsed_tags, list) else []
|
|
except Exception:
|
|
tags = []
|
|
elif field.name == "metadata":
|
|
metadata_text = await field.text()
|
|
try:
|
|
metadata = json.loads(metadata_text)
|
|
except Exception:
|
|
metadata = {}
|
|
elif field.name == "extension":
|
|
extension = await field.text()
|
|
|
|
return {
|
|
"image_bytes": image_bytes,
|
|
"image_base64": image_base64,
|
|
"name": name,
|
|
"tags": tags,
|
|
"metadata": metadata,
|
|
"extension": extension,
|
|
}
|
|
|
|
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
|
|
if not tag_text:
|
|
return []
|
|
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
|
|
|
async def _count_recipe_loras(
|
|
self, recipe_scanner: Any, recipe_id: Optional[str]
|
|
) -> Optional[int]:
|
|
"""Best-effort LoRA count for a freshly saved recipe (for the
|
|
re-import response). Returns None when the recipe cannot be read."""
|
|
if not recipe_id:
|
|
return None
|
|
try:
|
|
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
|
except Exception as exc:
|
|
self._logger.debug(
|
|
"Could not read new recipe %s for loras_count: %s",
|
|
recipe_id,
|
|
exc,
|
|
)
|
|
return None
|
|
loras = (recipe or {}).get("loras")
|
|
return len(loras) if isinstance(loras, list) else None
|
|
|
|
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
if payload is None:
|
|
return None
|
|
if payload == "":
|
|
return {}
|
|
try:
|
|
parsed = json.loads(payload)
|
|
except json.JSONDecodeError as exc:
|
|
raise RecipeValidationError(f"Invalid gen_params payload: {exc}") from exc
|
|
if parsed is None:
|
|
return {}
|
|
if not isinstance(parsed, dict):
|
|
raise RecipeValidationError("gen_params payload must be an object")
|
|
return parsed
|
|
|
|
def _parse_resources_payload(
|
|
self, payload_raw: str
|
|
) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
try:
|
|
payload = json.loads(payload_raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise RecipeValidationError(f"Invalid resources payload: {exc}") from exc
|
|
|
|
if not isinstance(payload, list):
|
|
raise RecipeValidationError("Resources payload must be a list")
|
|
|
|
checkpoint_entry: Optional[Dict[str, Any]] = None
|
|
lora_entries: List[Dict[str, Any]] = []
|
|
|
|
for resource in payload:
|
|
if not isinstance(resource, dict):
|
|
continue
|
|
resource_type = str(resource.get("type") or "").lower()
|
|
if resource_type == "checkpoint":
|
|
checkpoint_entry = self._build_checkpoint_entry(resource)
|
|
elif resource_type in {"lora", "lycoris"}:
|
|
lora_entries.append(self._build_lora_entry(resource))
|
|
|
|
return checkpoint_entry, lora_entries
|
|
|
|
def _build_checkpoint_entry(self, resource: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {
|
|
"type": resource.get("type", "checkpoint"),
|
|
"modelId": self._safe_int(resource.get("modelId")),
|
|
"modelVersionId": self._safe_int(resource.get("modelVersionId")),
|
|
"modelName": resource.get("modelName", ""),
|
|
"modelVersionName": resource.get("modelVersionName", ""),
|
|
}
|
|
|
|
def _build_lora_entry(self, resource: Dict[str, Any]) -> Dict[str, Any]:
|
|
weight_raw = resource.get("weight", 1.0)
|
|
try:
|
|
weight = float(weight_raw)
|
|
except (TypeError, ValueError):
|
|
weight = 1.0
|
|
return {
|
|
"file_name": resource.get("modelName", ""),
|
|
"weight": weight,
|
|
"id": self._safe_int(resource.get("modelVersionId")),
|
|
"name": resource.get("modelName", ""),
|
|
"version": resource.get("modelVersionName", ""),
|
|
"isDeleted": False,
|
|
"exclude": False,
|
|
}
|
|
|
|
async def _download_remote_media(
|
|
self, image_url: str
|
|
) -> tuple[bytes, str, Any, Any, Optional[str]]:
|
|
civitai_client = self._civitai_client_getter()
|
|
downloader = await self._downloader_factory()
|
|
temp_path = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
|
temp_path = temp_file.name
|
|
download_url = image_url
|
|
image_info = None
|
|
civitai_image_id = extract_civitai_image_id(image_url)
|
|
if civitai_image_id:
|
|
if civitai_client is None:
|
|
raise RecipeDownloadError(
|
|
"Civitai client unavailable for image download"
|
|
)
|
|
image_info = await civitai_client.get_image_info(
|
|
civitai_image_id, source_url=image_url
|
|
)
|
|
if not image_info:
|
|
raise RecipeDownloadError(
|
|
"Failed to fetch image information from Civitai"
|
|
)
|
|
|
|
media_url = image_info.get("url")
|
|
if not media_url:
|
|
raise RecipeDownloadError("No image URL found in Civitai response")
|
|
|
|
# Use optimized preview URLs if possible
|
|
media_type = image_info.get("type")
|
|
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
|
|
if rewritten_url:
|
|
download_url = rewritten_url
|
|
else:
|
|
download_url = media_url
|
|
|
|
success, result = await downloader.download_file(
|
|
download_url, temp_path, use_auth=False
|
|
)
|
|
if not success:
|
|
raise RecipeDownloadError(f"Failed to download image: {result}")
|
|
|
|
# Extract extension from URL
|
|
url_path = download_url.split("?")[0].split("#")[0]
|
|
extension = os.path.splitext(url_path)[1].lower()
|
|
if not extension:
|
|
extension = ".webp" # Default to webp if unknown
|
|
|
|
with open(temp_path, "rb") as file_obj:
|
|
model_ver_id = None
|
|
civitai_meta_raw = (
|
|
image_info.get("meta") if civitai_image_id and image_info else None
|
|
)
|
|
if civitai_image_id and image_info:
|
|
# modelVersionId (singular) — the primary version for this
|
|
# image on CivitAI. May be absent, or may *not* be the
|
|
# checkpoint (e.g. when the image was generated with a LoRA
|
|
# as the primary subject). When absent, DO NOT fall back to
|
|
# modelVersionIds[0] — that array mixes checkpoints, LoRAs,
|
|
# and other model version IDs without ordering guarantees.
|
|
# The downstream enrichment flow will find the real
|
|
# checkpoint via meta.resources (type:"model" hash) or
|
|
# meta.civitaiResources (type:"checkpoint" version ID), so
|
|
# leaving model_ver_id as None is safe and avoids the bug
|
|
# where a LoRA version ID was treated as the checkpoint.
|
|
model_ver_id = image_info.get("modelVersionId")
|
|
|
|
# Inject root-level modelVersionIds into meta so downstream
|
|
# parsers (CivitaiApiMetadataParser) can discover ALL resources
|
|
# (checkpoint + LoRAs), not just the first model version ID.
|
|
# CivitAI API returns modelVersionIds at the root level of
|
|
# the image response, NOT inside the meta object.
|
|
mvids = image_info.get("modelVersionIds")
|
|
if mvids:
|
|
if isinstance(civitai_meta_raw, dict):
|
|
civitai_meta_raw["modelVersionIds"] = mvids
|
|
else:
|
|
# meta is null but modelVersionIds exists — create a
|
|
# minimal dict so downstream parsers can discover
|
|
# LoRAs and checkpoints from the API response.
|
|
civitai_meta_raw = {"modelVersionIds": mvids}
|
|
|
|
# Inject browsingLevel (canonical integer) so the recipe's
|
|
# preview_nsfw_level can be set, enabling proper NSFW blur
|
|
# of the preview image. Fall back to nsfwLevel (string)
|
|
# when browsingLevel is absent.
|
|
if isinstance(civitai_meta_raw, dict):
|
|
browsing_level = image_info.get("browsingLevel")
|
|
nsfw_level_str = image_info.get("nsfwLevel")
|
|
if isinstance(browsing_level, int) and browsing_level > 0:
|
|
civitai_meta_raw["browsingLevel"] = browsing_level
|
|
elif (
|
|
isinstance(nsfw_level_str, str)
|
|
and nsfw_level_str in NSFW_LEVELS
|
|
):
|
|
civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[
|
|
nsfw_level_str
|
|
]
|
|
|
|
original_url = (
|
|
image_info.get("url") if civitai_image_id and image_info else None
|
|
)
|
|
|
|
return (
|
|
file_obj.read(),
|
|
extension,
|
|
civitai_meta_raw,
|
|
model_ver_id,
|
|
original_url,
|
|
)
|
|
except RecipeDownloadError:
|
|
raise
|
|
except RecipeValidationError:
|
|
raise
|
|
except Exception as exc: # pragma: no cover - defensive guard
|
|
raise RecipeValidationError(f"Unable to download image: {exc}") from exc
|
|
finally:
|
|
if temp_path:
|
|
try:
|
|
os.unlink(temp_path)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
def _safe_int(self, value: Any) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
async def _resolve_base_model_from_checkpoint(
|
|
self, checkpoint_entry: Dict[str, Any]
|
|
) -> str:
|
|
version_id = self._safe_int(checkpoint_entry.get("modelVersionId"))
|
|
|
|
if not version_id:
|
|
return ""
|
|
|
|
try:
|
|
provider = await get_default_metadata_provider()
|
|
if not provider:
|
|
return ""
|
|
|
|
version_info = await provider.get_model_version_info(str(version_id))
|
|
if isinstance(version_info, tuple):
|
|
version_info = version_info[0]
|
|
|
|
if isinstance(version_info, dict):
|
|
base_model = version_info.get("baseModel") or ""
|
|
return str(base_model) if base_model is not None else ""
|
|
except Exception as exc: # pragma: no cover - defensive logging
|
|
self._logger.warning(
|
|
"Failed to resolve base model from checkpoint metadata: %s", exc
|
|
)
|
|
|
|
return ""
|
|
|
|
async def check_image_exists(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
image_ids_raw = request.query.get("image_ids", "")
|
|
if not image_ids_raw:
|
|
return web.json_response({"success": True, "results": {}})
|
|
|
|
requested_ids = set()
|
|
for raw in image_ids_raw.split(","):
|
|
stripped = raw.strip()
|
|
if stripped and stripped.isdigit():
|
|
requested_ids.add(stripped)
|
|
|
|
if not requested_ids:
|
|
return web.json_response({"success": True, "results": {}})
|
|
|
|
cache = await recipe_scanner.get_cached_data()
|
|
|
|
# Use precomputed image_id_map (built once at cache init)
|
|
image_to_recipe = getattr(cache, "image_id_map", {})
|
|
|
|
results = {}
|
|
for img_id in requested_ids:
|
|
recipe_id = image_to_recipe.get(img_id)
|
|
results[img_id] = {
|
|
"in_library": recipe_id is not None,
|
|
"recipe_id": recipe_id,
|
|
}
|
|
|
|
return web.json_response({"success": True, "results": results})
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error checking image existence: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def import_from_url(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
image_url = request.query.get("image_url")
|
|
if not image_url:
|
|
raise RecipeValidationError("Missing required field: image_url")
|
|
|
|
force = request.query.get("force", "false").lower() == "true"
|
|
|
|
image_id = extract_civitai_image_id(image_url)
|
|
if not image_id:
|
|
raise RecipeValidationError(
|
|
"Could not extract Civitai image ID from URL"
|
|
)
|
|
|
|
if not force:
|
|
cache = await recipe_scanner.get_cached_data()
|
|
image_to_recipe = getattr(cache, "image_id_map", {})
|
|
existing_recipe_id = image_to_recipe.get(image_id)
|
|
if existing_recipe_id:
|
|
recipe_name = ""
|
|
for recipe in getattr(cache, "raw_data", []):
|
|
if str(recipe.get("id", "")) == existing_recipe_id:
|
|
recipe_name = recipe.get("title", "") or ""
|
|
break
|
|
return web.json_response({
|
|
"success": True,
|
|
"recipe_id": existing_recipe_id,
|
|
"name": recipe_name,
|
|
"already_exists": True,
|
|
})
|
|
|
|
async with self._import_semaphore:
|
|
return await self._do_import_from_url(image_url, recipe_scanner)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except RecipeDownloadError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error importing recipe from URL: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def _do_import_from_url(
|
|
self,
|
|
image_url: str,
|
|
recipe_scanner: Any,
|
|
*,
|
|
recipe_id: str | None = None,
|
|
target_dir: str | None = None,
|
|
) -> web.Response:
|
|
image_id = extract_civitai_image_id(image_url)
|
|
if not image_id:
|
|
raise RecipeValidationError(
|
|
"Could not extract Civitai image ID from URL"
|
|
)
|
|
|
|
image_bytes, extension, civitai_meta_raw, model_version_id, original_image_url = (
|
|
await self._download_remote_media(image_url)
|
|
)
|
|
|
|
# Diagnostics for the recipe modal's "Why no LoRAs?" panel. This path
|
|
# always comes from a CivitAI image URL (import_from_url validates the
|
|
# image id), so civitai_image is True.
|
|
diagnostics: Dict[str, Any] = {
|
|
"civitai_image": True,
|
|
"is_video": extension in (".mp4", ".webm"),
|
|
}
|
|
if isinstance(civitai_meta_raw, dict):
|
|
raw_mvids = civitai_meta_raw.get("modelVersionIds")
|
|
diagnostics["api_model_version_ids"] = (
|
|
len(raw_mvids) if isinstance(raw_mvids, list) else 0
|
|
)
|
|
inner_meta_for_diag = civitai_meta_raw.get("meta")
|
|
if isinstance(inner_meta_for_diag, dict):
|
|
diagnostics["api_meta_present"] = True
|
|
diagnostics["api_meta_keys"] = sorted(inner_meta_for_diag.keys())
|
|
|
|
# Build a version-cached map of local model hashes to cache items so
|
|
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
|
# exist on disk. Built once and shared by every parse pass below.
|
|
local_cache = await recipe_scanner.build_local_hash_cache()
|
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
|
|
|
# Extract embedded EXIF metadata
|
|
embedded_gen_params = {}
|
|
parsed_embedded = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
suffix=extension, delete=False
|
|
) as temp_img:
|
|
temp_img.write(image_bytes)
|
|
temp_img_path = temp_img.name
|
|
|
|
try:
|
|
raw_embedded = await asyncio.to_thread(
|
|
ExifUtils.extract_image_metadata, temp_img_path
|
|
)
|
|
diagnostics["exif_present"] = bool(raw_embedded)
|
|
if raw_embedded:
|
|
parser = (
|
|
self._analysis_service._recipe_parser_factory.create_parser(
|
|
raw_embedded
|
|
)
|
|
)
|
|
if parser:
|
|
diagnostics["exif_parser"] = parser.__class__.__name__
|
|
if isinstance(parser, CivitaiApiMetadataParser):
|
|
parsed_embedded = await parser.parse_metadata(
|
|
raw_embedded,
|
|
recipe_scanner=recipe_scanner,
|
|
local_cache=local_cache,
|
|
)
|
|
else:
|
|
parsed_embedded = await parser.parse_metadata(
|
|
raw_embedded, recipe_scanner=recipe_scanner
|
|
)
|
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
|
embedded_gen_params = parsed_embedded["gen_params"]
|
|
finally:
|
|
if os.path.exists(temp_img_path):
|
|
os.unlink(temp_img_path)
|
|
except Exception as exc:
|
|
self._logger.warning(
|
|
"Failed to extract embedded metadata: %s", exc
|
|
)
|
|
|
|
if not parsed_embedded and original_image_url:
|
|
self._logger.debug(
|
|
"Optimized image has no embedded metadata, "
|
|
"falling back to original: %s",
|
|
original_image_url,
|
|
)
|
|
try:
|
|
downloader = await self._downloader_factory()
|
|
with tempfile.NamedTemporaryFile(
|
|
suffix=".png", delete=False
|
|
) as tmp:
|
|
orig_tmp_path = tmp.name
|
|
try:
|
|
success, _ = await downloader.download_file(
|
|
original_image_url, orig_tmp_path, use_auth=False
|
|
)
|
|
if success:
|
|
raw_orig = await asyncio.to_thread(
|
|
ExifUtils.extract_image_metadata, orig_tmp_path
|
|
)
|
|
diagnostics["exif_present"] = bool(raw_orig)
|
|
if raw_orig:
|
|
parser = (
|
|
self._analysis_service._recipe_parser_factory.create_parser(
|
|
raw_orig
|
|
)
|
|
)
|
|
if parser:
|
|
diagnostics["exif_parser"] = parser.__class__.__name__
|
|
if isinstance(parser, CivitaiApiMetadataParser):
|
|
parsed_embedded = await parser.parse_metadata(
|
|
raw_orig,
|
|
recipe_scanner=recipe_scanner,
|
|
local_cache=local_cache,
|
|
)
|
|
else:
|
|
parsed_embedded = await parser.parse_metadata(
|
|
raw_orig, recipe_scanner=recipe_scanner
|
|
)
|
|
if (
|
|
parsed_embedded
|
|
and "gen_params" in parsed_embedded
|
|
):
|
|
embedded_gen_params = parsed_embedded[
|
|
"gen_params"
|
|
]
|
|
finally:
|
|
if os.path.exists(orig_tmp_path):
|
|
os.unlink(orig_tmp_path)
|
|
except Exception as exc:
|
|
self._logger.warning(
|
|
"Failed to extract metadata from original image: %s", exc
|
|
)
|
|
|
|
# Parse CivitAI API meta to discover all resources from modelVersionIds.
|
|
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
|
|
# LoRAs (modelVersionIds is NOT in the image EXIF).
|
|
civitai_parsed = None
|
|
if civitai_meta_raw:
|
|
civitai_inner_meta = civitai_meta_raw
|
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
|
# Propagate modelVersionIds into unwrapped meta — it lives
|
|
# at the outer meta level in the CivitAI API response.
|
|
_mvids = civitai_meta_raw.get("modelVersionIds")
|
|
if _mvids and isinstance(civitai_inner_meta, dict):
|
|
civitai_inner_meta["modelVersionIds"] = _mvids
|
|
if isinstance(civitai_inner_meta, dict):
|
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
|
civitai_inner_meta
|
|
)
|
|
if parser:
|
|
if isinstance(parser, CivitaiApiMetadataParser):
|
|
civitai_parsed = await parser.parse_metadata(
|
|
civitai_inner_meta,
|
|
recipe_scanner=recipe_scanner,
|
|
local_cache=local_cache,
|
|
)
|
|
else:
|
|
civitai_parsed = await parser.parse_metadata(
|
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
|
)
|
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
|
# Merge: API gen_params override EXIF at field level,
|
|
# EXIF fills in fields the API doesn't have.
|
|
embedded_gen_params = {
|
|
**(embedded_gen_params or {}),
|
|
**civitai_parsed["gen_params"],
|
|
}
|
|
|
|
metadata: Dict[str, Any] = {
|
|
"base_model": "",
|
|
"loras": [],
|
|
"gen_params": embedded_gen_params or {},
|
|
"source_path": image_url,
|
|
}
|
|
|
|
# Extract preview_nsfw_level from the CivitAI API response
|
|
# (injected into civitai_meta_raw by _download_remote_media).
|
|
if isinstance(civitai_meta_raw, dict):
|
|
bl = civitai_meta_raw.get("browsingLevel")
|
|
if isinstance(bl, int) and bl > 0:
|
|
metadata["preview_nsfw_level"] = bl
|
|
|
|
if civitai_parsed:
|
|
civitai_loras = civitai_parsed.get("loras", [])
|
|
if civitai_loras and not metadata.get("loras"):
|
|
metadata["loras"] = civitai_loras
|
|
civitai_model = civitai_parsed.get("model")
|
|
if civitai_model and not metadata.get("checkpoint"):
|
|
metadata["checkpoint"] = civitai_model
|
|
civitai_base_model = civitai_parsed.get("base_model")
|
|
if civitai_base_model and not metadata.get("base_model"):
|
|
metadata["base_model"] = civitai_base_model
|
|
|
|
# EXIF fills whatever the API-only parse left open — when the image
|
|
# API meta is null (only modelVersionIds present) the API parse
|
|
# yields a checkpoint but no LoRAs, while the image EXIF carries the
|
|
# full resource list.
|
|
if parsed_embedded:
|
|
if not metadata.get("loras"):
|
|
parsed_loras = parsed_embedded.get("loras")
|
|
if parsed_loras:
|
|
metadata["loras"] = parsed_loras
|
|
if not metadata.get("checkpoint"):
|
|
parsed_model = parsed_embedded.get("model")
|
|
if parsed_model:
|
|
metadata["checkpoint"] = parsed_model
|
|
if not metadata.get("base_model") and parsed_embedded.get("base_model"):
|
|
metadata["base_model"] = parsed_embedded["base_model"]
|
|
|
|
civitai_client = self._civitai_client_getter()
|
|
await RecipeEnricher.enrich_recipe(
|
|
recipe=metadata,
|
|
civitai_client=civitai_client,
|
|
request_params={},
|
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
|
prefetched_model_version_id=model_version_id,
|
|
)
|
|
|
|
prompt = (
|
|
metadata.get("gen_params", {}).get("prompt")
|
|
or metadata.get("gen_params", {}).get("positivePrompt")
|
|
or ""
|
|
)
|
|
if prompt:
|
|
name = " ".join(str(prompt).split()[:10])
|
|
else:
|
|
name = f"Civitai Image {image_id}"
|
|
|
|
# Record why this import ended up with no LoRAs so the recipe modal
|
|
# can explain it (collapsed by default).
|
|
from ...services.recipes.import_info import (
|
|
CHANNEL_REIMPORT_URL,
|
|
CHANNEL_URL,
|
|
build_import_info,
|
|
)
|
|
|
|
metadata["import_info"] = build_import_info(
|
|
CHANNEL_REIMPORT_URL if recipe_id else CHANNEL_URL,
|
|
diagnostics,
|
|
metadata.get("loras"),
|
|
)
|
|
|
|
result = await self._persistence_service.save_recipe(
|
|
recipe_scanner=recipe_scanner,
|
|
image_bytes=image_bytes,
|
|
image_base64=None,
|
|
name=name,
|
|
tags=[],
|
|
metadata=metadata,
|
|
extension=extension,
|
|
recipe_id=recipe_id,
|
|
target_dir=target_dir,
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
|
|
async def _do_reimport_from_local(
|
|
self,
|
|
file_path: str,
|
|
recipe_scanner: Any,
|
|
*,
|
|
recipe_id: str,
|
|
target_dir: str | None,
|
|
user_edits: dict[str, Any],
|
|
old_title: str,
|
|
persisted_source_path: str,
|
|
) -> web.Response:
|
|
"""Re-import a recipe from a local image file.
|
|
|
|
Reads the original source file, re-parses its original embedded
|
|
generation metadata (the appended recipe metadata block is ignored so
|
|
the current parser gets a fresh pass), saves a new recipe, then deletes
|
|
the old one.
|
|
|
|
``persisted_source_path`` is the source_path recorded on the new
|
|
recipe: the external source file when one exists, or empty when the
|
|
re-import fell back to the recipe's own previous preview image (that
|
|
file is deleted with the old recipe, so recording it would leave a
|
|
dangling path that blocks future re-imports).
|
|
"""
|
|
normalized = os.path.normpath(file_path)
|
|
if not os.path.isfile(normalized):
|
|
raise RecipeNotFoundError(
|
|
f"Source file no longer accessible: {normalized}"
|
|
)
|
|
|
|
with open(normalized, "rb") as fh:
|
|
image_bytes = fh.read()
|
|
|
|
extension = os.path.splitext(normalized)[1].lower() or ".png"
|
|
|
|
analysis_result = await self._analysis_service.analyze_local_image(
|
|
file_path=normalized,
|
|
recipe_scanner=recipe_scanner,
|
|
ignore_recipe_metadata=True,
|
|
)
|
|
analysis_payload: dict[str, Any] = analysis_result.payload
|
|
|
|
gen_params = analysis_payload.get("gen_params") or {}
|
|
loras = analysis_payload.get("loras") or []
|
|
checkpoint = analysis_payload.get("checkpoint")
|
|
base_model = analysis_payload.get("base_model", "")
|
|
|
|
metadata: dict[str, Any] = {
|
|
"base_model": base_model,
|
|
"loras": loras,
|
|
"gen_params": gen_params,
|
|
"source_path": persisted_source_path,
|
|
}
|
|
if checkpoint:
|
|
metadata["checkpoint"] = checkpoint
|
|
|
|
from ...services.recipes.import_info import (
|
|
CHANNEL_REIMPORT_LOCAL,
|
|
build_import_info,
|
|
)
|
|
|
|
metadata["import_info"] = build_import_info(
|
|
CHANNEL_REIMPORT_LOCAL,
|
|
analysis_payload.get("diagnostics"),
|
|
loras,
|
|
)
|
|
|
|
prompt = (
|
|
gen_params.get("prompt")
|
|
or gen_params.get("positivePrompt")
|
|
or ""
|
|
)
|
|
name = " ".join(str(prompt).split()[:10]) if prompt else old_title
|
|
|
|
result = await self._persistence_service.save_recipe(
|
|
recipe_scanner=recipe_scanner,
|
|
image_bytes=image_bytes,
|
|
image_base64=analysis_payload.get("image_base64"),
|
|
name=name,
|
|
tags=[],
|
|
metadata=metadata,
|
|
extension=extension,
|
|
target_dir=target_dir,
|
|
# The source is the recipe's own already-optimized preview image;
|
|
# store its bytes verbatim instead of re-compressing (which would
|
|
# only degrade quality) and skip the metadata re-append.
|
|
skip_optimize=True,
|
|
)
|
|
|
|
await self._persistence_service.delete_recipe(
|
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
|
)
|
|
|
|
new_recipe_id = result.payload.get("recipe_id")
|
|
if new_recipe_id and user_edits:
|
|
try:
|
|
await self._persistence_service.update_recipe(
|
|
recipe_scanner=recipe_scanner,
|
|
recipe_id=new_recipe_id,
|
|
updates=user_edits,
|
|
)
|
|
except Exception as exc:
|
|
self._logger.warning(
|
|
"Re-import (local) succeeded but failed to carry over "
|
|
"user edits for recipe %s: %s",
|
|
new_recipe_id,
|
|
exc,
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"old_recipe_id": recipe_id,
|
|
"recipe_id": new_recipe_id,
|
|
"source_path": persisted_source_path,
|
|
}
|
|
)
|
|
|
|
async def create_from_example(self, request: web.Request) -> web.Response:
|
|
"""Create a recipe from a model's example image using cached metadata.
|
|
|
|
Uses the image's meta data (already cached in .metadata.json from the
|
|
CivitAI model-versions API) to create a recipe without additional
|
|
CivitAI API calls.
|
|
|
|
If the image metadata doesn't contain any resources of the parent
|
|
model's type (LoRA-type or Checkpoint), the parent model is
|
|
auto-populated as a fallback.
|
|
|
|
Request body:
|
|
image_data (dict): The full image object from model-versions API
|
|
(includes meta, additionalResources, url, etc.)
|
|
model_hash (str): SHA256 hash of the parent model
|
|
model_name (str): Filename of the parent model
|
|
model_type (str): Page type (``"loras"``, ``"checkpoints"``, etc.)
|
|
local_image_path (str, optional): Local filesystem path to read
|
|
the image bytes for the recipe preview
|
|
"""
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
image_data = data.get("image_data")
|
|
model_hash = data.get("model_hash")
|
|
model_name = data.get("model_name")
|
|
model_type = data.get("model_type", "")
|
|
|
|
if not image_data or not model_hash or not model_name:
|
|
raise RecipeValidationError(
|
|
"Missing required fields: image_data, model_hash, model_name"
|
|
)
|
|
|
|
# Merge nested meta into top level so the parser finds everything.
|
|
# CivitaiApiMetadataParser expects prompt, seed, resources, etc.
|
|
# at the top level or wrapped under a "meta" key.
|
|
inner_meta = image_data.get("meta") or {}
|
|
parsed_input = {**image_data, **inner_meta}
|
|
parsed_input.pop("meta", None)
|
|
|
|
# Build the shared local hash cache so the parser can skip CivitAI
|
|
# API calls for models that exist on disk.
|
|
local_cache: Dict[str, Dict[str, Any]] = (
|
|
await recipe_scanner.build_local_hash_cache()
|
|
)
|
|
|
|
# Bounded supplement for un-backfilled parents. The shared builder
|
|
# never computes autov3; when the parent model exists on disk but
|
|
# its cached entry has no stored AutoV3, compute it for that single
|
|
# file and register the AutoV3 key so the parser can also match on
|
|
# that hash type (CivitAI metadata resources use AutoV3). This runs
|
|
# whenever the parent is found with an empty autov3, independent of
|
|
# whether the sha256 key is already present in the shared cache.
|
|
if model_hash:
|
|
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
|
if lora_scanner:
|
|
try:
|
|
parent_cache_data = await lora_scanner.get_cached_data()
|
|
for item in getattr(parent_cache_data, "raw_data", []):
|
|
if item.get("sha256", "").lower() == model_hash.lower():
|
|
autov3 = (item.get("autov3") or "").lower()
|
|
if not autov3:
|
|
file_path = item.get("file_path")
|
|
if file_path and os.path.exists(file_path):
|
|
try:
|
|
from ...utils.file_utils import (
|
|
calculate_autov3,
|
|
)
|
|
autov3 = (
|
|
calculate_autov3(file_path) or ""
|
|
).lower()
|
|
except Exception:
|
|
pass
|
|
if autov3:
|
|
local_cache[autov3] = item
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
|
parsed_input
|
|
)
|
|
if not parser:
|
|
raise RecipeValidationError("Unable to parse image metadata")
|
|
|
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
|
|
|
if isinstance(parser, CivitaiApiMetadataParser):
|
|
parsed = await parser.parse_metadata(
|
|
parsed_input,
|
|
recipe_scanner=recipe_scanner,
|
|
local_cache=local_cache,
|
|
)
|
|
else:
|
|
parsed = await parser.parse_metadata(
|
|
parsed_input, recipe_scanner=recipe_scanner
|
|
)
|
|
|
|
loras = list(parsed.get("loras") or [])
|
|
checkpoint = parsed.get("model")
|
|
is_lora_type = model_type.startswith("lora")
|
|
is_ckpt_type = model_type.startswith("checkpoint")
|
|
|
|
# Extract parent model metadata from local_cache (used below to
|
|
# reconcile isDeleted entries and enrich auto-populated ones).
|
|
parent_civitai_id: int | None = None
|
|
parent_model_id: int | None = None
|
|
parent_version_name: str | None = None
|
|
parent_model_name: str | None = None
|
|
# Resolve the parent strictly by its sha256 key. There is no
|
|
# arbitrary fallback: with a full-library cache, picking any entry
|
|
# would corrupt the isDeleted reconciliation below.
|
|
parent_item = local_cache.get(model_hash.lower()) if model_hash else None
|
|
if parent_item:
|
|
civ = parent_item.get("civitai") or {}
|
|
if isinstance(civ, dict):
|
|
parent_civitai_id = civ.get("id")
|
|
parent_model_id = civ.get("modelId")
|
|
parent_version_name = civ.get("name")
|
|
parent_model_name = parent_item.get("model_name")
|
|
|
|
# Reconcile isDeleted entries against the parent model.
|
|
# When the CivitAI hash lookup fails (known issue — hashes not
|
|
# yet computed), the parser marks the entry isDeleted even though
|
|
# the model exists locally.
|
|
if is_lora_type:
|
|
for lora in loras:
|
|
if lora.get("isDeleted") and lora.get("file_name") == model_name:
|
|
lora["isDeleted"] = False
|
|
lora["existsLocally"] = True
|
|
lora["hash"] = model_hash
|
|
if parent_civitai_id is not None:
|
|
lora["id"] = parent_civitai_id
|
|
if parent_model_id is not None:
|
|
lora["modelId"] = parent_model_id
|
|
if parent_version_name is not None:
|
|
lora["version"] = parent_version_name
|
|
if parent_model_name is not None:
|
|
lora["name"] = parent_model_name
|
|
elif is_ckpt_type and checkpoint and checkpoint.get("isDeleted"):
|
|
if checkpoint.get("file_name") == model_name:
|
|
checkpoint["isDeleted"] = False
|
|
checkpoint["existsLocally"] = True
|
|
checkpoint["hash"] = model_hash
|
|
if parent_civitai_id is not None:
|
|
checkpoint["id"] = parent_civitai_id
|
|
if parent_model_id is not None:
|
|
checkpoint["modelId"] = parent_model_id
|
|
if parent_version_name is not None:
|
|
checkpoint["version"] = parent_version_name
|
|
|
|
# Auto-populate parent model only when the image metadata didn't
|
|
# contain any resources of that type.
|
|
if is_lora_type and not loras:
|
|
lora_entry = {
|
|
"name": model_name,
|
|
"type": "lora",
|
|
"weight": 1.0,
|
|
"hash": model_hash,
|
|
"existsLocally": True,
|
|
"localPath": None,
|
|
"file_name": model_name,
|
|
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
|
"baseModel": parsed.get("base_model", ""),
|
|
"size": 0,
|
|
"downloadUrl": "",
|
|
"isDeleted": False,
|
|
}
|
|
if parent_civitai_id is not None:
|
|
lora_entry["id"] = parent_civitai_id
|
|
if parent_model_id is not None:
|
|
lora_entry["modelId"] = parent_model_id
|
|
if parent_version_name is not None:
|
|
lora_entry["version"] = parent_version_name
|
|
if parent_model_name is not None:
|
|
lora_entry["name"] = parent_model_name
|
|
loras.insert(0, lora_entry)
|
|
elif is_ckpt_type and not checkpoint:
|
|
checkpoint = {
|
|
"name": model_name,
|
|
"type": "checkpoint",
|
|
"hash": model_hash,
|
|
"file_name": model_name,
|
|
"existsLocally": True,
|
|
"baseModel": parsed.get("base_model", ""),
|
|
"isDeleted": False,
|
|
}
|
|
if parent_civitai_id is not None:
|
|
checkpoint["id"] = parent_civitai_id
|
|
if parent_model_id is not None:
|
|
checkpoint["modelId"] = parent_model_id
|
|
if parent_version_name is not None:
|
|
checkpoint["version"] = parent_version_name
|
|
if parent_model_name is not None:
|
|
checkpoint["name"] = parent_model_name
|
|
|
|
image_url = image_data.get("url") or ""
|
|
image_id = extract_civitai_image_id_from_cdn_url(image_url)
|
|
settings_mgr = get_settings_manager()
|
|
civitai_host = settings_mgr.get("civitai_host") if settings_mgr else None
|
|
page_url = build_civitai_image_page_url(image_id, host=civitai_host) or image_url
|
|
|
|
recipe_metadata: dict[str, Any] = {
|
|
"base_model": parsed.get("base_model") or "",
|
|
"loras": loras,
|
|
"gen_params": parsed.get("gen_params") or {},
|
|
"source_path": page_url,
|
|
}
|
|
nsfw_level = image_data.get("nsfwLevel")
|
|
if isinstance(nsfw_level, int):
|
|
recipe_metadata["preview_nsfw_level"] = nsfw_level
|
|
if checkpoint:
|
|
recipe_metadata["checkpoint"] = checkpoint
|
|
|
|
image_bytes: bytes | None = None
|
|
extension: str | None = None
|
|
local_image_path = data.get("local_image_path")
|
|
if local_image_path and os.path.exists(local_image_path):
|
|
with open(local_image_path, "rb") as f:
|
|
image_bytes = f.read()
|
|
ext = os.path.splitext(local_image_path)[1].lower()
|
|
if ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
|
extension = ext
|
|
elif image_data.get("url"):
|
|
try:
|
|
downloader = await self._downloader_factory()
|
|
url = image_data["url"]
|
|
tmp = tempfile.NamedTemporaryFile(delete=False)
|
|
tmp.close()
|
|
success, result = await downloader.download_file(
|
|
url, tmp.name, use_auth=False
|
|
)
|
|
if success:
|
|
with open(tmp.name, "rb") as f:
|
|
image_bytes = f.read()
|
|
url_path = url.split("?")[0].split("#")[0]
|
|
ext = os.path.splitext(url_path)[1].lower()
|
|
if ext:
|
|
extension = ext
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|
|
except Exception as exc:
|
|
self._logger.warning(
|
|
"Failed to download image for recipe: %s", exc
|
|
)
|
|
|
|
# Fallback: try to locate a custom image on disk using model_hash + image id
|
|
if image_bytes is None:
|
|
image_id = image_data.get("id") or ""
|
|
if image_id and model_hash:
|
|
from ...utils.example_images_paths import get_model_folder
|
|
model_folder = get_model_folder(model_hash)
|
|
if model_folder and os.path.exists(model_folder):
|
|
for fname in os.listdir(model_folder):
|
|
if f"custom_{image_id}" in fname:
|
|
ext = os.path.splitext(fname)[1].lower()
|
|
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
|
continue
|
|
fpath = os.path.join(model_folder, fname)
|
|
if os.path.isfile(fpath):
|
|
try:
|
|
with open(fpath, "rb") as f:
|
|
image_bytes = f.read()
|
|
extension = ext
|
|
except Exception as exc:
|
|
self._logger.warning(
|
|
"Failed to read custom image file %s: %s",
|
|
fpath, exc,
|
|
)
|
|
break
|
|
|
|
prompt = (
|
|
(parsed.get("gen_params") or {}).get("prompt") or ""
|
|
)
|
|
if prompt:
|
|
name = " ".join(str(prompt).split()[:10])
|
|
else:
|
|
name = f"Recipe from {model_name}"
|
|
|
|
save_result = await self._persistence_service.save_recipe(
|
|
recipe_scanner=recipe_scanner,
|
|
image_bytes=image_bytes,
|
|
image_base64=None,
|
|
name=name,
|
|
tags=[],
|
|
metadata=recipe_metadata,
|
|
extension=extension,
|
|
)
|
|
return web.json_response(save_result.payload, status=save_result.status)
|
|
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error creating recipe from example: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
|
|
class RecipeAnalysisHandler:
|
|
"""Analyze images to extract recipe metadata."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
|
recipe_scanner_getter: RecipeScannerGetter,
|
|
civitai_client_getter: CivitaiClientGetter,
|
|
logger: Logger,
|
|
analysis_service: RecipeAnalysisService,
|
|
) -> None:
|
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
|
self._recipe_scanner_getter = recipe_scanner_getter
|
|
self._civitai_client_getter = civitai_client_getter
|
|
self._logger = logger
|
|
self._analysis_service = analysis_service
|
|
|
|
async def analyze_uploaded_image(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
civitai_client = self._civitai_client_getter()
|
|
if recipe_scanner is None or civitai_client is None:
|
|
raise RuntimeError("Required services unavailable")
|
|
|
|
content_type = request.headers.get("Content-Type", "")
|
|
if "multipart/form-data" in content_type:
|
|
reader = await request.multipart()
|
|
field: Any = await reader.next()
|
|
if field is None or field.name != "image":
|
|
raise RecipeValidationError("No image field found")
|
|
image_chunks = bytearray()
|
|
while True:
|
|
chunk = await field.read_chunk()
|
|
if not chunk:
|
|
break
|
|
image_chunks.extend(chunk)
|
|
result = await self._analysis_service.analyze_uploaded_image(
|
|
image_bytes=bytes(image_chunks),
|
|
recipe_scanner=recipe_scanner,
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
|
|
if "application/json" in content_type:
|
|
data = await request.json()
|
|
result = await self._analysis_service.analyze_remote_image(
|
|
url=data.get("url"),
|
|
recipe_scanner=recipe_scanner,
|
|
civitai_client=civitai_client,
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
|
|
raise RecipeValidationError("Unsupported content type")
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc), "loras": []}, status=400)
|
|
except RecipeDownloadError as exc:
|
|
return web.json_response({"error": str(exc), "loras": []}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc), "loras": []}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error analyzing recipe image: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc), "loras": []}, status=500)
|
|
|
|
async def analyze_local_image(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
data = await request.json()
|
|
result = await self._analysis_service.analyze_local_image(
|
|
file_path=data.get("path"),
|
|
recipe_scanner=recipe_scanner,
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"error": str(exc), "loras": []}, status=400)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc), "loras": []}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error analyzing local image: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc), "loras": []}, status=500)
|
|
|
|
|
|
class RecipeSharingHandler:
|
|
"""Serve endpoints related to recipe sharing."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
|
recipe_scanner_getter: RecipeScannerGetter,
|
|
logger: Logger,
|
|
sharing_service: RecipeSharingService,
|
|
) -> None:
|
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
|
self._recipe_scanner_getter = recipe_scanner_getter
|
|
self._logger = logger
|
|
self._sharing_service = sharing_service
|
|
|
|
async def share_recipe(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
result = await self._sharing_service.share_recipe(
|
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
|
)
|
|
return web.json_response(result.payload, status=result.status)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error("Error sharing recipe: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
async def download_shared_recipe(self, request: web.Request) -> web.StreamResponse:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
download_info = await self._sharing_service.prepare_download(
|
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
|
)
|
|
return web.FileResponse(
|
|
download_info.file_path,
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{download_info.download_filename}"'
|
|
},
|
|
)
|
|
except RecipeNotFoundError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error downloading shared recipe: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
|
|
class RecipeWorkflowHandler:
|
|
"""Extract an embedded workflow from a recipe image and broadcast it."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
|
recipe_scanner_getter: RecipeScannerGetter,
|
|
prompt_server: type[PromptServerProtocol],
|
|
logger: Logger,
|
|
) -> None:
|
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
|
self._recipe_scanner_getter = recipe_scanner_getter
|
|
self._prompt_server = prompt_server
|
|
self._logger = logger
|
|
|
|
async def send_recipe_workflow(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
recipe_scanner = self._recipe_scanner_getter()
|
|
if recipe_scanner is None:
|
|
raise RuntimeError("Recipe scanner unavailable")
|
|
|
|
recipe_id = request.match_info["recipe_id"]
|
|
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
|
if not recipe:
|
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
|
|
|
if os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1":
|
|
return web.json_response(
|
|
{"error": "Standalone Mode Active"}, status=400
|
|
)
|
|
|
|
image_path = recipe.get("file_path")
|
|
if not image_path:
|
|
return web.json_response({"error": "no_workflow"}, status=404)
|
|
|
|
metadata = await asyncio.to_thread(
|
|
ExifUtils._load_structured_metadata, image_path
|
|
)
|
|
workflow_raw = metadata.get("workflow")
|
|
if not workflow_raw:
|
|
return web.json_response(
|
|
{
|
|
"error": "no_workflow",
|
|
"message": "No embedded workflow found in recipe image",
|
|
},
|
|
status=404,
|
|
)
|
|
|
|
# _load_structured_metadata always yields workflow as a JSON string;
|
|
# the frontend extension expects a parsed object for loadGraphData.
|
|
try:
|
|
workflow = (
|
|
json.loads(workflow_raw)
|
|
if isinstance(workflow_raw, str)
|
|
else workflow_raw
|
|
)
|
|
except (TypeError, ValueError):
|
|
self._logger.warning(
|
|
"Recipe %s embeds a non-JSON workflow payload; skipping send",
|
|
recipe_id,
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"error": "no_workflow",
|
|
"message": "Embedded workflow data is not valid JSON",
|
|
},
|
|
status=404,
|
|
)
|
|
|
|
self._prompt_server.instance.send_sync(
|
|
"lm_load_workflow",
|
|
{
|
|
"workflow": workflow,
|
|
"name": recipe.get("title") or "",
|
|
"recipe_id": recipe_id,
|
|
},
|
|
)
|
|
return web.json_response({"success": True, "sent": True})
|
|
except Exception as exc:
|
|
self._logger.error("Error sending recipe workflow: %s", exc, exc_info=True)
|
|
return web.json_response({"error": str(exc)}, status=500)
|
|
|
|
|
|
class BatchImportHandler:
|
|
"""Handle batch import operations for recipes."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
|
recipe_scanner_getter: RecipeScannerGetter,
|
|
civitai_client_getter: CivitaiClientGetter,
|
|
logger: Logger,
|
|
batch_import_service: BatchImportService,
|
|
) -> None:
|
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
|
self._recipe_scanner_getter = recipe_scanner_getter
|
|
self._civitai_client_getter = civitai_client_getter
|
|
self._logger = logger
|
|
self._batch_import_service = batch_import_service
|
|
|
|
async def start_batch_import(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
|
|
if self._batch_import_service.is_import_running():
|
|
return web.json_response(
|
|
{"success": False, "error": "Batch import already in progress"},
|
|
status=409,
|
|
)
|
|
|
|
data = await request.json()
|
|
items = data.get("items", [])
|
|
tags = data.get("tags", [])
|
|
skip_no_metadata = data.get("skip_no_metadata", False)
|
|
|
|
if not items:
|
|
return web.json_response(
|
|
{"success": False, "error": "No items provided"},
|
|
status=400,
|
|
)
|
|
|
|
for item in items:
|
|
if not item.get("source"):
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"error": "Each item must have a 'source' field",
|
|
},
|
|
status=400,
|
|
)
|
|
|
|
operation_id = await self._batch_import_service.start_batch_import(
|
|
recipe_scanner_getter=self._recipe_scanner_getter,
|
|
civitai_client_getter=self._civitai_client_getter,
|
|
items=items,
|
|
tags=tags,
|
|
skip_no_metadata=skip_no_metadata,
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"operation_id": operation_id,
|
|
}
|
|
)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self._logger.error("Error starting batch import: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def start_directory_import(self, request: web.Request) -> web.Response:
|
|
try:
|
|
await self._ensure_dependencies_ready()
|
|
|
|
if self._batch_import_service.is_import_running():
|
|
return web.json_response(
|
|
{"success": False, "error": "Batch import already in progress"},
|
|
status=409,
|
|
)
|
|
|
|
data = await request.json()
|
|
directory = data.get("directory")
|
|
recursive = data.get("recursive", True)
|
|
tags = data.get("tags", [])
|
|
skip_no_metadata = data.get("skip_no_metadata", True)
|
|
|
|
if not directory:
|
|
return web.json_response(
|
|
{"success": False, "error": "Directory path is required"},
|
|
status=400,
|
|
)
|
|
|
|
operation_id = await self._batch_import_service.start_directory_import(
|
|
recipe_scanner_getter=self._recipe_scanner_getter,
|
|
civitai_client_getter=self._civitai_client_getter,
|
|
directory=directory,
|
|
recursive=recursive,
|
|
tags=tags,
|
|
skip_no_metadata=skip_no_metadata,
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"operation_id": operation_id,
|
|
}
|
|
)
|
|
except RecipeValidationError as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error starting directory import: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def get_batch_import_progress(self, request: web.Request) -> web.Response:
|
|
try:
|
|
operation_id = request.query.get("operation_id")
|
|
if not operation_id:
|
|
return web.json_response(
|
|
{"success": False, "error": "operation_id is required"},
|
|
status=400,
|
|
)
|
|
|
|
progress = self._batch_import_service.get_progress(operation_id)
|
|
if not progress:
|
|
return web.json_response(
|
|
{"success": False, "error": "Operation not found"},
|
|
status=404,
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"progress": progress.to_dict(),
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error(
|
|
"Error getting batch import progress: %s", exc, exc_info=True
|
|
)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def cancel_batch_import(self, request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
operation_id = data.get("operation_id")
|
|
|
|
if not operation_id:
|
|
return web.json_response(
|
|
{"success": False, "error": "operation_id is required"},
|
|
status=400,
|
|
)
|
|
|
|
cancelled = self._batch_import_service.cancel_import(operation_id)
|
|
if not cancelled:
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"error": "Operation not found or already completed",
|
|
},
|
|
status=404,
|
|
)
|
|
|
|
return web.json_response(
|
|
{"success": True, "message": "Cancellation requested"}
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error("Error cancelling batch import: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
|
|
|
async def browse_directory(self, request: web.Request) -> web.Response:
|
|
"""Browse a directory and return its contents (subdirectories and files)."""
|
|
try:
|
|
data = await request.json()
|
|
directory_path = data.get("path", "")
|
|
|
|
if not directory_path:
|
|
return web.json_response(
|
|
{"success": False, "error": "Directory path is required"},
|
|
status=400,
|
|
)
|
|
|
|
# Normalize the path
|
|
path = Path(directory_path).expanduser().resolve()
|
|
|
|
# Security check: ensure path is within allowed directories
|
|
# Allow common image/model directories
|
|
allowed_roots = [
|
|
Path.home(),
|
|
Path("/"), # Allow browsing from root for flexibility
|
|
]
|
|
|
|
# Check if path is within any allowed root
|
|
is_allowed = False
|
|
for root in allowed_roots:
|
|
try:
|
|
path.relative_to(root)
|
|
is_allowed = True
|
|
break
|
|
except ValueError:
|
|
continue
|
|
|
|
if not is_allowed:
|
|
return web.json_response(
|
|
{"success": False, "error": "Access denied to this directory"},
|
|
status=403,
|
|
)
|
|
|
|
if not path.exists():
|
|
return web.json_response(
|
|
{"success": False, "error": "Directory does not exist"},
|
|
status=404,
|
|
)
|
|
|
|
if not path.is_dir():
|
|
return web.json_response(
|
|
{"success": False, "error": "Path is not a directory"},
|
|
status=400,
|
|
)
|
|
|
|
# List directory contents
|
|
directories = []
|
|
image_files = []
|
|
|
|
image_extensions = {
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".webp",
|
|
".bmp",
|
|
".tiff",
|
|
".tif",
|
|
}
|
|
|
|
try:
|
|
for item in path.iterdir():
|
|
try:
|
|
if item.is_dir():
|
|
# Skip hidden directories and common system folders
|
|
if not item.name.startswith(".") and item.name not in [
|
|
"__pycache__",
|
|
"node_modules",
|
|
]:
|
|
directories.append(
|
|
{
|
|
"name": item.name,
|
|
"path": str(item),
|
|
"is_parent": False,
|
|
}
|
|
)
|
|
elif item.is_file() and item.suffix.lower() in image_extensions:
|
|
image_files.append(
|
|
{
|
|
"name": item.name,
|
|
"path": str(item),
|
|
"size": item.stat().st_size,
|
|
}
|
|
)
|
|
except (PermissionError, OSError):
|
|
# Skip files/directories we can't access
|
|
continue
|
|
|
|
# Sort directories and files alphabetically
|
|
directories.sort(key=lambda x: x["name"].lower())
|
|
image_files.sort(key=lambda x: x["name"].lower())
|
|
|
|
# Add parent directory if not at root
|
|
parent_path = path.parent
|
|
show_parent = str(path) != str(path.root)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"current_path": str(path),
|
|
"parent_path": str(parent_path) if show_parent else None,
|
|
"directories": directories,
|
|
"image_files": image_files,
|
|
"image_count": len(image_files),
|
|
"directory_count": len(directories),
|
|
}
|
|
)
|
|
|
|
except PermissionError:
|
|
return web.json_response(
|
|
{"success": False, "error": "Permission denied"},
|
|
status=403,
|
|
)
|
|
except OSError as exc:
|
|
return web.json_response(
|
|
{"success": False, "error": f"Error reading directory: {str(exc)}"},
|
|
status=500,
|
|
)
|
|
|
|
except json.JSONDecodeError:
|
|
return web.json_response(
|
|
{"success": False, "error": "Invalid JSON"},
|
|
status=400,
|
|
)
|
|
except Exception as exc:
|
|
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|