1922 lines
75 KiB
Python
1922 lines
75 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
|
|
import folder_paths
|
|
|
|
try:
|
|
import imagehash
|
|
except ImportError:
|
|
imagehash = None # type: ignore
|
|
|
|
import server
|
|
from aiohttp import web
|
|
from PIL import Image
|
|
|
|
from ..load_image_configs.core.metadata_helper import MetadataHelper
|
|
from .constants import RESERVED_TAG_KEYWORD
|
|
from .database import (
|
|
TRASH_DIR,
|
|
get_db_connection,
|
|
)
|
|
from .repositories.image_repo import calculate_sha256
|
|
from .repositories.model_repo import add_model_relation, get_or_create_model
|
|
from .repositories.settings_repo import get_all_settings, upsert_setting
|
|
from .repositories.tag_repo import delete_tag, get_all_tags, rename_tag
|
|
from .schemas import (
|
|
ApiResponse,
|
|
BulkDeleteRequest,
|
|
BulkUpdateImageTagsRequest,
|
|
CreateFavoriteRequest,
|
|
CreateTagRequest,
|
|
DeleteFavoriteRequest,
|
|
DeleteImageRequest,
|
|
DeleteTagRequest,
|
|
FavoriteRecord,
|
|
FolderItem,
|
|
FolderMetadata,
|
|
FolderPreview,
|
|
FoldersResponse,
|
|
ImageItem,
|
|
ImageListItem,
|
|
ImageListResponse,
|
|
ImageSnapshotResponse,
|
|
LineageItem,
|
|
LinkParentRequest,
|
|
RegisterImageRequest,
|
|
RenameTagRequest,
|
|
RestoreImagesRequest,
|
|
ScanRequest,
|
|
SearchImagesRequest,
|
|
TagRecord,
|
|
UpdateFavoriteRequest,
|
|
UpdateImageTagsRequest,
|
|
UpdateSettingsRequest,
|
|
)
|
|
from .search_service import SearchService
|
|
from .services import image_service, scan_service
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/image-tags")
|
|
async def update_image_tags(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = UpdateImageTagsRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if req.imageId is None:
|
|
return web.json_response({"error": "imageId is required"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
# 1. Clear existing tags for this image
|
|
cursor.execute("DELETE FROM tag_image_relations WHERE image_id = ?", (req.imageId,))
|
|
|
|
# 2. Add new tags and create relations
|
|
# Deduplicate tags from request
|
|
unique_tags = list(dict.fromkeys(req.tags))
|
|
for tag_name in unique_tags:
|
|
tag_name = tag_name.strip()
|
|
if not tag_name or tag_name.lower() == RESERVED_TAG_KEYWORD:
|
|
continue
|
|
|
|
# Get or create tag
|
|
cursor.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,))
|
|
cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,))
|
|
tag_row = cursor.fetchone()
|
|
if tag_row:
|
|
tag_id = tag_row[0]
|
|
cursor.execute(
|
|
"INSERT OR IGNORE INTO tag_image_relations (image_id, tag_id) VALUES (?, ?)",
|
|
(req.imageId, tag_id),
|
|
)
|
|
|
|
conn.commit()
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
except Exception as e:
|
|
conn.rollback()
|
|
raise e
|
|
finally:
|
|
conn.close()
|
|
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/bulk-image-tags")
|
|
async def bulk_update_image_tags(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = BulkUpdateImageTagsRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if not req.imageIds:
|
|
return web.json_response({"error": "imageIds is required"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
# 1. Process tags to add
|
|
# Deduplicate tags from request
|
|
unique_add_tags = list(dict.fromkeys(req.addTags))
|
|
for tag_name in unique_add_tags:
|
|
tag_name = tag_name.strip()
|
|
if not tag_name or tag_name.lower() == RESERVED_TAG_KEYWORD:
|
|
continue
|
|
|
|
# Get or create tag
|
|
cursor.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,))
|
|
cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,))
|
|
tag_row = cursor.fetchone()
|
|
if tag_row:
|
|
tag_id = tag_row[0]
|
|
# Add to all selected images
|
|
for image_id in req.imageIds:
|
|
cursor.execute(
|
|
"INSERT OR IGNORE INTO tag_image_relations (image_id, tag_id) VALUES (?, ?)",
|
|
(image_id, tag_id),
|
|
)
|
|
|
|
# 2. Process tags to remove
|
|
for tag_name in req.removeTags:
|
|
tag_name = tag_name.strip()
|
|
if not tag_name:
|
|
continue
|
|
|
|
cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,))
|
|
tag_row = cursor.fetchone()
|
|
if tag_row:
|
|
tag_id = tag_row[0]
|
|
# Remove from all selected images
|
|
placeholders = ",".join(["?"] * len(req.imageIds))
|
|
cursor.execute(
|
|
f"DELETE FROM tag_image_relations WHERE tag_id = ? AND image_id IN ({placeholders})",
|
|
(tag_id, *req.imageIds),
|
|
)
|
|
|
|
conn.commit()
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
except Exception as e:
|
|
conn.rollback()
|
|
raise e
|
|
finally:
|
|
conn.close()
|
|
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/cleanup")
|
|
async def cleanup_endpoint(request: web.Request) -> web.Response:
|
|
try:
|
|
count = scan_service.perform_cleanup()
|
|
return web.json_response({"success": True, "count": count})
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/folders")
|
|
async def list_folders(request: web.Request) -> web.Response:
|
|
try:
|
|
path = request.query.get("path", "")
|
|
base_type = request.query.get("type", "output")
|
|
fast = request.query.get("fast", "false").lower() == "true"
|
|
logging.info(f"[Meld] GET /meld/folders type={base_type} path={path} fast={fast}")
|
|
|
|
if base_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
elif base_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
else:
|
|
base_dir = "" # Absolute path mode
|
|
|
|
target_path = os.path.abspath(os.path.join(base_dir, path))
|
|
|
|
if not os.path.exists(target_path):
|
|
return web.json_response({"folders": [], "images": [], "image_count": 0})
|
|
|
|
if not os.path.isdir(target_path):
|
|
return web.json_response({"error": "Not a directory"}, status=400)
|
|
|
|
folders: list[FolderItem] = []
|
|
images: list[ImageItem] = []
|
|
total_recursive_count = 0
|
|
|
|
try:
|
|
# Run blocking IO in a separate thread to avoid blocking the event loop
|
|
loop = asyncio.get_event_loop()
|
|
items = await loop.run_in_executor(None, os.listdir, target_path)
|
|
|
|
for item in items:
|
|
full_item_path = os.path.join(target_path, item)
|
|
# Check isdir in a thread if needed, but for simple listdir results it's usually fast enough.
|
|
# However, count_images_recursive and get_first_image_recursive MUST be in threads.
|
|
if await loop.run_in_executor(None, os.path.isdir, full_item_path):
|
|
if fast:
|
|
folders.append(FolderItem(name=item, count=None, preview=None))
|
|
else:
|
|
# Recursive count for subfolder
|
|
sub_count = await loop.run_in_executor(
|
|
None, scan_service.count_images_recursive, full_item_path
|
|
)
|
|
|
|
# Get sample image for preview
|
|
preview = None
|
|
sample_img_path = await loop.run_in_executor(
|
|
None, scan_service.get_first_image_recursive, full_item_path
|
|
)
|
|
if sample_img_path:
|
|
if base_dir:
|
|
rel_path = os.path.relpath(sample_img_path, base_dir)
|
|
filename = os.path.basename(rel_path)
|
|
subfolder = os.path.dirname(rel_path).replace("\\", "/")
|
|
preview = FolderPreview(
|
|
filename=filename,
|
|
subfolder=subfolder,
|
|
type=base_type,
|
|
)
|
|
else:
|
|
# Custom path mode
|
|
preview = FolderPreview(
|
|
filename=os.path.basename(sample_img_path),
|
|
subfolder=os.path.dirname(sample_img_path).replace("\\", "/"),
|
|
type=base_type,
|
|
)
|
|
|
|
folders.append(FolderItem(name=item, count=sub_count, preview=preview))
|
|
elif item.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
|
|
if base_dir:
|
|
images.append(
|
|
ImageItem(
|
|
filename=item,
|
|
subfolder=path.replace("\\", "/"),
|
|
type=base_type,
|
|
)
|
|
)
|
|
else:
|
|
# Custom path mode
|
|
images.append(
|
|
ImageItem(
|
|
filename=item,
|
|
subfolder=target_path.replace("\\", "/"),
|
|
type=base_type,
|
|
)
|
|
)
|
|
|
|
# The top-level 'image_count' should also be recursive if it includes subdirectories.
|
|
if not fast:
|
|
total_recursive_count = await loop.run_in_executor(
|
|
None, scan_service.count_images_recursive, target_path
|
|
)
|
|
else:
|
|
total_recursive_count = len(images)
|
|
|
|
except PermissionError:
|
|
return web.json_response({"error": "Permission denied"}, status=403)
|
|
|
|
folders.sort(key=lambda x: x.name.lower())
|
|
images.sort(key=lambda x: x.filename.lower())
|
|
|
|
response = FoldersResponse(
|
|
folders=folders,
|
|
images=images,
|
|
image_count=total_recursive_count,
|
|
)
|
|
return web.json_response(response.to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/folder-metadata")
|
|
async def get_folder_metadata(request: web.Request) -> web.Response:
|
|
try:
|
|
path = request.query.get("path", "")
|
|
base_type = request.query.get("type", "output")
|
|
folder_names = request.query.get("folders", "").split(",")
|
|
logging.info(f"[Meld] GET /meld/folder-metadata type={base_type} path={path} folders={folder_names}")
|
|
|
|
if base_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
elif base_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
else:
|
|
base_dir = "" # Absolute path mode
|
|
|
|
target_path = os.path.abspath(os.path.join(base_dir, path))
|
|
|
|
results = {}
|
|
loop = asyncio.get_event_loop()
|
|
for name in folder_names:
|
|
if not name:
|
|
continue
|
|
full_item_path = os.path.join(target_path, name)
|
|
if await loop.run_in_executor(None, os.path.isdir, full_item_path):
|
|
sub_count = await loop.run_in_executor(None, scan_service.count_images_recursive, full_item_path)
|
|
preview = None
|
|
sample_img_path = await loop.run_in_executor(
|
|
None, scan_service.get_first_image_recursive, full_item_path
|
|
)
|
|
if sample_img_path:
|
|
if base_dir:
|
|
rel_path = os.path.relpath(sample_img_path, base_dir)
|
|
filename = os.path.basename(rel_path)
|
|
subfolder = os.path.dirname(rel_path).replace("\\", "/")
|
|
preview = FolderPreview(
|
|
filename=filename,
|
|
subfolder=subfolder,
|
|
type=base_type,
|
|
)
|
|
else:
|
|
# Custom path mode
|
|
preview = FolderPreview(
|
|
filename=os.path.basename(sample_img_path),
|
|
subfolder=os.path.dirname(sample_img_path).replace("\\", "/"),
|
|
type=base_type,
|
|
)
|
|
results[name] = FolderMetadata(count=sub_count, preview=preview).to_dict()
|
|
|
|
return web.json_response(results)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/path-image-count")
|
|
async def get_path_image_count(request: web.Request) -> web.Response:
|
|
try:
|
|
path = request.query.get("path", "")
|
|
base_type = request.query.get("type", "output")
|
|
logging.info(f"[Meld] GET /meld/path-image-count type={base_type} path={path}")
|
|
|
|
if base_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
elif base_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
else:
|
|
base_dir = ""
|
|
|
|
target_path = os.path.abspath(os.path.join(base_dir, path))
|
|
loop = asyncio.get_event_loop()
|
|
if await loop.run_in_executor(None, os.path.isdir, target_path):
|
|
count = await loop.run_in_executor(None, scan_service.count_images_recursive, target_path)
|
|
return web.json_response(ApiResponse(success=True, count=count).to_dict())
|
|
return web.json_response(ApiResponse(success=True, count=0).to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/scan")
|
|
async def start_scan(request: web.Request) -> web.Response:
|
|
if scan_service.get_scan_state().is_running:
|
|
return web.json_response({"error": "Scan already running"}, status=400)
|
|
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = ScanRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
base_dir = ""
|
|
|
|
if req.type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
target_base = os.path.join(base_dir, req.subfolder)
|
|
elif req.type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
target_base = os.path.join(base_dir, req.subfolder)
|
|
elif req.type == "custom":
|
|
if not req.custom_path:
|
|
return web.json_response({"error": "Custom path is required"}, status=400)
|
|
target_base = req.custom_path
|
|
else:
|
|
return web.json_response({"error": "Invalid type"}, status=400)
|
|
|
|
target_base = os.path.abspath(target_base)
|
|
if not os.path.exists(target_base):
|
|
return web.json_response({"error": f"Path does not exist: {target_base}"}, status=404)
|
|
|
|
# In custom mode, base_dir for relative path calculation should be the target itself
|
|
calc_base = target_base if req.type == "custom" else base_dir
|
|
|
|
scan_service.start_scan_thread(
|
|
calc_base,
|
|
req.subfolder if req.type != "custom" else "",
|
|
req.type,
|
|
req.recursive,
|
|
req.auto_link_parent,
|
|
req.tags,
|
|
req.link_strategy,
|
|
)
|
|
|
|
return web.json_response(ApiResponse(success=True, message="started").to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/scan/cancel")
|
|
async def cancel_scan(request: web.Request) -> web.Response:
|
|
scan_service.cancel_scan()
|
|
return web.json_response({"status": "cancelling"})
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/scan/status")
|
|
async def get_scan_status(request: web.Request) -> web.Response:
|
|
state = scan_service.get_scan_state()
|
|
return web.json_response(state.to_dict())
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/image/{image_id}/details")
|
|
async def get_image_details(request: web.Request) -> web.Response:
|
|
try:
|
|
image_id = request.match_info["image_id"]
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Fetch full image details from DB
|
|
sql = """
|
|
SELECT i.id, i.filename, i.subfolder, i.type, i.created_at, i.phash, i.sha256, i.parent_id,
|
|
p.filename as parent_filename, p.subfolder as parent_subfolder, p.type as parent_type,
|
|
EXISTS(SELECT 1 FROM images c WHERE c.parent_id = i.id AND c.deleted_at IS NULL) as has_children,
|
|
i.positive_prompt, i.negative_prompt,
|
|
(SELECT GROUP_CONCAT(m.name, ', ') FROM models m
|
|
JOIN model_image_relations mir ON m.id = mir.model_id
|
|
WHERE mir.image_id = i.id) as model_name,
|
|
i.workflow, i.width, i.height, i.deleted_at
|
|
FROM images i LEFT JOIN images p ON i.parent_id = p.id
|
|
WHERE i.id = ?
|
|
"""
|
|
cursor.execute(sql, (image_id,))
|
|
img = cursor.fetchone()
|
|
|
|
if not img:
|
|
conn.close()
|
|
return web.json_response({"error": "Image not found"}, status=404)
|
|
|
|
# Reuse same mapping logic as list_images for consistency
|
|
(
|
|
img_id,
|
|
filename,
|
|
subfolder,
|
|
img_type,
|
|
created_at,
|
|
phash,
|
|
sha256,
|
|
parent_id,
|
|
p_filename,
|
|
p_subfolder,
|
|
p_type,
|
|
has_children,
|
|
db_positive,
|
|
db_negative,
|
|
model_name,
|
|
workflow,
|
|
width,
|
|
height,
|
|
deleted_at,
|
|
) = img
|
|
|
|
# Fetch tags
|
|
cursor.execute(
|
|
"SELECT t.name FROM tags t JOIN tag_image_relations r ON t.id = r.tag_id WHERE r.image_id = ?",
|
|
(image_id,),
|
|
)
|
|
tags = [row[0] for row in cursor.fetchall()]
|
|
|
|
# Reconstruct prompts if necessary
|
|
positive = db_positive
|
|
if positive is None:
|
|
cursor.execute(
|
|
"SELECT pp.name, r.strength FROM positive_prompts pp JOIN positive_prompt_image_relations r ON pp.id = r.positive_prompt_id WHERE r.image_id = ?",
|
|
(image_id,),
|
|
)
|
|
positive = ", ".join([row[0] if row[1] == 1.0 else f"({row[0]}:{row[1]})" for row in cursor.fetchall()])
|
|
|
|
negative = db_negative
|
|
if negative is None:
|
|
cursor.execute(
|
|
"SELECT np.name, r.strength FROM negative_prompts np JOIN negative_prompt_image_relations r ON np.id = r.negative_prompt_id WHERE r.image_id = ?",
|
|
(image_id,),
|
|
)
|
|
negative = ", ".join([row[0] if row[1] == 1.0 else f"({row[0]}:{row[1]})" for row in cursor.fetchall()])
|
|
|
|
# Ancestors
|
|
ancestors = []
|
|
if parent_id and p_filename:
|
|
ancestors = [{"id": parent_id, "filename": p_filename, "subfolder": p_subfolder, "type": p_type}]
|
|
|
|
conn.close()
|
|
|
|
item = ImageListItem(
|
|
id=img_id,
|
|
filename=filename,
|
|
subfolder=subfolder,
|
|
type="trash" if deleted_at is not None else img_type,
|
|
created_at=created_at,
|
|
deleted_at=deleted_at,
|
|
phash=phash,
|
|
sha256=sha256,
|
|
parent_id=parent_id,
|
|
parent_filename=p_filename,
|
|
parent_subfolder=p_subfolder,
|
|
parent_type=p_type,
|
|
has_children=bool(has_children),
|
|
positive=positive,
|
|
negative=negative,
|
|
positive_prompt=db_positive,
|
|
negative_prompt=db_negative,
|
|
model_name=model_name,
|
|
workflow=workflow,
|
|
width=width,
|
|
height=height,
|
|
is_minimal=False,
|
|
tags=tags,
|
|
exists=True, # Details assumes it exists in DB, file check can be added if needed
|
|
ancestors=ancestors,
|
|
)
|
|
|
|
return web.json_response(item.to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/image/{image_id}/workflow")
|
|
async def get_image_workflow(request: web.Request) -> web.Response:
|
|
try:
|
|
image_id = request.match_info["image_id"]
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("SELECT workflow FROM images WHERE id = ? AND deleted_at IS NULL", (image_id,))
|
|
row = cursor.fetchone()
|
|
conn.close()
|
|
|
|
if row and row[0]:
|
|
try:
|
|
# workflow is stored as JSON string in DB
|
|
workflow_data = json.loads(row[0])
|
|
return web.json_response({"workflow": workflow_data})
|
|
except Exception as e:
|
|
return web.json_response({"error": f"Failed to parse workflow: {str(e)}"}, status=500)
|
|
|
|
return web.json_response({"error": "Workflow not found"}, status=404)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/image/{image_id}/snapshot_data")
|
|
async def get_image_snapshot_data(request: web.Request) -> web.Response:
|
|
try:
|
|
image_id = request.match_info["image_id"]
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute(
|
|
"""
|
|
SELECT i.filename, i.subfolder, i.type, i.positive_prompt, i.negative_prompt, i.workflow,
|
|
(SELECT m.name FROM models m
|
|
JOIN model_image_relations mir ON m.id = mir.model_id
|
|
WHERE mir.image_id = i.id LIMIT 1) as model_name,
|
|
i.width, i.height
|
|
FROM images i WHERE i.id = ? AND i.deleted_at IS NULL
|
|
""",
|
|
(image_id,),
|
|
)
|
|
row = cursor.fetchone()
|
|
conn.close()
|
|
|
|
if not row:
|
|
return web.json_response({"error": "Image not found"}, status=404)
|
|
|
|
filename, subfolder, img_type, db_pos, db_neg, workflow_json, model_name, db_width, db_height = row
|
|
|
|
# Resolve path to extract full metadata if possible
|
|
if img_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
elif img_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
elif img_type == "temp":
|
|
base_dir = folder_paths.get_temp_directory()
|
|
else:
|
|
base_dir = None
|
|
|
|
data = ImageSnapshotResponse(
|
|
model_name=model_name or "v1-5-pruned-emaonly.ckpt",
|
|
positive=db_pos or "",
|
|
negative=db_neg or "",
|
|
seed=0,
|
|
steps=20,
|
|
cfg=8.0,
|
|
sampler_name="euler",
|
|
scheduler="normal",
|
|
width=db_width or 512,
|
|
height=db_height or 512,
|
|
)
|
|
|
|
if base_dir:
|
|
full_path = os.path.join(base_dir, subfolder, filename)
|
|
if os.path.exists(full_path):
|
|
with Image.open(full_path) as img:
|
|
data.width, data.height = img.size
|
|
|
|
# Use MetadataHelper to get more details (seed, steps, etc.)
|
|
(
|
|
pos,
|
|
neg,
|
|
m_name,
|
|
wf_json,
|
|
pr_json,
|
|
a1111_text,
|
|
logs,
|
|
) = MetadataHelper.extract_metadata(full_path)
|
|
|
|
# Update with more accurate info if found
|
|
if m_name:
|
|
data.model_name = m_name
|
|
if pos:
|
|
data.positive = pos
|
|
if neg:
|
|
data.negative = neg
|
|
|
|
# Get KSampler params
|
|
k_params: dict[str, Any] = {}
|
|
found_k = False
|
|
if wf_json:
|
|
k_params, found_k = MetadataHelper.get_ksampler_params(wf_json, [])
|
|
|
|
if not found_k and pr_json:
|
|
k_params, found_k = MetadataHelper.get_ksampler_params_from_prompt(pr_json, [])
|
|
|
|
if not found_k and a1111_text:
|
|
k_params = MetadataHelper.parse_a1111_params(a1111_text)
|
|
found_k = bool(k_params)
|
|
|
|
if found_k:
|
|
for k in ["seed", "steps", "cfg", "sampler_name", "scheduler"]:
|
|
if k in k_params and k_params[k] is not None:
|
|
setattr(data, k, k_params[k])
|
|
|
|
return web.json_response(data.to_dict())
|
|
except Exception as e:
|
|
logging.exception(f"[Meld] Failed to get snapshot data: {e}")
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/tags")
|
|
async def list_tags(request: web.Request) -> web.Response:
|
|
try:
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
tags = get_all_tags(cursor)
|
|
conn.close()
|
|
return web.json_response([tag.to_dict() for tag in tags])
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/tags")
|
|
async def create_tag(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = CreateTagRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if not req.name:
|
|
return web.json_response({"error": "name is required"}, status=400)
|
|
|
|
if req.name.lower() == RESERVED_TAG_KEYWORD:
|
|
return web.json_response(
|
|
{"error": f"Tag name '{RESERVED_TAG_KEYWORD}' is reserved for search and cannot be used."}, status=400
|
|
)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (req.name,))
|
|
conn.commit()
|
|
cursor.execute("SELECT id, name FROM tags WHERE name = ?", (req.name,))
|
|
row = cursor.fetchone()
|
|
conn.close()
|
|
|
|
if row:
|
|
return web.json_response(TagRecord(id=row[0], name=row[1]).to_dict())
|
|
return web.json_response({"error": "Failed to create tag"}, status=500)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.delete("/meld/tags")
|
|
async def remove_tag(request: web.Request) -> web.Response:
|
|
try:
|
|
tag_id_str = request.query.get("id")
|
|
if not tag_id_str:
|
|
return web.json_response({"error": "id is required"}, status=400)
|
|
|
|
try:
|
|
req = DeleteTagRequest(id=int(tag_id_str))
|
|
except ValueError:
|
|
return web.json_response({"error": "id must be an integer"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
success = delete_tag(conn, req.id)
|
|
conn.close()
|
|
|
|
if success:
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
return web.json_response({"error": "Tag not found"}, status=404)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/tags/rename")
|
|
async def tag_rename_endpoint(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = RenameTagRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if req.id is None or not req.name:
|
|
return web.json_response({"error": "id and name are required"}, status=400)
|
|
|
|
if req.name.lower() == RESERVED_TAG_KEYWORD:
|
|
return web.json_response(
|
|
{"error": f"Tag name '{RESERVED_TAG_KEYWORD}' is reserved for search and cannot be used."}, status=400
|
|
)
|
|
|
|
conn = get_db_connection()
|
|
try:
|
|
success = rename_tag(conn, req.id, req.name)
|
|
if success:
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
return web.json_response({"error": "Failed to rename tag (maybe name already exists?)"}, status=400)
|
|
finally:
|
|
conn.close()
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/home-dir")
|
|
async def get_home_dir(request: web.Request) -> web.Response:
|
|
try:
|
|
home_dir = os.path.expanduser("~")
|
|
return web.json_response({"home": home_dir})
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/test")
|
|
async def test_endpoint(request: web.Request) -> web.Response:
|
|
return web.json_response({"status": "ok", "message": "Meld is running"})
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/view-trash")
|
|
async def view_trash(request: web.Request) -> web.StreamResponse:
|
|
try:
|
|
filename = request.query.get("filename")
|
|
if not filename:
|
|
return web.Response(status=400)
|
|
|
|
# Basic path traversal protection
|
|
if os.path.basename(filename) != filename:
|
|
return web.Response(status=403)
|
|
|
|
file_path = os.path.normpath(os.path.join(TRASH_DIR, filename))
|
|
if not os.path.exists(file_path):
|
|
return web.Response(status=404)
|
|
|
|
return web.FileResponse(file_path)
|
|
except Exception as e:
|
|
logging.error(f"[Meld] Error in view_trash: {e}")
|
|
return web.Response(status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/view-custom")
|
|
async def view_custom(request: web.Request) -> web.StreamResponse:
|
|
try:
|
|
filename = request.query.get("filename")
|
|
subfolder = request.query.get("subfolder") # Absolute path for custom
|
|
if not filename or subfolder is None:
|
|
return web.Response(status=400)
|
|
|
|
file_path = os.path.normpath(os.path.join(subfolder, filename))
|
|
if not os.path.exists(file_path):
|
|
return web.Response(status=404)
|
|
|
|
# Basic validation: ensure it's an image
|
|
if not filename.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
|
|
return web.Response(status=403)
|
|
|
|
return web.FileResponse(file_path)
|
|
except Exception as e:
|
|
logging.error(f"[Meld] Error in view_custom: {e}")
|
|
return web.Response(status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/settings")
|
|
async def get_settings(request: web.Request) -> web.Response:
|
|
try:
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
db_settings = get_all_settings(cursor)
|
|
conn.close()
|
|
|
|
# Default settings
|
|
settings = {
|
|
"dev_mode": os.environ.get("MELD_DEV") == "true",
|
|
"gallery.show_parent_image": True,
|
|
"gallery.hide_parent_images": True,
|
|
"gallery.trash_retention_days": 30,
|
|
"sidebar.show_filename": "filename",
|
|
"sidebar.show_dimensions": True,
|
|
"sidebar.show_created_at": False,
|
|
"sidebar.show_model_name": True,
|
|
"sidebar.show_positive_prompt": True,
|
|
"sidebar.show_negative_prompt": False,
|
|
"sidebar.show_tags": True,
|
|
"viewer.details.max_positive_prompt_lines": 7,
|
|
"viewer.details.max_negative_prompt_lines": 7,
|
|
"viewer.show_icons": True,
|
|
"viewer.small_image_mode": "fit",
|
|
"fullscreen.small_image_mode": "fit",
|
|
"gallery.matching_strategy": "phash_created",
|
|
"gallery.lineage_max_depth": 5,
|
|
"gallery.trash.show_missing": False,
|
|
"gallery.view_mode": "grid_details",
|
|
"gallery.suggest_phash_threshold": 82,
|
|
"gallery.auto_link_phash_threshold": 92,
|
|
}
|
|
|
|
# Merge with DB settings
|
|
settings.update(db_settings)
|
|
|
|
return web.json_response(settings)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/settings")
|
|
async def save_settings(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = UpdateSettingsRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if req.key is None:
|
|
return web.json_response({"error": "key is required"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
upsert_setting(cursor, req.key, req.value)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/register")
|
|
async def register_image(request: web.Request) -> web.Response:
|
|
try:
|
|
if request.has_body and request.content_type == "application/json":
|
|
data = await request.json()
|
|
else:
|
|
return web.json_response({"error": "Content-Type must be application/json"}, status=400)
|
|
|
|
try:
|
|
req = RegisterImageRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if not req.filename:
|
|
return web.json_response({"error": "filename is required"}, status=400)
|
|
|
|
# Basic validation: filename must not contain path separators
|
|
if os.path.basename(req.filename) != req.filename:
|
|
return web.json_response({"error": "invalid filename"}, status=400)
|
|
|
|
# Resolve base directory
|
|
if req.type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
elif req.type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
elif req.type == "temp":
|
|
base_dir = folder_paths.get_temp_directory()
|
|
elif req.type == "custom":
|
|
base_dir = "" # Absolute path mode
|
|
else:
|
|
return web.json_response({"error": f"invalid type: {req.type}"}, status=400)
|
|
|
|
# Resolve full path safely (prevent path traversal)
|
|
if req.type == "custom":
|
|
# For custom type, subfolder is already an absolute path
|
|
full_path = os.path.abspath(os.path.join(req.subfolder, req.filename))
|
|
else:
|
|
full_path = os.path.abspath(os.path.join(base_dir, req.subfolder, req.filename))
|
|
base_abs = os.path.abspath(base_dir)
|
|
if os.path.commonpath([base_abs, full_path]) != base_abs:
|
|
return web.json_response({"error": "invalid path"}, status=400)
|
|
|
|
if not os.path.exists(full_path):
|
|
return web.json_response({"error": f"File not found: {full_path}"}, status=404)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Get matching strategy from settings
|
|
db_settings = get_all_settings(cursor)
|
|
matching_strategy = db_settings.get("gallery.matching_strategy", "phash_created")
|
|
|
|
# Calculate pHash and SHA256
|
|
sha256 = calculate_sha256(full_path)
|
|
|
|
# Check if already registered by SHA256
|
|
cursor.execute(
|
|
"SELECT id FROM images WHERE sha256 = ? AND deleted_at IS NULL",
|
|
(sha256,),
|
|
)
|
|
existing_sha = cursor.fetchone()
|
|
if existing_sha:
|
|
conn.close()
|
|
return web.json_response(
|
|
ApiResponse(
|
|
success=True, message="Already registered (hash match)", data={"id": existing_sha[0]}
|
|
).to_dict()
|
|
)
|
|
|
|
# Check if already registered by filename/subfolder
|
|
cursor.execute(
|
|
"SELECT id FROM images WHERE filename = ? AND subfolder = ? AND deleted_at IS NULL",
|
|
(req.filename, req.subfolder),
|
|
)
|
|
existing = cursor.fetchone()
|
|
if existing:
|
|
conn.close()
|
|
return web.json_response(
|
|
ApiResponse(success=True, message="Already registered (path match)", data={"id": existing[0]}).to_dict()
|
|
)
|
|
|
|
# Extract metadata from PNG
|
|
pos, neg, model, wf_json, pr_json, a1111_text, logs = MetadataHelper.extract_metadata(full_path)
|
|
|
|
timestamp = os.path.getmtime(full_path)
|
|
|
|
# Get image dimensions
|
|
width, height = 0, 0
|
|
try:
|
|
with Image.open(full_path) as img:
|
|
width, height = img.size
|
|
except Exception:
|
|
pass
|
|
|
|
# Calculate pHash
|
|
phash = None
|
|
if imagehash is not None:
|
|
try:
|
|
with Image.open(full_path) as img:
|
|
phash = str(imagehash.phash(img))
|
|
except Exception:
|
|
logging.warning(f"[Meld] Failed to calculate phash for {full_path}")
|
|
|
|
# Infer parent_id
|
|
parent_id = scan_service.infer_parent_id(
|
|
cursor, req.filename, req.subfolder, req.type, phash, timestamp, strategy=matching_strategy
|
|
)
|
|
|
|
# Insert Image
|
|
sql = """
|
|
INSERT INTO images
|
|
(filename, subfolder, type, created_at, phash, sha256, width, height, parent_id, deleted_at, positive_prompt, negative_prompt, workflow)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
|
|
"""
|
|
cursor.execute(
|
|
sql,
|
|
(
|
|
req.filename,
|
|
req.subfolder,
|
|
req.type,
|
|
timestamp,
|
|
phash,
|
|
sha256,
|
|
width,
|
|
height,
|
|
parent_id,
|
|
pos,
|
|
neg,
|
|
wf_json,
|
|
),
|
|
)
|
|
image_id = cursor.lastrowid
|
|
|
|
# Insert Model Relation
|
|
if model:
|
|
m_id = get_or_create_model(cursor, model)
|
|
add_model_relation(cursor, image_id, m_id)
|
|
|
|
# Insert Prompts
|
|
pos_list = MetadataHelper.smart_split(pos) if pos else []
|
|
neg_list = MetadataHelper.smart_split(neg) if neg else []
|
|
|
|
for p in pos_list:
|
|
prompt_results = MetadataHelper.parse_prompt_with_weight(p)
|
|
for clean_name, strength in prompt_results:
|
|
if not clean_name:
|
|
continue
|
|
cursor.execute("INSERT OR IGNORE INTO positive_prompts (name) VALUES (?)", (clean_name,))
|
|
cursor.execute("SELECT id FROM positive_prompts WHERE name = ?", (clean_name,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
pp_id = row[0]
|
|
cursor.execute(
|
|
"INSERT INTO positive_prompt_image_relations (image_id, positive_prompt_id, strength) VALUES (?, ?, ?)",
|
|
(image_id, pp_id, strength),
|
|
)
|
|
|
|
for n in neg_list:
|
|
prompt_results = MetadataHelper.parse_prompt_with_weight(n)
|
|
for clean_name, strength in prompt_results:
|
|
if not clean_name:
|
|
continue
|
|
cursor.execute("INSERT OR IGNORE INTO negative_prompts (name) VALUES (?)", (clean_name,))
|
|
cursor.execute("SELECT id FROM negative_prompts WHERE name = ?", (clean_name,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
np_id = row[0]
|
|
cursor.execute(
|
|
"INSERT INTO negative_prompt_image_relations (image_id, negative_prompt_id, strength) VALUES (?, ?, ?)",
|
|
(image_id, np_id, strength),
|
|
)
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Notify frontend
|
|
server.PromptServer.instance.send_sync("meld-image-saved", {"count": 1})
|
|
|
|
return web.json_response(ApiResponse(success=True, data={"id": image_id}).to_dict())
|
|
except Exception:
|
|
logging.exception("[Meld] Failed to register image")
|
|
return web.json_response({"error": "internal error"}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/suggest")
|
|
async def suggest_endpoint(request: web.Request) -> web.Response:
|
|
try:
|
|
query = request.query.get("query", "")
|
|
prefix = request.query.get("type", None)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
suggestions = SearchService.get_suggestions(cursor, query, prefix_filter=prefix)
|
|
conn.close()
|
|
|
|
return web.json_response(suggestions)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/search-suggestions")
|
|
async def search_suggestions_endpoint(request: web.Request) -> web.Response:
|
|
try:
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
suggestions = SearchService.get_random_search_suggestions(cursor)
|
|
conn.close()
|
|
|
|
return web.json_response(suggestions)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/list")
|
|
async def list_images(request: web.Request) -> web.Response:
|
|
try:
|
|
start_time = time.time()
|
|
is_minimal = request.query.get("minimal", "false").lower() == "true"
|
|
req = SearchImagesRequest(
|
|
offset=int(request.query.get("offset", 0)),
|
|
limit=int(request.query.get("limit", 1000000)),
|
|
query=request.query.get("query", ""),
|
|
view=request.query.get("view", "default"),
|
|
)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
search_sql, search_params, order_sql = SearchService.build_search_sql(req.query)
|
|
|
|
# Get settings
|
|
db_settings = get_all_settings(cursor)
|
|
lineage_max_depth = int(db_settings.get("gallery.lineage_max_depth", 5))
|
|
|
|
# Determine deletion filter
|
|
deleted_filter = "i.deleted_at IS NOT NULL" if req.view == "trash" else "i.deleted_at IS NULL"
|
|
|
|
# Get total count
|
|
count_sql = f"SELECT COUNT(*) FROM images i WHERE {deleted_filter}{search_sql}"
|
|
cursor.execute(count_sql, search_params)
|
|
total_count = cursor.fetchone()[0]
|
|
|
|
# Determine order
|
|
if order_sql:
|
|
final_order = order_sql
|
|
else:
|
|
final_order = "i.deleted_at DESC" if req.view == "trash" else "i.created_at DESC"
|
|
|
|
# Fetch images with basic info
|
|
fetch_sql = f"""
|
|
SELECT i.id, i.filename, i.subfolder, i.type, i.created_at, i.phash, i.sha256, i.parent_id,
|
|
p.filename as parent_filename, p.subfolder as parent_subfolder, p.type as parent_type,
|
|
EXISTS(SELECT 1 FROM images c WHERE c.parent_id = i.id AND c.deleted_at IS NULL) as has_children,
|
|
i.positive_prompt, i.negative_prompt,
|
|
(SELECT GROUP_CONCAT(m.name, ', ') FROM models m
|
|
JOIN model_image_relations mir ON m.id = mir.model_id
|
|
WHERE mir.image_id = i.id) as model_name,
|
|
NULL as workflow, i.width, i.height, i.deleted_at
|
|
FROM images i LEFT JOIN images p ON i.parent_id = p.id
|
|
WHERE {deleted_filter}{search_sql} ORDER BY {final_order} LIMIT ? OFFSET ?
|
|
"""
|
|
cursor.execute(fetch_sql, (*search_params, req.limit, req.offset))
|
|
images = cursor.fetchall()
|
|
|
|
logging.info(
|
|
f"[Meld] list_images: found {len(images)}/{total_count} images (limit={req.limit}, offset={req.offset}) in {time.time() - start_time:.3f}s"
|
|
)
|
|
|
|
# Fetch ancestors in bulk if requested and depth > 1
|
|
# (depth 1 is already joined in fetch_sql as p_filename etc.)
|
|
image_ids = [img[0] for img in images]
|
|
ancestors_map: dict[int, list[dict[str, Any]]] = {}
|
|
tags_map: dict[int, list[str]] = {}
|
|
reconstructed_pos_map: dict[int, list[str]] = {}
|
|
reconstructed_neg_map: dict[int, list[str]] = {}
|
|
|
|
if image_ids:
|
|
placeholders = ",".join(["?"] * len(image_ids))
|
|
|
|
# Bulk fetch tags
|
|
cursor.execute(
|
|
f"SELECT r.image_id, t.name FROM tags t JOIN tag_image_relations r ON t.id = r.tag_id WHERE r.image_id IN ({placeholders})",
|
|
image_ids,
|
|
)
|
|
for img_id, tag_name in cursor.fetchall():
|
|
if img_id not in tags_map:
|
|
tags_map[img_id] = []
|
|
tags_map[img_id].append(tag_name)
|
|
|
|
# Bulk fetch lineage (ancestors)
|
|
if lineage_max_depth > 1:
|
|
ancestor_sql = f"""
|
|
WITH RECURSIVE lineage AS (
|
|
SELECT i.id as start_id, i.parent_id, 1 as depth
|
|
FROM images i
|
|
WHERE i.id IN ({placeholders}) AND i.parent_id IS NOT NULL
|
|
UNION ALL
|
|
SELECT l.start_id, i.parent_id, l.depth + 1
|
|
FROM images i
|
|
JOIN lineage l ON i.id = l.parent_id
|
|
WHERE i.parent_id IS NOT NULL AND l.depth < ?
|
|
)
|
|
SELECT l.start_id, i.id, i.filename, i.subfolder, i.type
|
|
FROM lineage l
|
|
JOIN images i ON l.parent_id = i.id
|
|
WHERE i.deleted_at IS NULL
|
|
ORDER BY l.start_id, l.depth
|
|
"""
|
|
cursor.execute(ancestor_sql, (*image_ids, lineage_max_depth))
|
|
for start_id, a_id, a_fname, a_subf, a_type in cursor.fetchall():
|
|
if start_id not in ancestors_map:
|
|
ancestors_map[start_id] = []
|
|
if len(ancestors_map[start_id]) < lineage_max_depth:
|
|
ancestors_map[start_id].append(
|
|
{"id": a_id, "filename": a_fname, "subfolder": a_subf, "type": a_type}
|
|
)
|
|
|
|
# Bulk fetch prompts ONLY for images that need reconstruction (where db_prompt is NULL)
|
|
# Skip reconstruction if minimal mode is on
|
|
needs_reconstruction_pos = [img[0] for img in images if img[12] is None] if not is_minimal else []
|
|
if needs_reconstruction_pos:
|
|
ph = ",".join(["?"] * len(needs_reconstruction_pos))
|
|
cursor.execute(
|
|
f"SELECT r.image_id, pp.name, r.strength FROM positive_prompts pp JOIN positive_prompt_image_relations r ON pp.id = r.positive_prompt_id WHERE r.image_id IN ({ph})",
|
|
needs_reconstruction_pos,
|
|
)
|
|
for img_id, name, strength in cursor.fetchall():
|
|
if img_id not in reconstructed_pos_map:
|
|
reconstructed_pos_map[img_id] = []
|
|
reconstructed_pos_map[img_id].append(name if strength == 1.0 else f"({name}:{strength})")
|
|
|
|
needs_reconstruction_neg = [img[0] for img in images if img[13] is None] if not is_minimal else []
|
|
if needs_reconstruction_neg:
|
|
ph = ",".join(["?"] * len(needs_reconstruction_neg))
|
|
cursor.execute(
|
|
f"SELECT r.image_id, np.name, r.strength FROM negative_prompts np JOIN negative_prompt_image_relations r ON np.id = r.negative_prompt_id WHERE r.image_id IN ({ph})",
|
|
needs_reconstruction_neg,
|
|
)
|
|
for img_id, name, strength in cursor.fetchall():
|
|
if img_id not in reconstructed_neg_map:
|
|
reconstructed_neg_map[img_id] = []
|
|
reconstructed_neg_map[img_id].append(name if strength == 1.0 else f"({name}:{strength})")
|
|
|
|
result_list = []
|
|
|
|
for img in images:
|
|
(
|
|
img_id,
|
|
filename,
|
|
subfolder,
|
|
img_type,
|
|
created_at,
|
|
phash,
|
|
sha256,
|
|
parent_id,
|
|
p_filename,
|
|
p_subfolder,
|
|
p_type,
|
|
has_children,
|
|
db_positive,
|
|
db_negative,
|
|
model_name,
|
|
workflow,
|
|
width,
|
|
height,
|
|
deleted_at,
|
|
) = img
|
|
|
|
effective_type = "trash" if deleted_at is not None else img_type
|
|
|
|
# Use DB columns if available, otherwise use bulk-fetched reconstructed prompts
|
|
positive = db_positive if db_positive is not None else ", ".join(reconstructed_pos_map.get(img_id, []))
|
|
negative = db_negative if db_negative is not None else ", ".join(reconstructed_neg_map.get(img_id, []))
|
|
|
|
# Truncate if minimal mode is on
|
|
if is_minimal:
|
|
if positive and len(positive) > 200:
|
|
positive = positive[:200] + "..."
|
|
if negative and len(negative) > 200:
|
|
negative = negative[:200] + "..."
|
|
|
|
tags = tags_map.get(img_id, [])
|
|
|
|
# Check if file exists
|
|
base_dir = None
|
|
effective_subfolder = ""
|
|
if effective_type == "trash":
|
|
base_dir = TRASH_DIR
|
|
effective_subfolder = ""
|
|
elif img_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
effective_subfolder = subfolder
|
|
elif img_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
effective_subfolder = subfolder
|
|
elif img_type == "temp":
|
|
base_dir = folder_paths.get_temp_directory()
|
|
effective_subfolder = subfolder
|
|
elif img_type == "custom":
|
|
base_dir = ""
|
|
effective_subfolder = subfolder
|
|
|
|
exists = False
|
|
if base_dir is not None:
|
|
full_path = os.path.normpath(os.path.abspath(os.path.join(base_dir, effective_subfolder, filename)))
|
|
exists = os.path.exists(full_path)
|
|
|
|
# Fallback for depth 1 or missing in ancestors_map
|
|
ancestors = ancestors_map.get(img_id, [])
|
|
if not ancestors and lineage_max_depth >= 1 and parent_id and p_filename:
|
|
ancestors = [{"id": parent_id, "filename": p_filename, "subfolder": p_subfolder, "type": p_type}]
|
|
|
|
result_list.append(
|
|
ImageListItem(
|
|
id=img_id,
|
|
filename=filename,
|
|
subfolder=subfolder,
|
|
type=effective_type,
|
|
created_at=created_at,
|
|
deleted_at=deleted_at,
|
|
phash=phash,
|
|
sha256=sha256,
|
|
parent_id=parent_id,
|
|
parent_filename=p_filename,
|
|
parent_subfolder=p_subfolder,
|
|
parent_type=p_type,
|
|
has_children=bool(has_children),
|
|
positive=positive,
|
|
negative=negative,
|
|
positive_prompt=positive if is_minimal else db_positive,
|
|
negative_prompt=negative if is_minimal else db_negative,
|
|
model_name=model_name,
|
|
workflow=workflow,
|
|
width=width,
|
|
height=height,
|
|
is_minimal=is_minimal,
|
|
tags=tags,
|
|
exists=exists,
|
|
ancestors=ancestors,
|
|
)
|
|
)
|
|
|
|
conn.close()
|
|
response = ImageListResponse(
|
|
images=result_list,
|
|
total=total_count,
|
|
offset=req.offset,
|
|
limit=req.limit,
|
|
)
|
|
return web.json_response(response.to_dict())
|
|
except Exception as e:
|
|
logging.exception("[Meld] Failed to list images")
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/restore")
|
|
async def restore_images(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = RestoreImagesRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if not req.ids:
|
|
return web.json_response({"error": "ids are required"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# 1. Identify all images to restore, including deleted ancestors
|
|
all_ids_to_restore = set(req.ids)
|
|
placeholders = ",".join(["?"] * len(req.ids))
|
|
|
|
# Recursive query to find all deleted ancestors of the requested images
|
|
ancestor_query = f"""
|
|
WITH RECURSIVE lineage AS (
|
|
SELECT id, parent_id, deleted_at FROM images WHERE id IN ({placeholders})
|
|
UNION ALL
|
|
SELECT i.id, i.parent_id, i.deleted_at FROM images i
|
|
JOIN lineage l ON i.id = l.parent_id
|
|
WHERE l.deleted_at IS NOT NULL
|
|
)
|
|
SELECT id FROM lineage WHERE deleted_at IS NOT NULL
|
|
"""
|
|
cursor.execute(ancestor_query, req.ids)
|
|
for (ancestor_id,) in cursor.fetchall():
|
|
all_ids_to_restore.add(ancestor_id)
|
|
|
|
final_ids = list(all_ids_to_restore)
|
|
placeholders = ",".join(["?"] * len(final_ids))
|
|
|
|
# 2. Fetch details for all images to be restored
|
|
# Sort by created_at ASC to ensure parents are processed before children
|
|
cursor.execute(
|
|
f"SELECT id, filename, subfolder, type, deleted_at FROM images WHERE id IN ({placeholders}) ORDER BY created_at ASC",
|
|
final_ids,
|
|
)
|
|
images = cursor.fetchall()
|
|
|
|
restored_ids = []
|
|
for img_id, trash_filename, subfolder, img_type, deleted_at in images:
|
|
if deleted_at is None:
|
|
continue
|
|
|
|
# 1. Resolve original base directory
|
|
if img_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
elif img_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
elif img_type == "temp":
|
|
base_dir = folder_paths.get_temp_directory()
|
|
elif img_type == "custom":
|
|
base_dir = ""
|
|
else:
|
|
continue
|
|
|
|
trash_full_path = os.path.normpath(os.path.join(TRASH_DIR, trash_filename))
|
|
if not os.path.exists(trash_full_path):
|
|
# File missing from trash, but we can still "restore" its DB status
|
|
cursor.execute("UPDATE images SET deleted_at = NULL WHERE id = ?", (img_id,))
|
|
restored_ids.append(img_id)
|
|
continue
|
|
|
|
# 2. Reconstruct original filename (remove timestamp prefix)
|
|
# The format was f"{int(now)}_{filename}"
|
|
parts = trash_filename.split("_", 1)
|
|
original_filename = parts[1] if len(parts) > 1 else trash_filename
|
|
|
|
# 3. Ensure subfolder exists
|
|
target_dir = os.path.join(base_dir, subfolder)
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
|
|
# 4. Handle filename collision
|
|
final_filename = image_service.get_unique_filename(base_dir, subfolder, original_filename)
|
|
target_full_path = os.path.join(target_dir, final_filename)
|
|
|
|
try:
|
|
shutil.move(trash_full_path, target_full_path)
|
|
# 5. Update DB
|
|
cursor.execute(
|
|
"UPDATE images SET deleted_at = NULL, filename = ? WHERE id = ?", (final_filename, img_id)
|
|
)
|
|
restored_ids.append(img_id)
|
|
except Exception as e:
|
|
logging.error(f"[Meld] Failed to restore file {trash_filename}: {e}")
|
|
continue
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return web.json_response(
|
|
ApiResponse(success=True, count=len(restored_ids), data={"restored_ids": restored_ids}).to_dict()
|
|
)
|
|
except Exception as e:
|
|
logging.exception("[Meld] Restore failed")
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/bulk-delete")
|
|
async def bulk_delete_images(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = BulkDeleteRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if not req.ids:
|
|
return web.json_response({"error": "ids are required"}, status=400)
|
|
|
|
permanent = req.permanent or req.delete_files
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Get file paths and current status
|
|
placeholders = ",".join(["?"] * len(req.ids))
|
|
cursor.execute(
|
|
f"SELECT id, filename, subfolder, type, deleted_at FROM images WHERE id IN ({placeholders})", req.ids
|
|
)
|
|
images = cursor.fetchall()
|
|
|
|
deleted_count = 0
|
|
now = time.time()
|
|
|
|
for img_id, filename, subfolder, img_type, deleted_at in images:
|
|
# 1. Resolve current full path
|
|
if deleted_at is not None:
|
|
# Already in trash
|
|
base_dir = TRASH_DIR
|
|
current_subfolder = ""
|
|
else:
|
|
# Normal location
|
|
if img_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
elif img_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
elif img_type == "temp":
|
|
base_dir = folder_paths.get_temp_directory()
|
|
elif img_type == "custom":
|
|
base_dir = ""
|
|
else:
|
|
continue
|
|
current_subfolder = subfolder
|
|
|
|
current_full_path = os.path.normpath(os.path.abspath(os.path.join(base_dir, current_subfolder, filename)))
|
|
|
|
if permanent:
|
|
# --- Permanent Delete ---
|
|
if os.path.exists(current_full_path):
|
|
try:
|
|
os.remove(current_full_path)
|
|
except Exception as e:
|
|
logging.warning(f"[Meld] Failed to permanently delete file {current_full_path}: {e}")
|
|
|
|
# Update children to set parent_id to NULL
|
|
cursor.execute("UPDATE images SET parent_id = NULL WHERE parent_id = ?", (img_id,))
|
|
# Remove from DB
|
|
cursor.execute("DELETE FROM images WHERE id = ?", (img_id,))
|
|
# Also cleanup relations
|
|
cursor.execute("DELETE FROM positive_prompt_image_relations WHERE image_id = ?", (img_id,))
|
|
cursor.execute("DELETE FROM negative_prompt_image_relations WHERE image_id = ?", (img_id,))
|
|
cursor.execute("DELETE FROM model_image_relations WHERE image_id = ?", (img_id,))
|
|
cursor.execute("DELETE FROM tag_image_relations WHERE image_id = ?", (img_id,))
|
|
|
|
else:
|
|
# --- Logical Delete (Move to Trash) ---
|
|
if deleted_at is not None:
|
|
# Already logically deleted, skip
|
|
continue
|
|
|
|
if os.path.exists(current_full_path):
|
|
new_filename = f"{int(now)}_{filename}"
|
|
new_full_path = os.path.join(TRASH_DIR, new_filename)
|
|
try:
|
|
shutil.move(current_full_path, new_full_path)
|
|
# Update DB with new filename and deleted_at
|
|
# IMPORTANT: subfolder is NOT changed (used for restoration)
|
|
cursor.execute(
|
|
"UPDATE images SET deleted_at = ?, filename = ? WHERE id = ?", (now, new_filename, img_id)
|
|
)
|
|
except Exception as e:
|
|
logging.error(f"[Meld] Failed to move file to trash {current_full_path}: {e}")
|
|
continue
|
|
else:
|
|
# File missing but we still mark it as deleted in DB
|
|
cursor.execute("UPDATE images SET deleted_at = ? WHERE id = ?", (now, img_id))
|
|
|
|
deleted_count += 1
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return web.json_response(ApiResponse(success=True, count=deleted_count).to_dict())
|
|
except Exception as e:
|
|
logging.exception("[Meld] Bulk delete failed")
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/delete")
|
|
async def delete_image(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = DeleteImageRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if not req.id and not req.filename:
|
|
return web.json_response({"error": "id or filename is required"}, status=400)
|
|
|
|
# Re-use bulk_delete logic by wrapping id in a list
|
|
if req.id:
|
|
ids = [req.id]
|
|
else:
|
|
# Find IDs from filename
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT id FROM images WHERE filename = ?", (req.filename,))
|
|
ids = [row[0] for row in cursor.fetchall()]
|
|
conn.close()
|
|
|
|
if not ids:
|
|
return web.json_response({"error": "Image not found"}, status=404)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
placeholders = ",".join(["?"] * len(ids))
|
|
cursor.execute(
|
|
f"SELECT id, filename, subfolder, type, deleted_at FROM images WHERE id IN ({placeholders})", ids
|
|
)
|
|
images = cursor.fetchall()
|
|
|
|
now = time.time()
|
|
for img_id, img_filename, subfolder, img_type, deleted_at in images:
|
|
# Resolve current path
|
|
if deleted_at is not None:
|
|
base_dir = TRASH_DIR
|
|
current_subfolder = ""
|
|
else:
|
|
if img_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
elif img_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
elif img_type == "temp":
|
|
base_dir = folder_paths.get_temp_directory()
|
|
elif img_type == "custom":
|
|
base_dir = ""
|
|
else:
|
|
continue
|
|
current_subfolder = subfolder
|
|
|
|
current_full_path = os.path.normpath(
|
|
os.path.abspath(os.path.join(base_dir, current_subfolder, img_filename))
|
|
)
|
|
|
|
if req.permanent:
|
|
if os.path.exists(current_full_path):
|
|
try:
|
|
os.remove(current_full_path)
|
|
except Exception:
|
|
pass
|
|
cursor.execute("UPDATE images SET parent_id = NULL WHERE parent_id = ?", (img_id,))
|
|
cursor.execute("DELETE FROM images WHERE id = ?", (img_id,))
|
|
cursor.execute("DELETE FROM positive_prompt_image_relations WHERE image_id = ?", (img_id,))
|
|
cursor.execute("DELETE FROM negative_prompt_image_relations WHERE image_id = ?", (img_id,))
|
|
cursor.execute("DELETE FROM model_image_relations WHERE image_id = ?", (img_id,))
|
|
cursor.execute("DELETE FROM tag_image_relations WHERE image_id = ?", (img_id,))
|
|
else:
|
|
if deleted_at is None:
|
|
if os.path.exists(current_full_path):
|
|
new_filename = f"{int(now)}_{img_filename}"
|
|
new_full_path = os.path.join(TRASH_DIR, new_filename)
|
|
try:
|
|
shutil.move(current_full_path, new_full_path)
|
|
cursor.execute(
|
|
"UPDATE images SET deleted_at = ?, filename = ? WHERE id = ?",
|
|
(now, new_filename, img_id),
|
|
)
|
|
except Exception:
|
|
continue
|
|
else:
|
|
cursor.execute("UPDATE images SET deleted_at = ? WHERE id = ?", (now, img_id))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/link-parent")
|
|
async def link_parent(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = LinkParentRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if req.childId is None:
|
|
return web.json_response({"error": "childId is required"}, status=400)
|
|
|
|
if req.childId == req.parentId:
|
|
return web.json_response({"error": "Cannot set an image as its own parent"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Check created_at timestamps to prevent circular dependencies (parent must be older than child)
|
|
if req.parentId is not None:
|
|
cursor.execute("SELECT created_at FROM images WHERE id = ?", (req.childId,))
|
|
child_row = cursor.fetchone()
|
|
cursor.execute("SELECT created_at FROM images WHERE id = ?", (req.parentId,))
|
|
parent_row = cursor.fetchone()
|
|
|
|
if child_row and parent_row:
|
|
child_created = child_row[0]
|
|
parent_created = parent_row[0]
|
|
if parent_created >= child_created:
|
|
conn.close()
|
|
return web.json_response({"error": "Parent image must be older than the child image"}, status=400)
|
|
|
|
cursor.execute("UPDATE images SET parent_id = ? WHERE id = ?", (req.parentId, req.childId))
|
|
conn.commit()
|
|
conn.close()
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/suggest-parents")
|
|
async def suggest_parents(request: web.Request) -> web.Response:
|
|
try:
|
|
image_id = request.query.get("id")
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Get settings
|
|
db_settings = get_all_settings(cursor)
|
|
default_threshold = db_settings.get("gallery.suggest_phash_threshold", 82)
|
|
threshold_pct = float(request.query.get("threshold", default_threshold))
|
|
threshold = round(64 * (1 - threshold_pct / 100))
|
|
|
|
if not image_id:
|
|
conn.close()
|
|
return web.json_response({"error": "id is required"}, status=400)
|
|
strategy = db_settings.get("gallery.matching_strategy", "phash_created")
|
|
|
|
# Get target image info
|
|
cursor.execute(
|
|
"SELECT phash, created_at, filename, subfolder, type FROM images WHERE id = ?",
|
|
(image_id,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
conn.close()
|
|
return web.json_response([])
|
|
|
|
target_phash, target_created_at, filename, subfolder, img_type = row
|
|
|
|
suggestions = image_service.get_parent_suggestions(
|
|
cursor,
|
|
int(image_id),
|
|
target_phash,
|
|
target_created_at,
|
|
filename,
|
|
subfolder,
|
|
img_type,
|
|
strategy=strategy,
|
|
threshold=threshold,
|
|
)
|
|
|
|
conn.close()
|
|
# Return top matches
|
|
return web.json_response([s.to_dict() for s in suggestions[:20]])
|
|
except Exception as e:
|
|
logging.exception("[Meld] Failed to suggest parents")
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/lineage")
|
|
async def get_lineage(request: web.Request) -> web.Response:
|
|
try:
|
|
image_id = request.query.get("id")
|
|
if not image_id:
|
|
return web.json_response({"error": "id is required"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Recursive query to get all ancestors and descendants
|
|
sql = """
|
|
WITH RECURSIVE
|
|
ancestors(id) AS (
|
|
SELECT id FROM images WHERE id = ?
|
|
UNION
|
|
SELECT i.parent_id FROM images i JOIN ancestors a ON i.id = a.id WHERE i.parent_id IS NOT NULL
|
|
),
|
|
descendants(id) AS (
|
|
SELECT id FROM images WHERE id = ?
|
|
UNION
|
|
SELECT i.id FROM images i JOIN descendants d ON i.parent_id = d.id
|
|
)
|
|
SELECT i.id, i.filename, i.subfolder, i.type, i.created_at, i.parent_id, i.phash,
|
|
p.filename as parent_filename, p.subfolder as parent_subfolder, p.type as parent_type,
|
|
i.positive_prompt, i.negative_prompt,
|
|
(SELECT GROUP_CONCAT(m.name, ', ') FROM models m
|
|
JOIN model_image_relations mir ON m.id = mir.model_id
|
|
WHERE mir.image_id = i.id) as model_name,
|
|
i.workflow, i.width, i.height
|
|
FROM images i LEFT JOIN images p ON i.parent_id = p.id
|
|
WHERE (i.id IN (SELECT id FROM ancestors) OR i.id IN (SELECT id FROM descendants)) AND i.deleted_at IS NULL
|
|
ORDER BY i.created_at
|
|
"""
|
|
|
|
cursor.execute(sql, (image_id, image_id))
|
|
rows = cursor.fetchall()
|
|
|
|
result = []
|
|
for row in rows:
|
|
img_id = row[0]
|
|
|
|
# Fetch tags
|
|
cursor.execute(
|
|
"SELECT t.name FROM tags t JOIN tag_image_relations r ON t.id = r.tag_id WHERE r.image_id = ?",
|
|
(img_id,),
|
|
)
|
|
tag_rows = cursor.fetchall()
|
|
tags = [t[0] for t in tag_rows]
|
|
|
|
result.append(
|
|
LineageItem(
|
|
id=img_id,
|
|
filename=row[1],
|
|
subfolder=row[2],
|
|
type=row[3],
|
|
created_at=row[4],
|
|
parent_id=row[5],
|
|
phash=row[6],
|
|
parent_filename=row[7],
|
|
parent_subfolder=row[8],
|
|
parent_type=row[9],
|
|
positive=row[10] or "",
|
|
negative=row[11] or "",
|
|
positive_prompt=row[10],
|
|
negative_prompt=row[11],
|
|
model_name=row[12],
|
|
workflow=row[13],
|
|
width=row[14],
|
|
height=row[15],
|
|
tags=tags,
|
|
).to_dict()
|
|
)
|
|
|
|
conn.close()
|
|
return web.json_response(result)
|
|
except Exception as e:
|
|
logging.exception("[Meld] Failed to get lineage")
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/meld/favorites")
|
|
async def list_favorites(request: web.Request) -> web.Response:
|
|
try:
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT id, name, query, created_at FROM favorites ORDER BY created_at DESC")
|
|
rows = cursor.fetchall()
|
|
conn.close()
|
|
|
|
favorites = [FavoriteRecord(id=row[0], name=row[1], query=row[2], created_at=row[3]).to_dict() for row in rows]
|
|
|
|
return web.json_response(favorites)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/favorites")
|
|
async def save_favorite(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = CreateFavoriteRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if not req.query:
|
|
return web.json_response({"error": "query is required"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"INSERT INTO favorites (name, query, created_at) VALUES (?, ?, ?)", (req.name, req.query, time.time())
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/favorites/update")
|
|
async def update_favorite(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = UpdateFavoriteRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if req.id is None or req.name is None:
|
|
return web.json_response({"error": "id and name are required"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
if req.query:
|
|
cursor.execute("UPDATE favorites SET name = ?, query = ? WHERE id = ?", (req.name, req.query, req.id))
|
|
else:
|
|
cursor.execute("UPDATE favorites SET name = ? WHERE id = ?", (req.name, req.id))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/meld/favorites/delete")
|
|
async def delete_favorite(request: web.Request) -> web.Response:
|
|
try:
|
|
data = await request.json()
|
|
try:
|
|
req = DeleteFavoriteRequest.from_dict(data)
|
|
except (TypeError, ValueError) as e:
|
|
return web.json_response({"error": f"Invalid schema: {e}"}, status=400)
|
|
|
|
if req.id is None:
|
|
return web.json_response({"error": "id is required"}, status=400)
|
|
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("DELETE FROM favorites WHERE id = ?", (req.id,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return web.json_response(ApiResponse(success=True).to_dict())
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
# --- Automatic cleanup (at extension load time) ---
|
|
def _run_auto_cleanup() -> None:
|
|
"""Run cleanup in the background"""
|
|
import time
|
|
|
|
time.sleep(5) # Wait a bit to prioritize other initialization tasks
|
|
try:
|
|
count = scan_service.perform_cleanup()
|
|
if count > 0:
|
|
logging.info(f"[Meld] Extension load cleanup: Removed {count} missing images from database.")
|
|
except Exception as e:
|
|
logging.warning(f"[Meld] Extension load cleanup failed: {e}")
|
|
|
|
|
|
threading.Thread(target=_run_auto_cleanup, daemon=True).start()
|