Files
Kinglord-ComfyUI_LoRA_Sidebar/__init__.py
T

3157 lines
138 KiB
Python

from server import PromptServer
import os
from aiohttp import web
import aiohttp
import folder_paths
import hashlib
import json
import logging
import io
import mimetypes
import shutil
import asyncio
import time
from collections import Counter
import logging
import glob
import subprocess
import sys
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import random
# Set up logging
DEBUG = False
class ErrorOnlyFilter(logging.Filter):
def filter(self, record):
return record.levelno == logging.ERROR
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG if DEBUG else logging.ERROR)
handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG if DEBUG else logging.ERROR)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
if not DEBUG:
error_filter = ErrorOnlyFilter()
handler.addFilter(error_filter)
# Define the paths
PLUGIN_DIR = os.path.dirname(os.path.realpath(__file__))
LORA_DATA_DIR = os.path.join(PLUGIN_DIR, "loraData")
os.makedirs(LORA_DATA_DIR, exist_ok=True)
# Rate limiting settings
MAX_REQUESTS_PER_MINUTE = 120 # Increase at your own risk, don't get banned!
RATE_LIMITER = None
# Test limit constant
TEST_LIMIT = 0
# Add a global variable to track if processing is in progress
is_processing = False
PROCESSED_LORAS_VERSION = 2 # used to force reprocessing LoRAs when data files change
NEW_ITEM_HOURS = 72
FALLBACK_TIMESTAMP = datetime(2100, 1, 1).timestamp()
LORA_FILE_INFO = {}
# Cache data for faster performance with new sorting
LORA_CACHE = {
'ordered_loras': None,
'sent_loras': set() # Keep track of which LoRAs we've sent to FE
}
# Caching store variables
CACHE_SETTINGS = {
'sortMethod': 'AlphaAsc',
'sortModels': 'None',
'tagSource': 'CivitAI',
'customTags': [],
'catNew': True,
'nsfwFolder': True,
'nsfwString': '4game'
}
# loading bar stuff
ANSI_COLORS = {
'GREEN': '\033[92m',
'BLUE': '\033[94m',
'CYAN': '\033[96m',
'YELLOW': '\033[93m',
'RED': '\033[91m',
'ENDC': '\033[0m',
'BOLD': '\033[1m'
}
# hack for lora processing
class LoraDataStore:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(LoraDataStore, cls).__new__(cls)
cls._instance.data = None
return cls._instance
@classmethod
def set_data(cls, data):
cls._instance = cls()
cls._instance.data = data
logger.info(f"Data set in LoraDataStore: {cls._instance.data}")
@classmethod
def get_data(cls):
cls._instance = cls()
logger.info(f"Data retrieved from LoraDataStore: {cls._instance.data}")
return cls._instance.data
@classmethod
def clear_data(cls):
cls._instance = cls()
cls._instance.data = None
class RateLimiter:
def __init__(self, max_calls, period):
self.max_calls = max_calls
self.period = period # in seconds
self.calls = []
self.lock = asyncio.Lock()
async def acquire(self):
async with self.lock:
current = time.time()
# Remove timestamps older than the period
while self.calls and self.calls[0] <= current - self.period:
self.calls.pop(0)
if len(self.calls) >= self.max_calls:
wait_time = self.period - (current - self.calls[0])
logger.debug(f"Rate limit reached. Sleeping for {wait_time:.2f} seconds.")
await asyncio.sleep(wait_time)
self.calls.append(time.time())
# Initialize the RateLimiter
RATE_LIMITER = RateLimiter(MAX_REQUESTS_PER_MINUTE, 60) # 120 calls per 60 seconds
async def get_lora_sort_metadata():
"""
Gets both dates and names for all LoRA info.json files.
Returns a dictionary mapping lora_id to {
'ctime': timestamp,
'mtime': timestamp,
'name': display_name
}
"""
sort_metadata = {}
for folder in os.listdir(LORA_DATA_DIR):
info_path = os.path.join(LORA_DATA_DIR, folder, "info.json")
try:
if os.path.isfile(info_path):
stats = os.stat(info_path)
# Get the base info
sort_metadata[folder] = {
'ctime': stats.st_ctime,
'mtime': stats.st_mtime,
'name': folder # Default fallback
}
# Read the name from info.json
try:
with open(info_path, 'r', encoding='utf-8') as f:
json_data = json.load(f)
sort_metadata[folder]['name'] = json_data.get('name', folder)
except json.JSONDecodeError:
logger.error(f"Error reading info.json for {folder}")
except (OSError, IOError) as e:
logger.error(f"Error getting info for {folder}: {str(e)}")
continue
return sort_metadata
async def hash_file(filepath):
# Check for local saved hash
stripped_file_path = os.path.splitext(filepath)[0]
hash_path = stripped_file_path + ".sha256"
# If local hash file exists, read it
if os.path.isfile(hash_path):
try:
with open(hash_path, "rt") as f:
return f.read().strip()
except Exception as e:
logger.warning(f"Error reading hash file {hash_path}: {str(e)}")
# Nothing local, so let's hash it ourselves
sha256_hash = hashlib.sha256()
with open(filepath, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
async def check_local_info(file_path):
# Try CivitAI first
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
logger.info(f"Checking for CivitAI metadata at: {civitai_info_path}")
if os.path.exists(civitai_info_path):
try:
logger.info(f"Found CivitAI metadata file, attempting to read: {civitai_info_path}")
with open(civitai_info_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
return {
"modelId": metadata.get("modelId"),
"id": metadata.get("id"), # Version ID from the info
"name": metadata.get("name"), # Version name
"model": {
"name": metadata.get("model", {}).get("name"),
"nsfw": metadata.get("model", {}).get("nsfw", False),
"tags": metadata.get("model", {}).get("tags", []),
"description": metadata.get("model", {}).get("description"), # Model description
"type": metadata.get("model", {}).get("type")
},
"trainedWords": metadata.get("trainedWords", []),
"baseModel": metadata.get("baseModel"),
"description": metadata.get("description"), # Version description
"images": metadata.get("images", []),
"createdAt": metadata.get("createdAt"), # Match API format
"updatedAt": metadata.get("updatedAt"), # Match API format
"local_metadata": True, # Add flag
"has_local_images": True
}
except Exception as e:
logger.error(f"Error reading civitai.info for {file_path}: {str(e)}")
# Try SMatrix second
matrix_info_path = f"{os.path.splitext(file_path)[0]}.cm-info.json"
logger.info(f"Checking for SMatrix metadata at: {matrix_info_path}")
if os.path.exists(matrix_info_path):
try:
logger.info(f"Found SMatrix metadata file, attempting to read: {matrix_info_path}")
with open(matrix_info_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
return {
"modelId": metadata.get("ModelId"),
"id": metadata.get("VersionId"), # Version ID
"name": metadata.get("VersionName"),
"model": {
"name": metadata.get("ModelName"),
"nsfw": metadata.get("Nsfw", False),
"tags": metadata.get("Tags", []),
"description": metadata.get("ModelDescription"),
"type": metadata.get("ModelType")
},
"trainedWords": metadata.get("TrainedWords", []),
"baseModel": metadata.get("BaseModel"),
"description": metadata.get("VersionDescription"),
"images": [],
"createdAt": metadata.get("ImportedAt"), #all we have is imported date here so using it for both
"updatedAt": metadata.get("ImportedAt"),
"local_metadata": True,
"has_local_images": True
}
except Exception as e:
logger.error(f"Error reading cm-info.json for {file_path}: {str(e)}")
# Try rg3 third...get it?
rg3_info_path = f"{os.path.splitext(file_path)[0]}.safetensors.rgthree-info.json"
logger.info(f"Checking for RG3 metadata at: {rg3_info_path}")
if os.path.exists(rg3_info_path):
try:
logger.info(f"Found RG3 metadata file, attempting to read: {rg3_info_path}")
with open(rg3_info_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
civitai = metadata.get('raw', {}).get('civitai', {})
return_data = {
"modelId": civitai.get("modelId"),
"id": civitai.get("id"),
"name": civitai.get("name"), # version name
"model": {
"name": civitai.get("model", {}).get("name"),
"nsfw": civitai.get("model", {}).get("nsfw", False),
"description": None,
"type": civitai.get("model", {}).get("type"),
},
"trainedWords": civitai.get("trainedWords", []),
"baseModel": civitai.get("baseModel"),
"description": civitai.get("description"), # version desc
"images": [
{
"url": img.get("url"),
"type": img.get("type", "image"),
"hasMeta": img.get("hasMeta", False),
"nsfwLevel": img.get("nsfwLevel", 0)
}
for img in civitai.get("images", [])
],
"createdAt": civitai.get("createdAt"),
"updatedAt": civitai.get("updatedAt"),
"local_metadata": True,
"has_local_images": False
}
logger.info(f"Successfully parsed RG3 metadata for {file_path}")
logger.info(f"RG3 metadata {return_data}")
return return_data
except Exception as e:
logger.error(f"Error reading safetensors.rgthree-info.json for {file_path}: {str(e)}")
# No valid metadata found in any format
logger.info(f"No valid metadata found for: {file_path}")
return False
async def fetch_model_info(session, model_id, skip_rate_limit=False):
url = f"https://civitai.com/api/v1/models/{model_id}"
if not skip_rate_limit:
await RATE_LIMITER.acquire()
async with session.get(url) as response:
if response.status == 200:
return await response.json()
return None
async def fetch_version_info(session, file_hash):
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
await RATE_LIMITER.acquire() # Enforce rate limit
try:
async with session.get(url) as response:
if response.status == 200:
return await response.json()
elif response.status == 404:
# Expected response for custom LoRAs and removed content
logger.info(f"Hash lookup failed for {file_hash}, likely a custom LoRA or removed content. Falling back to local data.")
else:
# Unexpected API response
logger.warning(f"Unexpected response from CivitAI API: {response.status}")
except Exception as e:
logger.warning(f"Error during hash lookup: {str(e)}")
return None
async def fetch_version_info_by_id(session, version_id, skip_rate_limit=False):
url = f"https://civitai.com/api/v1/model-versions/{version_id}"
if not skip_rate_limit:
await RATE_LIMITER.acquire()
async with session.get(url) as response:
if response.status == 200:
return await response.json()
return None
async def download_image(session, image_url, save_path):
# await RATE_LIMITER.acquire() # here in case but would rather not rate limit this
async with session.get(image_url) as response:
if response.status == 200:
content_type = response.headers.get('Content-Type', '')
ext = mimetypes.guess_extension(content_type) or '.jpg'
final_save_path = f"{os.path.splitext(save_path)[0]}{ext}"
with open(final_save_path, 'wb') as f:
f.write(await response.read())
return os.path.basename(final_save_path)
return None
def get_tag_categories(settings):
"""Get appropriate tag categories based on settings."""
PREDEFINED_TAGS = [
"character", "style", "celebrity", "concept", "clothing", "poses",
"background", "tool", "buildings", "vehicle", "objects", "animal", "assets", "action"
]
if settings['tagSource'] == 'CivitAI':
return PREDEFINED_TAGS
return [tag.strip().lower() for tag in settings.get('customTags', [])]
def format_date(date_input):
"""
Convert any reasonable date format to YYYY-MM-DD.
Returns 'unknown' for invalid/missing dates.
Handles:
- ISO 8601 timestamps (2024-10-24T12:14:32.925Z)
- Simple dates (2024-10-24)
- Unix timestamps
"""
if not date_input:
return "unknown"
try:
# If it's already a datetime object
if isinstance(date_input, datetime):
dt = date_input
# If it's a string, try parsing it
elif isinstance(date_input, str):
# Try ISO format first (handles both full timestamps and simple dates)
try:
dt = datetime.fromisoformat(date_input.replace('Z', '+00:00'))
except ValueError:
# If that fails, try basic date format
dt = datetime.strptime(date_input, '%Y-%m-%d')
# If it's a number (timestamp)
elif isinstance(date_input, (int, float)):
dt = datetime.fromtimestamp(date_input)
else:
return "unknown"
return dt.strftime('%Y-%m-%d')
except Exception as e:
logger.error(f"Error formatting date {date_input}: {str(e)}")
return "unknown"
async def copy_placeholder_as_preview(lora_id):
placeholder_path = os.path.join(LORA_DATA_DIR, "placeholder.jpeg")
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
preview_path = os.path.join(lora_folder, "preview.jpeg")
# Ensure the LoRA folder exists
os.makedirs(lora_folder, exist_ok=True)
if os.path.exists(placeholder_path) and not os.path.exists(preview_path):
shutil.copy(placeholder_path, preview_path)
logger.info(f"Placeholder image copied as preview for LoRA {lora_id}")
else:
if not os.path.exists(placeholder_path):
logger.warning(f"Placeholder image not found: {placeholder_path}")
if os.path.exists(preview_path):
logger.info(f"Preview already exists for LoRA {lora_id}: {preview_path}")
def get_subdir(file_path):
"""Get the subdirectory relative to the lora base path."""
try:
# Get all possible lora directories from folder_paths
lora_dirs = folder_paths.get_folder_paths("loras")
logger.debug(f"Input file_path: {file_path}")
logger.debug(f"Lora dirs from folder_paths: {lora_dirs}")
# Filter out output directories
lora_dirs = [d for d in lora_dirs if 'output' not in d.lower().split(os.sep)]
logger.debug(f"Lora dirs from folder_paths (excluding output dirs): {lora_dirs}")
if not lora_dirs:
return ""
# Normalize the input path
norm_path = os.path.normpath(file_path)
logger.debug(f"Normalized input path: {norm_path}")
# Handle cross-drive path comparison by normalizing drive letters
file_drive, file_path_tail = os.path.splitdrive(norm_path)
# Find the matching base directory
matching_base = None
matching_link = None
for base_dir in lora_dirs:
base_dir = os.path.normpath(base_dir)
logger.debug(f"\nChecking base dir: {base_dir}")
# Check for symlinks in this lora directory
try:
for item in os.listdir(base_dir):
link_path = os.path.join(base_dir, item)
if os.path.islink(link_path):
real_link = os.path.realpath(link_path)
logger.debug(f"Found symlink: {item} -> {real_link}")
# Apply drive neutralization to symlink target
link_drive, link_tail = os.path.splitdrive(real_link)
neutral_link = os.path.join("C:", link_tail)
neutral_file = os.path.join("C:", file_path_tail)
if neutral_file.startswith(neutral_link):
matching_base = real_link
matching_link = item
logger.debug(f"Found matching symlink: {item}")
break
except Exception as e:
logger.debug(f"Error checking symlinks in {base_dir}: {str(e)}")
continue
# Regular path matching as before
base_drive, base_path_tail = os.path.splitdrive(base_dir)
# Create drive-letter-neutral paths for comparison
neutral_file_path = os.path.join("C:", file_path_tail)
neutral_base_path = os.path.join("C:", base_path_tail)
logger.debug(f"Neutral paths for comparison:")
logger.debug(f" neutral_file_path: {neutral_file_path}")
logger.debug(f" neutral_base_path: {neutral_base_path}")
# Check if this base matches
if neutral_file_path.startswith(neutral_base_path):
logger.debug(f"Found matching base! Length: {len(base_dir)}")
if matching_base is None or len(base_dir) > len(matching_base):
matching_base = base_dir
logger.debug(f"Updated matching_base to: {matching_base}")
if not matching_base:
logger.debug("No matching base directory found")
return ""
logger.debug(f"Final matching base: {matching_base}")
# If we matched via symlink, construct the path accordingly
if matching_link:
# Get the part of the path after the symlink target
_, match_tail = os.path.splitdrive(matching_base)
neutral_match = os.path.join("C:", match_tail)
neutral_file = os.path.join("C:", file_path_tail)
after_base = neutral_file[len(neutral_match):].lstrip('\\/')
rel_path = os.path.join(matching_link, os.path.dirname(after_base))
logger.debug(f"Constructed symlink path: {rel_path}")
return rel_path
# Regular path handling as before
_, match_path_tail = os.path.splitdrive(matching_base)
neutral_match_path = os.path.join("C:", match_path_tail)
logger.debug(f"Neutral match path for relpath calc: {neutral_match_path}")
# Calculate relative path
try:
rel_path = os.path.relpath(os.path.dirname(neutral_file_path), neutral_match_path)
logger.debug(f"Calculated relpath: {rel_path}")
except Exception as e:
logger.error(f"Error calculating relpath: {str(e)}")
return ""
# Return empty string for root directory
if rel_path == ".":
logger.debug("Relpath is root directory")
return ""
logger.debug(f"Final relative path: {rel_path}")
return rel_path
except Exception as e:
logger.error(f"Error in get_subdir: {str(e)}")
logger.error(f"File path: {file_path}")
return ""
def find_custom_images(base_path, base_filename):
"""Find custom images for a LoRA in both internal and external locations"""
custom_images = []
# Get the directory containing the base file
base_dir = os.path.dirname(base_path)
# Get base name without extension for matching
name_without_ext = os.path.splitext(base_filename)[0]
# Common image extensions to look for
image_extensions = ('.jpg', '.jpeg', '.png', '.webp')
logger.info(f"Searching for custom images in {base_dir} for {base_filename}")
# For internal loras, look in the lora data directory
lora_data_path = os.path.join(LORA_DATA_DIR, name_without_ext)
if os.path.exists(lora_data_path):
for file in os.listdir(lora_data_path):
if file.lower().endswith(image_extensions) and not file.startswith('preview'):
logger.info(f"Found internal custom image: {file}")
custom_images.append({
"url": f"/lora_sidebar/custom_image/{name_without_ext}/{file}",
"type": "image",
"nsfwLevel": 0,
"hasMeta": True,
"custom": True,
"name": file
})
# Look for images in the LoRA's directory
if os.path.exists(base_dir):
for file in os.listdir(base_dir):
if (file.lower().startswith(name_without_ext.lower()) and
file.lower().endswith(image_extensions) and
not file.lower().endswith('.preview' + os.path.splitext(file)[1])):
logger.info(f"Found external custom image: {file}")
custom_images.append({
"url": f"/lora_sidebar/custom_image/{name_without_ext}/{file}",
"type": "image",
"nsfwLevel": 0,
"hasMeta": True,
"custom": True,
"name": file
})
logger.info(f"Found total of {len(custom_images)} custom images")
return custom_images
def manage_category_counts(action="calculate", **kwargs):
"""
Unified category count management.
Args:
action: Type of operation ("calculate" or "update")
kwargs: Arguments based on action type:
For "calculate":
loras: Full list of loras
paginated_loras: Current page of loras (optional)
settings: User settings
For "update":
old_category: Previous category
new_category: New category
current_counts: Existing counts dict (optional)
"""
if not LORA_CACHE.get('ordered_loras'):
return None
if action == "calculate":
loras = kwargs.get('loras', [])
paginated_loras = kwargs.get('paginated_loras', [])
settings = kwargs.get('settings', {})
category_counts = {}
# Count real categories
for lora in loras:
if not lora['favorite'] and not lora['is_new']: # don't count favs or new items
category = lora['category']
if category not in category_counts:
category_counts[category] = {'total': 0, 'loaded': 0}
category_counts[category]['total'] += 1
# Add status counts
status_counts = {
'Favorites': {'total': len([l for l in loras if l['favorite']])},
'New': {'total': len([l for l in loras if l['is_new'] and not l['favorite']])}
}
category_counts.update(status_counts)
# Count loaded if provided
if paginated_loras:
for lora in paginated_loras:
if lora['favorite']:
category_counts['Favorites']['loaded'] = category_counts['Favorites'].get('loaded', 0) + 1
elif lora['is_new'] and settings.get('catNew'):
category_counts['New']['loaded'] = category_counts['New'].get('loaded', 0) + 1
else:
category = lora['category']
category_counts[category]['loaded'] = category_counts[category].get('loaded', 0) + 1
elif action == "update":
old_category = kwargs.get('old_category')
new_category = kwargs.get('new_category')
category_counts = dict(kwargs.get('current_counts', LORA_CACHE.get('category_info', {})))
# Ensure categories exist
for cat in [old_category, new_category]:
if cat and cat not in category_counts:
category_counts[cat] = {'total': 0, 'loaded': 0}
# Update counts
if old_category:
category_counts[old_category]['total'] -= 1
if 'loaded' in category_counts[old_category]:
category_counts[old_category]['loaded'] -= 1
if new_category:
category_counts[new_category]['total'] += 1
if 'loaded' in category_counts[new_category]:
category_counts[new_category]['loaded'] += 1
# Update cache and return
LORA_CACHE['category_info'] = category_counts
logger.debug(f"cat counts from category counter: {category_counts}")
return category_counts
def validate_processed_loras(processed_loras):
"""Validate and clean processed_loras data, keeping newest paths"""
# Ensure we have required structure
if not isinstance(processed_loras, dict):
return {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
# Ensure required keys exist
processed_loras.setdefault("version", PROCESSED_LORAS_VERSION)
processed_loras.setdefault("loras", [])
processed_loras.setdefault("favorites", [])
# Check for duplicates - keep entry with newest path
entries_by_filename = {}
for lora in processed_loras["loras"]:
filename = lora.get("filename")
if filename:
# If we've seen this filename before, keep the newer path
if filename in entries_by_filename:
# Check if this path is different from what we have
if lora.get("path") != entries_by_filename[filename].get("path"):
# Update to the new path
entries_by_filename[filename]["path"] = lora.get("path")
logger.info(f"Updated path for {filename} to: {lora.get('path')}")
else:
entries_by_filename[filename] = lora
# Convert back to list, now with unique entries and newest paths
processed_loras["loras"] = list(entries_by_filename.values())
return processed_loras
def get_completion_message(lora_count):
# after all this i need to have some fun
messages = {
0: [
"No LoRAs found - your collection awaits! 🟢",
"Empty canvas - ready for your first LoRA! 🎨",
"Time to start your LoRA adventure! 🚀"
],
1: [
"A humble beginning - keep going! 🔰",
"🥠 There are more LoRAs in your future.",
"IT HAS BEGUN! *DUN-DUN-DUN-DUN-DA-DA-DUN-DUN-DUN-DUN*",
"I appreciate it, but honestly - why did you even download this thing? 😂"
],
50: [
"Nice collection! You're well on your way to LoRA mastery. 🎓",
"Your LoRA library is growing! 👍",
"A curator of fine LoRAs, I see! 🛒",
],
500: [
"Impressive arsenal of LoRAs! You're a serious collector. 🥃",
"Your LoRA collection is becoming a force to be reckoned with! 💪",
"Triple digits! Now that's dedication...or something else... 💦",
"Are ya winning, son? 🤠"
],
2000: [
"This collection is getting scary! 😱",
"Your LoRA collection is award winng, just don't ask which one! 🥇",
"You know I can see these LoRAs...right? 👀",
"I know, you can stop anytime you want. 🚬"
],
9000: [
"WHAT?! IT'S OVER 9000!!! 🤯",
"They said they had a lot of LoRAs. You said, Hold my beer. 🍺",
"Behold, a true LoRA connoisseur walks among us! ಠ_ರೃ",
"You don't remember downloading half of these, do you? 🤔"
],
15000: [
"Are you from Sparta? Because this IS MADNESS! ⚔️",
"Impressive. Most Impressive. 🌑",
"⊙▂⊙"
],
float('inf'): [
"🏆 HOW DID THIS EVEN LOAD?!? 🏆",
"🏆 GOD TIER 🏆",
"🏆 CIVITAI WISHES IT WAS THIS COOL 🏆"
]
}
# Find the appropriate threshold
threshold = max((t for t in messages.keys() if lora_count >= t), key=lambda x: x if x != float('inf') else float('-inf'))
# Return a random message from that threshold's list
return random.choice(messages[threshold])
@PromptServer.instance.routes.get("/lora_sidebar/loras/list")
async def list_loras(request):
global LORA_FILE_INFO
logger.info(f"Pulling LoRA list from route: {request.path}")
logger.info("Pulling LoRA list")
lora_dirs = folder_paths.get_folder_paths("loras")
lora_files = []
for lora_dir in lora_dirs:
# Resolve symlinks to ensure proper path handling
resolved_lora_dir = os.path.realpath(lora_dir)
if os.path.exists(resolved_lora_dir):
# Use os.walk to recursively walk through subfolders and resolve symlinks
for root, dirs, files in os.walk(resolved_lora_dir, followlinks=True):
for filename in files:
# Skip hidden files and system files starting with "._"
if filename.startswith('.') or filename.startswith('._'):
continue
file_path = os.path.join(root, filename)
# Resolve any symlinks to their actual path and check if it's a file
resolved_file_path = os.path.realpath(file_path)
if os.path.isfile(resolved_file_path) and filename.lower().endswith(('.safetensors', '.ckpt', '.pt')):
lora_files.append({"filename": filename, "path": resolved_file_path})
# Store in LORA_FILE_INFO
LORA_FILE_INFO[os.path.splitext(filename)[0].strip()] = {"filename": filename, "path": resolved_file_path}
if TEST_LIMIT > 0 and len(lora_files) >= TEST_LIMIT:
break
if TEST_LIMIT > 0 and len(lora_files) >= TEST_LIMIT:
break
return lora_files
@PromptServer.instance.routes.get("/lora_sidebar/file_details/{lora_id}")
async def get_file_details(request):
lora_id = request.match_info['lora_id']
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
try:
return web.json_response({
"managed_dir": lora_folder,
"exists": os.path.exists(lora_folder)
})
except Exception as e:
logger.error(f"Error getting file details: {str(e)}")
return web.json_response({
"error": "Failed to get file details"
}, status=500)
@PromptServer.instance.routes.get("/lora_sidebar/preview/{lora_name}")
async def get_lora_preview(request):
# Load preview media with proper MIME type handling.
lora_name = request.match_info['lora_name']
def get_content_type(filepath):
ext = os.path.splitext(filepath)[1].lower()
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.mp4': 'video/mp4',
'.webm': 'video/webm'
}
return content_types.get(ext, 'application/octet-stream')
# Look in managed folder
lora_folder = os.path.join(LORA_DATA_DIR, lora_name)
for ext in ['.jpg', '.png', '.jpeg', '.mp4', '.webm']:
preview_path = os.path.join(lora_folder, f"preview{ext}")
if os.path.exists(preview_path):
return web.FileResponse(
preview_path,
headers={"Content-Type": get_content_type(preview_path)}
)
# Check if this LoRA uses local image data
info_path = os.path.join(LORA_DATA_DIR, lora_name, "info.json")
if os.path.exists(info_path):
with open(info_path, "r", encoding="utf-8") as f:
info = json.load(f)
logger.info(f"Loading info for {lora_name}: local_metadata={info.get('local_metadata')}, path={info.get('path')}")
if info.get("local_metadata") and info.get("path"):
base_path = os.path.splitext(info["path"])[0]
for ext in ['.preview.png', '.preview.jpg', '.preview.jpeg', '.preview.mp4', '.preview.webm']:
preview_path = f"{base_path}{ext}"
if os.path.exists(preview_path):
return web.FileResponse(
preview_path,
headers={"Content-Type": get_content_type(preview_path)}
)
# Fallback to placeholder
placeholder_path = os.path.join(LORA_DATA_DIR, "placeholder.jpeg")
if os.path.exists(placeholder_path):
return web.FileResponse(
placeholder_path,
headers={"Content-Type": "image/jpeg"}
)
return web.Response(status=404)
@PromptServer.instance.routes.get("/lora_sidebar/info/{lora_name}")
async def get_lora_info(request):
try:
lora_name = request.match_info['lora_name']
info_path = os.path.join(LORA_DATA_DIR, lora_name, "info.json")
if not os.path.exists(info_path):
return web.json_response({
"status": "error",
"message": "LoRA info file not found"
}, status=404)
with open(info_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
return web.json_response({
"status": "success",
"info": info_data
})
except Exception as e:
logger.error(f"Error getting LoRA info: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.get("/lora_sidebar/custom_image/{lora_name}/{image_name}")
async def get_lora_custom_image(request):
# Load custom images for a LoRA
lora_name = request.match_info['lora_name']
image_name = request.match_info['image_name']
def get_content_type(filepath):
ext = os.path.splitext(filepath)[1].lower()
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp'
}
return content_types.get(ext, 'application/octet-stream')
logger.info(f"Looking for custom image {image_name} for LoRA {lora_name}")
# Check internal lora data directory first (existing behavior)
custom_path = os.path.join(LORA_DATA_DIR, lora_name, image_name)
if os.path.exists(custom_path) and os.path.isfile(custom_path):
logger.info(f"Found image in internal directory: {custom_path}")
return web.FileResponse(
custom_path,
headers={"Content-Type": get_content_type(custom_path)}
)
# Then check actual LoRA location if we have the info
if lora_name in LORA_FILE_INFO:
file_info = LORA_FILE_INFO[lora_name]
base_dir = os.path.dirname(file_info['path'])
# Try exact match first
external_path = os.path.join(base_dir, image_name)
if os.path.exists(external_path) and os.path.isfile(external_path):
logger.info(f"Found exact match image in LoRA directory: {external_path}")
return web.FileResponse(
external_path,
headers={"Content-Type": get_content_type(external_path)}
)
# If no exact match, try looking for images with LoRA name prefix
lora_filename = os.path.splitext(file_info['filename'])[0]
for file in os.listdir(base_dir):
if (file.lower().startswith(lora_filename.lower()) and
file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')) and
not file.lower().endswith('.preview.png')):
external_path = os.path.join(base_dir, file)
logger.info(f"Found matching prefixed image: {external_path}")
return web.FileResponse(
external_path,
headers={"Content-Type": get_content_type(external_path)}
)
logger.info(f"No matching image found for {lora_name}/{image_name}")
return web.Response(status=404)
@PromptServer.instance.routes.get("/lora_sidebar/placeholder")
async def get_placeholder(request):
placeholder_path = os.path.join(LORA_DATA_DIR, "placeholder.jpeg")
if os.path.exists(placeholder_path):
return web.FileResponse(placeholder_path)
return web.Response(status=404)
@PromptServer.instance.routes.get("/lora_sidebar/unprocessed_count")
async def get_unprocessed_count(request):
logger.info("Starting fresh: Scanning for unprocessed LoRAs")
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
# Initialize processed_loras with default structure
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
# Get the current LoRA files first
lora_files = await list_loras(request)
current_lora_names = [os.path.splitext(lf['filename'])[0].strip() for lf in lora_files]
# Check version and handle existing data
if os.path.exists(processed_loras_file):
with io.open(processed_loras_file, "r", encoding="utf-8") as f:
try:
loaded_data = json.load(f)
if not isinstance(loaded_data, dict) or not loaded_data.get('version') or loaded_data.get('version') < PROCESSED_LORAS_VERSION:
logger.info(f"Outdated or missing version, marking all LoRAs for reprocessing (current version: {PROCESSED_LORAS_VERSION})")
# Preserve favorites but clear loras list
favorites = loaded_data.get('favorites', []) if isinstance(loaded_data, dict) else []
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": favorites
}
# Save the updated structure
with io.open(processed_loras_file, "w", encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
# All current loras need processing
response_data = {
"unprocessed_count": len(current_lora_names),
"new_loras": current_lora_names,
"moved_loras": [],
"duplicate_loras": [],
"missing_loras": [],
"local_metadata": 0,
"remote_metadata": 0
}
LoraDataStore.set_data(response_data)
return web.json_response(response_data)
else:
processed_loras = loaded_data
except json.JSONDecodeError:
logger.error("Error reading processed_loras.json")
unprocessed_count = 0
new_loras = []
moved_loras = []
duplicate_loras = []
local_metadata_count = 0
remote_metadata_needed = 0
# Create a dictionary of processed LoRAs (keyed by filename)
processed_loras_dict = {lora['filename']: lora['path'] for lora in processed_loras['loras']}
# Create a dictionary of current LoRAs (keyed by filename) and count occurrences
current_loras = {os.path.splitext(lf['filename'])[0].strip(): lf['path'] for lf in lora_files}
filename_count = Counter(current_loras.keys())
potential_duplicates = [filename for filename, count in filename_count.items() if count > 1]
# Check each lora
for base_filename, file_path in current_loras.items():
needs_processing = False
if base_filename in potential_duplicates:
if base_filename not in processed_loras_dict:
new_loras.append(base_filename)
needs_processing = True
elif processed_loras_dict[base_filename] != file_path:
moved_loras.append(base_filename)
needs_processing = True
duplicate_loras.append(base_filename)
else:
if base_filename not in processed_loras_dict:
new_loras.append(base_filename)
needs_processing = True
elif processed_loras_dict[base_filename] != file_path:
moved_loras.append(base_filename)
needs_processing = True
# If this LoRA needs processing, check for local metadata
if needs_processing:
if await check_local_info(file_path):
local_metadata_count += 1
else:
remote_metadata_needed += 1
# Check for missing LoRAs
missing_loras = [lora for lora in processed_loras_dict.keys()
if lora.strip() not in current_loras]
total_unprocessed = len(new_loras) + len(moved_loras) + len(missing_loras)
logger.info(f"Total LoRAs: {len(lora_files)}, Unprocessed: {total_unprocessed}")
logger.info(f"New: {len(new_loras)}, Moved: {len(moved_loras)}, Missing: {len(missing_loras)}")
logger.info(f"Local Metadata: {local_metadata_count}, Needs Remote: {remote_metadata_needed}")
response_data = {
"unprocessed_count": total_unprocessed,
"new_loras": new_loras,
"moved_loras": moved_loras,
"duplicate_loras": duplicate_loras,
"missing_loras": missing_loras,
"local_metadata": local_metadata_count,
"remote_metadata": remote_metadata_needed
}
LoraDataStore.set_data(response_data)
return web.json_response(response_data)
@PromptServer.instance.routes.get("/lora_sidebar/estimate")
async def estimate_processing_time(request):
# Extract 'count' from query parameters
params = request.rel_url.query
count_new_str = params.get('new', '0')
count_moved_str = params.get('moved', '0')
count_missing_str = params.get('missing', '0')
count_local_str = params.get('local', '0')
count_remote_str = params.get('remote', '0')
try:
num_unprocessed = int(count_new_str) + int(count_moved_str) + int(count_missing_str)
num_new = int(count_new_str)
num_moved = int(count_moved_str)
num_missing = int(count_missing_str)
num_local = int(count_local_str)
num_remote = int(count_remote_str)
except ValueError:
logger.error(f"Invalid count parameter: {count_new_str}")
return web.json_response({"error": "Invalid count parameter"}, status=400)
# Calculate estimated seconds
estimated_long_seconds = 1.85 * num_remote # 1.85 seconds per LoRA
estimated_short_seconds = 0.6 * num_local
estimated_super_short_seconds = 0.2 * num_missing
estimated_seconds = estimated_long_seconds + estimated_short_seconds + estimated_super_short_seconds
# Determine estimated_time_minutes string
if estimated_seconds < 60:
estimated_time = "Less than 1 minute"
else:
# Calculate hours, minutes, and seconds
hours = int(estimated_seconds // 3600)
remaining_seconds = estimated_seconds % 3600
minutes = int(remaining_seconds // 60)
seconds = int(remaining_seconds % 60)
# Construct the formatted time string
time_parts = []
if hours > 0:
time_parts.append(f"{hours} hour{'s' if hours > 1 else ''}")
if minutes > 0:
time_parts.append(f"{minutes} minute{'s' if minutes > 1 else ''}")
if seconds > 0 or (hours == 0 and minutes == 0):
time_parts.append(f"{seconds} second{'s' if seconds > 1 else ''}")
estimated_time = ", ".join(time_parts)
logger.debug(f"Estimated processing time: ({estimated_time})")
# Prepare and return JSON response
return web.json_response({
"total_unprocessed_loras": num_unprocessed,
"new_loras": num_new,
"moved_loras": num_moved,
"missing_loras": num_missing,
"local_loras": num_local,
"remote_loras": num_remote,
"estimated_time_seconds": round(estimated_seconds, 2),
"estimated_time_minutes": estimated_time
})
@PromptServer.instance.routes.get("/lora_sidebar/is_processing")
async def is_processing_handler(request):
"""Endpoint to check if LoRA processing is currently running."""
return web.json_response({"is_processing": is_processing})
@PromptServer.instance.routes.get("/lora_sidebar/process")
async def process_loras(request):
global is_processing, LORA_FILE_INFO, CACHE_SETTINGS
# Get actual user settings as soon as we can
settings = PromptServer.instance.user_manager.settings.get_settings(request)
CACHE_SETTINGS.update({
'sortMethod': settings.get("LoRA Sidebar.General.sortMethod", 'AlphaAsc'),
'sortModels': settings.get("LoRA Sidebar.General.sortModels", 'None'),
'tagSource': settings.get("LoRA Sidebar.General.tagSource", 'CivitAI'),
'customTags': settings.get("LoRA Sidebar.General.customTags", "").split(','),
'catNew': settings.get("LoRA Sidebar.General.catNew", True),
'nsfwFolder': settings.get("LoRA Sidebar.NSFW.nsfwFolder", True),
'nsfwString': settings.get("LoRA Sidebar.NSFW.folderString", 'NSFW')
})
# Check if processing is already in progress
if is_processing:
return web.json_response({
"status": "Processing already in progress",
"processed_count": 0,
"total_count": 0,
"skipped_count": 0
}, status=400)
is_processing = True
try:
# Check if we have the unprocessed data
unprocessed_info = LoraDataStore.get_data()
if unprocessed_info is None:
# If not, we need to call get_unprocessed_count
logger.info("Unprocessed data not found, calling get_unprocessed_count")
unprocessed_response = await get_unprocessed_count(request) # can remove later?
unprocessed_info = LoraDataStore.get_data()
if unprocessed_info is None:
raise ValueError("Failed to retrieve unprocessed LoRAs data")
logger.info(f"Unprocessed info in process_loras: {unprocessed_info}")
new_loras = unprocessed_info.get('new_loras', [])
moved_loras = unprocessed_info.get('moved_loras', [])
missing_loras = unprocessed_info.get('missing_loras', [])
processed_count = 0
skipped_count = 0
total_count = len(new_loras) + len(moved_loras) + len(missing_loras)
# Path to the processed LoRAs JSON file
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
# Load existing processed LoRAs
if os.path.exists(processed_loras_file):
with io.open(processed_loras_file, "r", encoding="utf-8") as f:
try:
processed_loras = json.load(f)
if not isinstance(processed_loras, dict):
logger.warning("processed_loras.json is malformed. Expected a dictionary.")
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
except json.JSONDecodeError:
logger.error("Error decoding processed_loras.json. Starting with empty data.")
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
else:
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
logger.info("No processed_loras.json found. Starting with empty data.")
logger.info(f"Found {len(new_loras)} new LoRAs, {len(moved_loras)} moved LoRAs, and {len(missing_loras)} missing LoRAs to process.")
async with aiohttp.ClientSession() as session:
# Process both new and moved LoRAs
loras_to_process = new_loras + moved_loras # Combine both lists
# Process new LoRAs
for lora_file in loras_to_process:
filename = lora_file #should remove i think?
base_filename = lora_file
lora_folder = os.path.join(LORA_DATA_DIR, base_filename)
# Check if LoRA is already processed and up to date
info_json_path = os.path.join(lora_folder, "info.json")
if os.path.exists(info_json_path):
needs_reprocess = False
try:
with open(info_json_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
# Check if info.json version is current
if not info_data.get('info_version') or info_data.get('info_version') < PROCESSED_LORAS_VERSION:
needs_reprocess = True
logger.info(f"Found outdated info.json (version: {info_data.get('info_version', 'none')} -> {PROCESSED_LORAS_VERSION}) for {filename}, reprocessing")
except Exception as e:
logger.error(f"Error checking info.json version for {filename}: {str(e)}")
logger.info(f"Will reprocess {filename} due to error checking version")
needs_reprocess = True
# Handle moved files
lora_info = LORA_FILE_INFO.get(base_filename)
if lora_info:
new_path = lora_info['path']
new_subdir = get_subdir(new_path)
path_updated = False
try:
# Check if the LoRA exists in processed_loras.json
existing_entry = next((item for item in processed_loras["loras"]
if item.get("filename") == base_filename), None)
# Update processed_loras.json if needed
if existing_entry is None:
processed_loras["loras"].append({
"filename": base_filename,
"path": new_path
})
path_updated = True
logger.info(f"Added new entry for moved LoRA {base_filename}: {new_path}")
elif existing_entry.get("path") != new_path:
existing_entry["path"] = new_path
path_updated = True
logger.info(f"Updated path for moved LoRA {base_filename}: {new_path}")
# If the path was updated, also update info.json
if path_updated:
# Read current info.json
with open(info_json_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
needs_update = False
# Check if we need to update metadata source
if info_data.get("local_metadata"):
new_civitai_info = f"{os.path.splitext(new_path)[0]}.civitai.info"
if not os.path.exists(new_civitai_info):
info_data["local_metadata"] = False
needs_update = True
needs_reprocess = True
logger.info(f"External metadata not found at new location for {base_filename}, switching to internal")
# Update path and subdir if needed
if info_data.get("path") != new_path or info_data.get("subdir") != new_subdir:
info_data["path"] = new_path
info_data["subdir"] = new_subdir
needs_update = True
logger.info(f"Updated path/subdir in info.json for {base_filename}")
# Write updates if needed
if needs_update:
with open(info_json_path, "w", encoding="utf-8") as f:
json.dump(info_data, f, indent=4)
logger.info(f"Saved updated info.json for moved LoRA: {base_filename}")
# Save processed_loras.json immediately after updating
if os.path.exists(processed_loras_file):
with open(processed_loras_file, "r", encoding="utf-8") as f:
existing_data = json.load(f)
# Update the specific entry in existing_data
existing_lora = next((item for item in existing_data["loras"]
if item.get("filename") == base_filename), None)
if existing_lora:
existing_lora["path"] = new_path
else:
existing_data["loras"].append({
"filename": base_filename,
"path": new_path
})
existing_data = validate_processed_loras(existing_data)
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(existing_data, f, indent=4, ensure_ascii=False)
logger.info(f"Updated processed_loras.json for {base_filename}")
# Handle move completion
if base_filename in moved_loras:
moved_loras.remove(base_filename)
processed_count += 1
progress = int(((processed_count + skipped_count) / total_count) * 100)
await PromptServer.instance.send_json("lora_process_progress", {
"progress": progress,
"completed": processed_count + skipped_count,
"total": total_count
})
logger.info(f"Completed move processing for {base_filename}")
except Exception as e:
logger.error(f"Error updating info for moved LoRA {base_filename}: {str(e)}")
needs_reprocess = True # Force reprocess if we had an error
# Only skip if we don't need to reprocess and haven't moved
if not needs_reprocess:
skipped_count += 1
progress = int(((processed_count + skipped_count) / total_count) * 100)
await PromptServer.instance.send_json("lora_process_progress", {
"progress": progress,
"completed": processed_count + skipped_count,
"total": total_count
})
logger.info(f"Skipping already processed LoRA (current version): {filename}")
continue
try:
# Process LoRA without creating the folder first
logger.info(f"Processing {filename}")
# Get the file path from LORA_FILE_INFO - let's add proper error handling here
if LORA_FILE_INFO is None:
raise ValueError(f"LORA_FILE_INFO is None when processing {filename}")
if LORA_FILE_INFO is not None:
logger.info(f"Looking for base_filename: {base_filename}")
# Get the file path from LORA_FILE_INFO
lora_info = LORA_FILE_INFO.get(base_filename)
if not lora_info:
raise ValueError(f"No entry in LORA_FILE_INFO for {base_filename}. Available keys: {list(LORA_FILE_INFO.keys())[:5]}")
file_path = lora_info['path']
has_local_metadata = False # Initialize
# Check for local metadata first
version_info = await check_local_info(file_path)
if version_info:
has_local_metadata = True # Set flag if we found local metadata
logger.info("Got version info from local metadata")
else:
# If no local metadata, proceed with CivitAI API calls
file_hash = await hash_file(file_path)
version_info = await fetch_version_info(session, file_hash)
logger.info("Got version info from CivitAI")
# Calculate subdir, handling symlink issues and cross-drive paths
subdir = get_subdir(file_path)
if version_info:
# Prepare data to save
info_to_save = {
"name": (version_info.get('model', {}).get('name') or version_info.get('name') or filename),
"modelId": version_info.get('modelId'),
"versionId": version_info.get('id'),
"versionName": version_info.get('name'),
"tags": [],
"trained_words": version_info.get('trainedWords', []),
"baseModel": version_info.get('baseModel'),
"images": [],
"nsfw": version_info.get('model', {}).get('nsfw', False),
"nsfwLevel": 0,
"version_desc": version_info.get('description'),
"reco_weight": 1,
"model_desc": version_info.get('model', {}).get('description'),
"type": version_info.get('model', {}).get('type'),
"createdDate": format_date(version_info.get('createdAt')) or datetime.now().strftime('%Y-%m-%d'),
"updatedDate": format_date(version_info.get('updatedAt')),
"subdir": subdir,
"path": file_path,
"local_metadata": has_local_metadata,
"info_version": PROCESSED_LORAS_VERSION,
"user_edits": []
}
# Fetch model info if available and we're not using local metadata
model_id = info_to_save["modelId"]
if model_id and not has_local_metadata:
model_info = await fetch_model_info(session, model_id)
if model_info:
info_to_save["tags"] = model_info.get('tags', [])
info_to_save["nsfwLevel"] = model_info.get('nsfwLevel', 0)
info_to_save["model_desc"] = model_info.get('description')
# Get custom images only for local metadata
custom_images = []
if has_local_metadata:
logger.info(f"Looking for custom images for local metadata LoRA: {filename}")
custom_images = find_custom_images(file_path, base_filename)
logger.info(f"Found {len(custom_images)} custom images")
# Process remote images from version_info
remote_images = []
for image in version_info.get('images', []):
meta = image.get('meta', {}) # Default to empty dict if None
remote_images.append({
"url": image.get('url'),
"type": image.get('type'),
"nsfwLevel": image.get('nsfwLevel', 0),
"hasMeta": image.get('hasMeta', False),
"prompt": meta.get('prompt') if meta else None, # Guard against None
"blurhash": image.get('hash')
})
# Combine custom images first, then remote images
info_to_save['images'] = custom_images + remote_images
# Handle trained_words
trained_words = version_info.get('trainedWords', [])
if isinstance(trained_words, list):
if len(trained_words) == 1 and ',' in trained_words[0]:
# Split the comma-separated string into a list
info_to_save["trained_words"] = [word.strip() for word in trained_words[0].split(',') if word.strip()]
else:
# Already a list, just use it as is
info_to_save["trained_words"] = trained_words
elif isinstance(trained_words, str):
# If it's a single string, split it by commas
info_to_save["trained_words"] = [word.strip() for word in trained_words.split(',') if word.strip()]
else:
# Fallback to an empty list if it's neither a list nor a string
info_to_save["trained_words"] = []
# Download the first image as preview
has_local_images = version_info.get('has_local_images', False) if has_local_metadata else False
if info_to_save['images']:
if not has_local_images: # Download if not using local images or not local metadata
preview_path = os.path.join(LORA_DATA_DIR, filename, "preview")
os.makedirs(os.path.dirname(preview_path), exist_ok=True)
preview_filename = await download_image(session, info_to_save['images'][0]['url'], preview_path)
if preview_filename:
logger.info(f"Saved preview image as {preview_filename}")
else:
logger.info("Using existing local preview image")
else:
# Only copy placeholder if no local images
if not has_local_images:
logger.info("No images available and no local images - copying placeholder")
await copy_placeholder_as_preview(base_filename)
else:
logger.info("No images in metadata but using local images")
# Create the LoRA folder after successful processing
os.makedirs(lora_folder, exist_ok=True)
# Save information to a JSON file
info_file_path = os.path.join(lora_folder, "info.json")
with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(info_to_save, f, indent=4)
else:
logger.info(f"Failed to fetch info for {filename}, treating it as a custom LoRA.")
custom_images = [] # Initialize first
try:
custom_images = find_custom_images(file_path, base_filename)
logger.info(f"Found {len(custom_images)} custom images for custom LoRA")
except Exception as e:
logger.error(f"Error finding custom images: {str(e)}")
custom_images = [] # Ensure we have an empty list if something fails
info_to_save = {
"name": filename, # Use filename as the name for custom LoRAs
"modelId": None,
"versionId": None,
"versionName": None,
"tags": [],
"trained_words": [],
"baseModel": "custom", # Custom for dropdown
"images": custom_images,
"nsfw": False,
"nsfwLevel": 0,
"version_desc": "Custom Lora",
"reco_weight": 1,
"model_desc": None,
"type": None,
"createdDate": datetime.now().strftime('%Y-%m-%d'),
"updatedDate": datetime.now().strftime('%Y-%m-%d'),
"subdir": subdir,
"path": file_path,
"local_metadata": True,
"info_version": PROCESSED_LORAS_VERSION, # don't really need this for custom loras
"user_edits": []
}
# Handle preview image for custom LoRA
if custom_images:
try:
logger.info("Using first custom image as preview")
preview_path = os.path.join(LORA_DATA_DIR, filename, "preview")
os.makedirs(os.path.dirname(preview_path), exist_ok=True)
# For custom images, copy the first one as preview
first_image = custom_images[0]['name']
source_path = os.path.join(os.path.dirname(file_path), first_image)
if os.path.exists(source_path):
ext = os.path.splitext(first_image)[1]
shutil.copy2(source_path, os.path.join(preview_path + ext))
logger.info(f"Copied custom image as preview: {first_image}")
except Exception as e:
logger.error(f"Error setting preview image: {str(e)}")
await copy_placeholder_as_preview(base_filename)
else:
# Copy the placeholder image as preview
logger.info("No custom images found - copying placeholder")
await copy_placeholder_as_preview(base_filename)
# Create the LoRA folder
os.makedirs(lora_folder, exist_ok=True)
# Save the minimal info.json
info_file_path = os.path.join(lora_folder, "info.json")
with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(info_to_save, f, indent=4)
logger.info(f"Processed {filename}")
# Add to cache
if LORA_CACHE.get('ordered_loras') is not None:
info_to_save['id'] = base_filename # Make sure ID is set
info_to_save['favorite'] = base_filename in processed_loras.get('favorites', [])
# Remove any existing entry first (in case of reprocessing)
LORA_CACHE['ordered_loras'] = [
item for item in LORA_CACHE['ordered_loras']
if item['id'] != base_filename
]
# Add new entry
LORA_CACHE['ordered_loras'].append(info_to_save)
# Resort if needed
if len(LORA_CACHE['ordered_loras']) > 1:
sort_metadata = await get_lora_sort_metadata()
LORA_CACHE['ordered_loras'] = await sort_loras_with_categories(
LORA_CACHE['ordered_loras'],
CACHE_SETTINGS,
processed_loras.get('favorites', []),
sort_metadata
)
# Update category counts
manage_category_counts("calculate",
loras=LORA_CACHE['ordered_loras'],
settings=CACHE_SETTINGS
)
processed_count += 1
processed_loras["loras"].append({
"filename": base_filename,
"path": file_path # Include the path here
})
processed_loras = validate_processed_loras(processed_loras)
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
except Exception as e:
logger.error(f"Error processing {filename}: {str(e)}")
# Remove the folder if it was partially created
if os.path.exists(lora_folder):
shutil.rmtree(lora_folder)
# Send progress update
progress = int(((processed_count + skipped_count) / total_count) * 100)
await PromptServer.instance.send_json("lora_process_progress", {
"progress": progress,
"completed": processed_count + skipped_count,
"total": total_count
})
# Handle missing LoRAs
for missing_lora_name in missing_loras:
try:
lora_folder = os.path.join(LORA_DATA_DIR, missing_lora_name)
# Remove the folder if it exists
if os.path.exists(lora_folder):
shutil.rmtree(lora_folder)
logger.info(f"Removed folder for missing LoRA: {missing_lora_name}")
# Read current data to preserve version and favorites
existing_data = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
if os.path.exists(processed_loras_file):
with open(processed_loras_file, "r", encoding="utf-8") as f:
existing_data = json.load(f)
# Update loras list - remove the missing one
existing_data["loras"] = [
lora for lora in existing_data["loras"]
if lora.get("filename") != missing_lora_name
]
# Also remove from favorites if present
if missing_lora_name in existing_data.get("favorites", []):
existing_data["favorites"].remove(missing_lora_name)
# Update cache if it exists
if LORA_CACHE.get('ordered_loras'):
LORA_CACHE['ordered_loras'] = [
lora for lora in LORA_CACHE['ordered_loras']
if lora.get('id') != missing_lora_name
]
# Save updated data
with open(processed_loras_file, "w", encoding="utf-8") as f:
json.dump(existing_data, f, indent=4, ensure_ascii=False)
logger.info(f"Removed {missing_lora_name} from processed_loras.json")
processed_count += 1
except Exception as e:
logger.error(f"Error handling missing LoRA {missing_lora_name}: {str(e)}")
skipped_count += 1
# Send progress update
progress = int(((processed_count + skipped_count) / total_count) * 100)
await PromptServer.instance.send_json("lora_process_progress", {
"progress": progress,
"completed": processed_count + skipped_count,
"total": total_count
})
if LORA_CACHE.get('ordered_loras'):
# Get proper user settings
settings = PromptServer.instance.user_manager.settings.get_settings(request)
sort_metadata = await get_lora_sort_metadata()
# Resort entire cache with proper settings
LORA_CACHE['ordered_loras'] = await sort_loras_with_categories(
LORA_CACHE['ordered_loras'],
settings,
processed_loras.get('favorites', []),
sort_metadata
)
# Final category calculation with proper settings
category_info = manage_category_counts("calculate",
loras=LORA_CACHE['ordered_loras'],
settings=settings
)
response_data = {
"status": "Processing complete",
"processed_count": processed_count,
"total_count": total_count,
"skipped_count": skipped_count,
"categoryInfo": category_info
}
else:
response_data = {
"status": "Processing complete",
"processed_count": processed_count,
"total_count": total_count,
"skipped_count": skipped_count
}
finally:
is_processing = False # Ensure flag is reset when processing finishes
LoraDataStore.clear_data()
# disable the refresh all setting after processing
setting_id = "LoRA Sidebar.General.refreshAll"
settings = PromptServer.instance.user_manager.settings.get_settings(request)
settings[setting_id] = False
PromptServer.instance.user_manager.settings.save_settings(request, settings)
refresh_setting = settings.get("LoRA Sidebar.General.refreshAll")
logger.info(f"Current refresh setting value: {refresh_setting}")
return web.json_response(response_data)
@PromptServer.instance.routes.get("/lora_sidebar/data")
async def get_lora_data(request):
# Get request parameters
offset = int(request.query.get('offset', 0))
limit = int(request.query.get('limit', 500))
# Make sure we don't go over actual total lora size
#totalLoras = len(LORA_CACHE['ordered_loras'])
#logger.error('Initial request limit', limit)
#if limit > totalLoras:
# limit = totalLoras - 1
# logger.error('NEW request limit', limit)
# Get settings and metadata
settings = get_user_settings(request)
sort_metadata = await get_lora_sort_metadata()
# Cache check if we need to rebuild/resort cache
needs_resort = False
needs_rebuild = False
if not LORA_CACHE.get('ordered_loras'):
logger.info("No cache found, will rebuild")
needs_rebuild = True
else:
# Settings that require rebuild (modify core data)
rebuild_keys = ['nsfwFolder', 'nsfwString']
for key in rebuild_keys:
if settings.get(key) != CACHE_SETTINGS.get(key):
logger.info(f"Setting requiring rebuild changed: {key} {CACHE_SETTINGS.get(key)} -> {settings.get(key)}")
needs_rebuild = True
break
# Settings that only need resort (presentation changes)
if not needs_rebuild: # Only check if we don't already need rebuild
resort_keys = ['sortMethod', 'sortModels', 'tagSource']
for key in resort_keys:
if settings.get(key) != CACHE_SETTINGS.get(key):
logger.info(f"Sort setting changed: {key} {CACHE_SETTINGS.get(key)} -> {settings.get(key)}")
needs_resort = True
break
# Custom tags check
if settings.get('tagSource') == 'Custom' and settings.get('customTags') != CACHE_SETTINGS.get('customTags'):
logger.info("Custom tags changed")
needs_resort = True
# Load favorites
favorites = []
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
if os.path.exists(processed_loras_file):
with open(processed_loras_file, "r", encoding="utf-8") as f:
try:
processed_loras = json.load(f)
favorites = processed_loras.get('favorites', [])
except json.JSONDecodeError:
logger.error("Error reading processed_loras.json")
if needs_rebuild:
lora_data = []
# Load LoRA data
all_folders = [folder for folder in os.listdir(LORA_DATA_DIR)
if os.path.isdir(os.path.join(LORA_DATA_DIR, folder))]
for folder in all_folders:
info_file = os.path.join(LORA_DATA_DIR, folder, "info.json")
if os.path.exists(info_file):
with open(info_file, "r", encoding="utf-8") as f:
try:
data = json.load(f)
data['id'] = folder
data['favorite'] = folder in favorites
# Add NSFW folder check
nsfw_folder = settings.get('nsfwFolder', True)
if nsfw_folder and 'path' in data:
path_lower = data['path'].lower()
nsfw_string = settings.get('nsfwString', 'NSFW').lower()
if nsfw_string in path_lower:
logger.info(f"Setting NSFW flag for {folder} due to path: {data['path']}")
data['nsfw'] = True
# Get filename and path
if folder in LORA_FILE_INFO:
data['filename'] = LORA_FILE_INFO[folder]['filename']
data['path'] = LORA_FILE_INFO[folder]['path']
else:
data['filename'] = f"{folder}.safetensors"
data['path'] = ""
lora_data.append(data)
except json.JSONDecodeError:
logger.error(f"Error reading {info_file}. Skipping.")
# Pre-sort all data
ordered_loras = await sort_loras_with_categories(lora_data, settings, favorites, sort_metadata)
LORA_CACHE['ordered_loras'] = ordered_loras
# After rebuild, update cache settings
CACHE_SETTINGS.update({
'sortMethod': settings.get('sortMethod'),
'sortModels': settings.get('sortModels'),
'tagSource': settings.get('tagSource'),
'customTags': settings.get('customTags', []),
'catNew': settings.get('catNew', True),
'nsfwFolder': settings.get('nsfwFolder', True),
'nsfwString': settings.get("nsfwString", 'NSFW')
})
elif needs_resort:
logger.info("Resorting existing cache")
# Just resort existing data
LORA_CACHE['ordered_loras'] = await sort_loras_with_categories(
LORA_CACHE['ordered_loras'],
settings,
favorites,
sort_metadata
)
# Update cache settings
CACHE_SETTINGS.update({
'sortMethod': settings.get('sortMethod'),
'sortModels': settings.get('sortModels'),
'tagSource': settings.get('tagSource'),
'customTags': settings.get('customTags', []),
'catNew': settings.get('catNew', True),
'nsfwFolder': settings.get('nsfwFolder', True),
'nsfwString': settings.get("nsfwString", 'NSFW')
})
# Get all loras from cache
all_loras = LORA_CACHE.get('ordered_loras', [])
# Separate loras into priority groups
favorites_list = [lora for lora in all_loras if lora['favorite']]
new_items = []
if settings.get('catNew'):
new_items = [lora for lora in all_loras if not lora['favorite'] and lora.get('is_new')]
def sort_category(cat):
"""
Custom sort function that:
1. Forces lowercase first for consistent sorting
2. Handles numeric prefixes properly
3. Preserves natural number ordering
"""
# Force lowercase immediately
cat = str(cat).strip().lower()
# Split the string into numeric and non-numeric parts
parts = []
current_part = ""
current_is_digit = False
for char in cat:
is_digit = char.isdigit()
if current_part and is_digit != current_is_digit:
# Type changed, store the current part
if current_is_digit:
parts.append(int(current_part))
else:
parts.append(current_part) # Already lowercase
current_part = ""
current_part += char
current_is_digit = is_digit
# Add the last part
if current_part:
if current_is_digit:
parts.append(int(current_part))
else:
parts.append(current_part) # Already lowercase
return parts
# Group remaining loras by category
remaining_by_category = {}
for lora in all_loras:
if not lora['favorite'] and (not lora.get('is_new') or not settings.get('catNew')):
cat = str(lora.get('category', 'Unsorted')).lower()
if cat not in remaining_by_category:
remaining_by_category[cat] = []
remaining_by_category[cat].append(lora)
# Create ordered list with priorities
ordered_categories = sorted(remaining_by_category.keys())
prioritized_loras = favorites_list + new_items
#Debug print order
logger.info("Category order:")
for idx, cat in enumerate(ordered_categories[:10]):
logger.info(f"{idx + 1}. {cat} ({len(remaining_by_category[cat])} items)")
# Add category-based loras in order
for category in ordered_categories:
prioritized_loras.extend(remaining_by_category[category])
# Debug print the first few non-favorite, non-new loras
start_idx = len(favorites_list) + len(new_items)
logger.info("\nFirst 10 category loras being sent:")
for idx, lora in enumerate(prioritized_loras[start_idx:start_idx+10]):
logger.info(f"{idx + 1}. Category: {lora.get('category', 'Unknown')} | Name: {lora.get('name', lora.get('filename', 'Unknown'))}")
# Apply pagination
start_idx = offset
end_idx = offset + limit
paginated_loras = LORA_CACHE['ordered_loras'][start_idx:end_idx]
# Also debug print what's actually being sent in this chunk
logger.info(f"\nSending chunk from {start_idx} to {end_idx} ({len(paginated_loras)} items)")
logger.info("First 10 items in this chunk:")
for idx, lora in enumerate(paginated_loras[:10]):
logger.info(f"{idx + 1}. Category: {lora.get('category', 'Unknown')} | "
f"Name: {lora.get('name', lora.get('filename', 'Unknown'))} | "
f"{'(Favorite)' if lora.get('favorite') else '(New)' if lora.get('is_new') else ''}")
# Clear sent tracking on initial load
if offset == 0:
LORA_CACHE['sent_loras'] = set()
# Track what we're sending
LORA_CACHE['sent_loras'].update(lora['id'] for lora in paginated_loras)
# Calculate category counts for all data
category_counts = manage_category_counts("calculate",
loras=all_loras,
paginated_loras=paginated_loras,
settings=settings
)
return web.json_response({
"loras": paginated_loras,
"favorites": favorites if offset == 0 else [],
"hasMore": end_idx < len(LORA_CACHE['ordered_loras']),
"totalCount": len(LORA_CACHE['ordered_loras']),
"categoryInfo": category_counts
})
@PromptServer.instance.routes.post("/lora_sidebar/toggle_favorite")
async def toggle_favorite(request):
data = await request.json()
lora_id = data.get('id')
logger.info(f"Toggle favorite request for LoRA ID: {lora_id}")
# Update processed_loras.json
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
if os.path.exists(processed_loras_file):
with open(processed_loras_file, "r", encoding="utf-8") as f:
try:
processed_loras = json.load(f)
except json.JSONDecodeError:
processed_loras = {"favorites": []}
else:
processed_loras = {"favorites": []}
if 'favorites' not in processed_loras:
processed_loras['favorites'] = []
# Toggle status
is_favorite = lora_id in processed_loras['favorites']
logger.info(f"LoRA current favorite status: {is_favorite}")
if is_favorite:
processed_loras['favorites'].remove(lora_id)
logger.info(f"Removed {lora_id} from favorites")
else:
processed_loras['favorites'].append(lora_id)
logger.info(f"Added {lora_id} to favorites")
# Get the lora's current category before updating
old_category = None
new_category = None
if LORA_CACHE['ordered_loras']:
for lora in LORA_CACHE['ordered_loras']:
if lora['id'] == lora_id:
old_category = 'Favorites' if is_favorite else lora['category']
new_category = lora['category'] if is_favorite else 'Favorites'
lora['favorite'] = not is_favorite
break
# Update category counts
category_info = manage_category_counts("update",
old_category=old_category,
new_category=new_category
)
# Save persistent data
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4)
return web.json_response({
"status": "success",
"categoryInfo": category_info
})
@PromptServer.instance.routes.post("/lora_sidebar/set_preview")
async def set_preview_image(request):
try:
data = await request.json()
lora_id = data.get('id')
image_url = data.get('url')
if not lora_id or not image_url:
return web.json_response({"error": "Missing parameters"}, status=400)
# Handle internal URLs (custom images)
if image_url.startswith('/lora_sidebar/custom_image/'):
try:
# Parse the filename from the URL
parts = image_url.split('/')
if len(parts) >= 5: # Ensure we have enough parts
source_filename = parts[-1] # Get the last part as filename
# Source path (where the custom image is stored)
source_path = os.path.join(LORA_DATA_DIR, lora_id, source_filename)
if not os.path.exists(source_path):
return web.json_response({"error": "Source image not found"}, status=404)
# Remove any existing preview files
preview_dir = os.path.join(LORA_DATA_DIR, lora_id)
for filepath in glob.glob(os.path.join(preview_dir, 'preview.*')):
os.remove(filepath)
# Determine extension from source file
ext = os.path.splitext(source_filename)[1]
preview_path = os.path.join(preview_dir, f"preview{ext}")
# Copy the file
shutil.copy2(source_path, preview_path)
# Generate a unique version
preview_version = str(int(time.time()))
return web.json_response({
"status": "success",
"preview_version": preview_version
})
except Exception as e:
logger.error(f"Error setting preview from internal image: {str(e)}")
return web.json_response({"error": str(e)}, status=500)
# Handle external URLs (original code for external images)
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status == 200:
content_type = response.headers.get('Content-Type', '')
ext = mimetypes.guess_extension(content_type) or '.jpg'
# Remove any existing preview files
preview_dir = os.path.join(LORA_DATA_DIR, lora_id)
for filepath in glob.glob(os.path.join(preview_dir, 'preview.*')):
os.remove(filepath)
# Save the new preview image
preview_path = os.path.join(preview_dir, f"preview{ext}")
with open(preview_path, 'wb') as f:
f.write(await response.read())
# Generate a unique version for just this preview
preview_version = str(int(time.time()))
return web.json_response({
"status": "success",
"preview_version": preview_version
})
else:
return web.json_response({"error": "Failed to fetch image"}, status=400)
except Exception as e:
logger.error(f"Error setting preview image: {str(e)}")
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/refresh/{version_id}")
async def refresh_lora(request):
version_id = request.match_info['version_id']
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
# Define the standard field order
STANDARD_FIELD_ORDER = [
"name",
"modelId",
"versionId",
"versionName",
"tags",
"trained_words",
"baseModel",
"images",
"nsfw",
"nsfwLevel",
"version_desc",
"reco_weight",
"model_desc",
"type",
"createdDate",
"updatedDate",
"subdir",
"path",
"local_metadata",
"info_version",
"user_edits"
]
async with aiohttp.ClientSession() as session:
try:
# Find the existing LoRA folder based on version ID
existing_lora_folder = None
existing_info = None
base_filename = None
for folder in os.listdir(LORA_DATA_DIR):
info_file_path = os.path.join(LORA_DATA_DIR, folder, "info.json")
if os.path.exists(info_file_path):
with open(info_file_path, "r", encoding="utf-8") as f:
folder_info = json.load(f)
if str(folder_info.get("versionId")) == str(version_id):
existing_lora_folder = os.path.join(LORA_DATA_DIR, folder)
existing_info = folder_info
base_filename = folder
break
if not existing_lora_folder or not existing_info:
logger.warning(f"LoRA with version ID {version_id} not found.")
return web.json_response({
"status": "error",
"message": f"LoRA with version ID {version_id} not found"
}, status=404)
# Cache user edits and custom images
user_edits = existing_info.get('user_edits', [])
existing_custom_images = [img for img in existing_info.get('images', [])
if img.get('custom') is True]
# Check if this LoRA uses local metadata
if existing_info.get("local_metadata"):
version_info = await check_local_info(existing_info["path"])
if not version_info:
return web.json_response({
"status": "error",
"message": "Local metadata file no longer exists"
}, status=400)
# Also get remote info for images if needed
if not version_info.get('images'):
remote_info = await fetch_version_info_by_id(session, version_id)
if remote_info and remote_info.get('images'):
version_info['images'] = remote_info['images']
else:
version_info = await fetch_version_info_by_id(session, version_id, skip_rate_limit=True)
# Initialize updates dictionary
updates = {}
# Get modelId from either source
model_id = version_info.get('modelId') or existing_info.get('modelId')
# Only fetch model info if we need it and aren't using local metadata
if model_id and not existing_info.get("local_metadata"):
logger.info(f"Fetching model info for model ID: {model_id}")
model_info = await fetch_model_info(session, model_id, skip_rate_limit=True)
if model_info:
# Only update non-user-edited fields
if 'tags' not in user_edits and model_info.get('tags'):
updates['tags'] = model_info.get('tags', [])
if 'nsfwLevel' not in user_edits and model_info.get('nsfwLevel') is not None:
updates['nsfwLevel'] = model_info.get('nsfwLevel', 0)
if 'model_desc' not in user_edits and model_info.get('description'):
updates['model_desc'] = model_info.get('description')
# Update fields only if they're not in user_edits
if version_info:
if 'name' not in user_edits:
new_name = version_info.get('model', {}).get('name', existing_info.get('name'))
if new_name and new_name != existing_info.get('name'):
updates['name'] = new_name
if 'trained_words' not in user_edits:
new_trained_words = version_info.get('trainedWords', existing_info.get('trained_words'))
if new_trained_words and new_trained_words != existing_info.get('trained_words'):
if isinstance(new_trained_words, list):
if len(new_trained_words) == 1 and ',' in new_trained_words[0]:
# Split single entry with commas into multiple words
updates['trained_words'] = [word.strip() for word in new_trained_words[0].split(',') if word.strip()]
else:
# Already a proper list format, use as-is
updates['trained_words'] = new_trained_words
elif isinstance(new_trained_words, str):
# Single string - split it
updates['trained_words'] = [word.strip() for word in new_trained_words.split(',') if word.strip()]
else:
updates['trained_words'] = []
if 'baseModel' not in user_edits:
new_base_model = version_info.get('baseModel')
if new_base_model and new_base_model != existing_info.get('baseModel'):
updates['baseModel'] = new_base_model
if 'version_desc' not in user_edits:
new_description = version_info.get('description')
if new_description and new_description != existing_info.get('description'):
updates['description'] = new_description
# Handle remote images
new_remote_images = []
for image in version_info.get('images', []):
meta = image.get('meta', {})
image_data = {
"url": image.get('url'),
"type": image.get('type'),
"nsfwLevel": image.get('nsfwLevel', 0),
"hasMeta": image.get('hasMeta', False),
"prompt": meta.get('prompt') if meta else None,
"blurhash": image.get('hash')
}
if image_data['url']:
new_remote_images.append(image_data)
# Look for any new custom images
if LORA_FILE_INFO.get(base_filename):
file_path = LORA_FILE_INFO[base_filename]['path']
current_custom_images = find_custom_images(file_path, base_filename)
# Merge with existing custom images, avoiding duplicates
existing_custom_urls = {img['url'] for img in existing_custom_images}
new_custom_images = [img for img in current_custom_images
if img['url'] not in existing_custom_urls]
if new_custom_images:
existing_custom_images.extend(new_custom_images)
# Update images list
updates['images'] = existing_custom_images + new_remote_images
# Always check and update subdir if path has changed
if LORA_FILE_INFO.get(base_filename):
new_path = LORA_FILE_INFO[base_filename]['path']
new_subdir = get_subdir(new_path)
if new_path != existing_info.get('path') or new_subdir != existing_info.get('subdir'):
updates['path'] = new_path
updates['subdir'] = new_subdir
# Update processed_loras.json with new path
if os.path.exists(processed_loras_file):
with open(processed_loras_file, 'r', encoding="utf-8") as f:
processed_loras = json.load(f)
# Update the path in processed_loras
for lora in processed_loras.get('loras', []):
if lora.get('filename') == base_filename:
lora['path'] = new_path
break
processed_loras = validate_processed_loras(processed_loras)
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
#Check for and update date fields
if version_info:
created_date = format_date(version_info.get('createdAt'))
updated_date = format_date(version_info.get('updatedAt'))
# If API version has valid date or existing date is invalid/missing, update it
if (created_date != 'unknown' and created_date != existing_info.get('createdDate')) or \
existing_info.get('createdDate') in [None, 'unknown']:
updates['createdDate'] = created_date if created_date != 'unknown' else datetime.now().strftime('%Y-%m-%d')
if (updated_date != 'unknown' and updated_date != existing_info.get('updatedDate')) or \
existing_info.get('updatedDate') in [None, 'unknown']:
updates['updatedDate'] = updated_date if updated_date != 'unknown' else datetime.now().strftime('%Y-%m-%d')
# If there are updates, apply them while preserving user edits
if updates:
logger.info(f"Applying updates to LoRA {existing_lora_folder}: {updates}")
# Apply updates while preserving user edits
for key, value in updates.items():
if key not in user_edits: # Only update if not user-edited
existing_info[key] = value
# Create new ordered dict using standard field order
ordered_info = {}
# First add all fields in standard order
for field in STANDARD_FIELD_ORDER:
if field in existing_info:
ordered_info[field] = existing_info[field]
# Then add any additional fields that might exist but aren't in standard order
for key in existing_info:
if key not in ordered_info:
ordered_info[key] = existing_info[key]
# Save updated information
info_file_path = os.path.join(existing_lora_folder, "info.json")
with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(ordered_info, f, indent=4, ensure_ascii=False)
# Update the cache using base_filename
if LORA_CACHE.get('ordered_loras'):
for item in LORA_CACHE['ordered_loras']:
if item['id'] == base_filename:
item.update(ordered_info)
break
category_info = manage_category_counts("calculate",
loras=LORA_CACHE['ordered_loras'],
)
return web.json_response({
"status": "success",
"data": existing_info,
"updated_fields": list(updates.keys()),
"categoryInfo": category_info
})
else:
logger.info(f"No updates required for LoRA with version ID {version_id}.")
return web.json_response({
"status": "success",
"message": "No updates required"
})
except Exception as e:
logger.error(f"Error refreshing LoRA: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/refresh/{lora_id}")
async def refresh_lora(request):
lora_id = request.match_info['lora_id']
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
info_json_path = os.path.join(lora_folder, "info.json")
if not os.path.exists(info_json_path):
return web.json_response({"status": "error", "message": "LoRA not found"}, status=404)
with open(info_json_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
model_id = info_data.get("modelId")
version_id = info_data.get("versionId")
async with aiohttp.ClientSession() as session:
try:
# If no model or version ID, perform a hash lookup
if not model_id or not version_id:
logger.warning(f"No model or version ID found for {lora_id}, performing hash lookup.")
file_hash = await hash_file(os.path.join(lora_folder, f"{lora_id}.safetensors")) # Assuming safetensors format
version_info = await fetch_version_info(session, file_hash)
if not version_info:
return web.json_response({"status": "error", "message": "Could not refresh LoRA"}, status=400)
else:
version_info = await fetch_version_info_by_id(session, version_id)
# Update info.json with refreshed data
# (Code to update the info.json with new data)
return web.json_response({"status": "success", "data": info_data})
except Exception as e:
logger.info(f"Error refreshing LoRA: {str(e)}")
return web.json_response({"status": "error", "message": str(e)}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/update_info")
async def update_lora_info(request):
try:
data = await request.json()
lora_id = data.get('id')
field = data.get('field')
value = data.get('value')
if not all([lora_id, field]):
return web.json_response({
"status": "error",
"message": "Missing required parameters"
}, status=400)
# Construct paths
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
info_file_path = os.path.join(lora_folder, "info.json")
if not os.path.exists(info_file_path):
return web.json_response({
"status": "error",
"message": "LoRA info file not found"
}, status=404)
# Read current info
with open(info_file_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
# Initialize user_edits if it doesn't exist
if 'user_edits' not in info_data:
info_data['user_edits'] = []
# Special handling for arrays/lists
if isinstance(value, list) and field in ['tags', 'trained_words']:
value = [str(item).strip() for item in value if str(item).strip()]
# Update the field
old_value = info_data.get(field)
info_data[field] = value
# Track the edit if it's not already in user_edits
if field not in info_data['user_edits']:
info_data['user_edits'].append(field)
# Update in-memory cache before file write
cache_updated = False
if LORA_CACHE.get('ordered_loras'):
for lora in LORA_CACHE['ordered_loras']:
if lora['id'] == lora_id:
lora[field] = value
if 'user_edits' not in lora:
lora['user_edits'] = []
if field not in lora['user_edits']:
lora['user_edits'].append(field)
cache_updated = True
break
# Save the updated info
try:
with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(info_data, f, indent=4, ensure_ascii=False)
except Exception as e:
logger.error(f"Error saving info file: {str(e)}")
if cache_updated:
return web.json_response({
"status": "warning",
"message": "Changes saved to memory but not to disk. Changes may be lost on server restart.",
"field": field,
"old_value": old_value,
"new_value": value,
"user_edits": info_data['user_edits']
})
raise
# Recalculate category info if needed
category_info = None
if field in ['tags', 'baseModel', 'subdir', 'favorite', 'nsfw']:
category_info = manage_category_counts("calculate",
loras=LORA_CACHE['ordered_loras']
)
# Prepare response data
response_data = {
"status": "success",
"message": f"Updated {field} successfully",
"field": field,
"old_value": old_value,
"new_value": value,
"user_edits": info_data['user_edits']
}
if category_info:
response_data["categoryInfo"] = category_info
logger.info(f"Updated LoRA {lora_id} field '{field}': {old_value} -> {value}")
return web.json_response(response_data)
except json.JSONDecodeError as e:
logger.error(f"JSON decode error updating LoRA info: {str(e)}")
return web.json_response({
"status": "error",
"message": "Invalid JSON data"
}, status=400)
except Exception as e:
logger.error(f"Error updating LoRA info: {str(e)}")
return web.json_response({
"status": "error",
"message": f"Failed to update LoRA info: {str(e)}"
}, status=500)
@PromptServer.instance.routes.get("/lora_sidebar/latest_temp_image")
async def get_latest_temp_image(request):
temp_dir = folder_paths.get_temp_directory()
try:
# Get all png files in temp directory
temp_files = [f for f in os.listdir(temp_dir)
if f.endswith('.png') and os.path.isfile(os.path.join(temp_dir, f))]
if not temp_files:
return web.json_response({
"status": "error",
"message": "No generated images found"
}, status=404)
# Sort by modification time, newest first
latest_file = max(temp_files,
key=lambda f: os.path.getmtime(os.path.join(temp_dir, f)))
# Construct full URL using request information
host = request.headers.get('Host', 'localhost:8189')
scheme = request.scheme
# Format complete URL
image_url = f"{scheme}://{host}/api/view?filename={latest_file}&type=temp"
return web.json_response({
"status": "success",
"url": image_url
})
except Exception as e:
logger.error(f"Error getting latest temp image: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/upload_images")
async def upload_lora_images(request):
try:
# Check if we're getting a URL upload
if request.content_type == 'application/json':
data = await request.json()
lora_id = data.get('lora_id')
urls = data.get('urls', [])
if not lora_id or not urls:
return web.json_response({
"status": "error",
"message": "No valid URLs or LoRA ID provided"
}, status=400)
# Add duplicate check for temp images
info_path = os.path.join(LORA_DATA_DIR, lora_id, "info.json")
if os.path.exists(info_path):
with open(info_path, 'r', encoding='utf-8') as f:
existing_data = json.load(f)
existing_sources = {img.get('source_url') for img in existing_data.get('images', [])
if img.get('source_url')}
# Only check the temp image URLs
temp_urls = [url for url in urls if 'type=temp' in url]
if any(url in existing_sources for url in temp_urls):
return web.json_response({
"status": "error",
"message": "Image already added to this LoRA"
}, status=400)
images = []
async with aiohttp.ClientSession() as session:
for url in urls:
try:
# Validate URL
if not url.startswith(('http://', 'https://')):
continue
# Download image
async with session.get(url) as response:
if response.status != 200:
continue
# Get content type and file extension
content_type = response.headers.get('Content-Type', '')
if not content_type.startswith('image/'):
continue
ext = mimetypes.guess_extension(content_type) or '.jpg'
# Generate unique filename
filename = f"web_image_{int(time.time())}_{len(images)}{ext}"
# Save the file
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
os.makedirs(lora_folder, exist_ok=True)
file_path = os.path.join(lora_folder, filename)
with open(file_path, 'wb') as f:
f.write(await response.read())
images.append({
"url": f"/lora_sidebar/custom_image/{lora_id}/{filename}",
"type": "image",
"nsfwLevel": 0,
"hasMeta": True,
"custom": True,
"name": filename,
"source_url": url
})
except Exception as e:
logger.error(f"Error downloading image from {url}: {str(e)}")
continue
else:
# Handle multipart form data (existing local file upload code)
reader = await request.multipart()
lora_id = None
images = []
while True:
part = await reader.next()
if part is None:
break
if part.name == 'lora_id':
lora_id = (await part.read()).decode()
elif part.name == 'files[]':
file_data = await part.read()
filename = part.filename
if not filename:
continue
if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
continue
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
os.makedirs(lora_folder, exist_ok=True)
file_path = os.path.join(lora_folder, filename)
with open(file_path, 'wb') as f:
f.write(file_data)
images.append({
"url": f"/lora_sidebar/custom_image/{lora_id}/{filename}",
"type": "image",
"nsfwLevel": 0,
"hasMeta": True,
"custom": True,
"name": filename
})
if not lora_id or not images:
return web.json_response({
"status": "error",
"message": "No valid images or LoRA ID provided"
}, status=400)
# Update info.json with new images
info_path = os.path.join(LORA_DATA_DIR, lora_id, "info.json")
if os.path.exists(info_path):
with open(info_path, 'r', encoding='utf-8') as f:
info_data = json.load(f)
# Preserve ALL existing images and add new ones
existing_images = info_data.get('images', [])
# Only filter out duplicates if they're custom images with the same name
if images and existing_images:
new_image_names = set(img['name'] for img in images if img.get('custom'))
existing_images = [img for img in existing_images
if not (img.get('custom') and img.get('name') in new_image_names)]
info_data['images'] = existing_images + images
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(info_data, f, indent=4, ensure_ascii=False)
return web.json_response({
"status": "success",
"message": f"Added {len(images)} images",
"images": images
})
except Exception as e:
logger.error(f"Error uploading images: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/open_folder")
async def open_folder(request):
try:
data = await request.json()
folder_path = data.get('path')
if not folder_path or not os.path.exists(folder_path):
return web.json_response({
"status": "error",
"message": "Invalid or non-existent path"
}, status=400)
try:
# Windows
if os.name == 'nt':
os.startfile(folder_path)
# macOS and Linux/Unix-like systems
else:
if sys.platform == 'darwin': # macOS
subprocess.Popen(['open', folder_path])
else: # Linux/Unix
subprocess.Popen(['xdg-open', folder_path])
return web.json_response({"status": "success"})
except Exception as e:
logger.error(f"Error opening folder: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
except Exception as e:
logger.error(f"Error processing request: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/delete_lora")
async def delete_lora(request):
data = await request.json()
lora_id = data.get('id')
if not lora_id:
return web.json_response({"status": "error", "message": "No LoRA ID provided"}, status=400)
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
try:
# Remove the LoRA folder
if os.path.exists(lora_folder):
shutil.rmtree(lora_folder)
# Update processed_loras.json
if os.path.exists(processed_loras_file):
with open(processed_loras_file, 'r', encoding="utf-8") as f:
processed_loras = json.load(f)
# Remove from processed list using the new format
if 'loras' in processed_loras:
processed_loras['loras'] = [
lora for lora in processed_loras['loras']
if lora.get('filename') != lora_id
]
if 'favorites' in processed_loras:
processed_loras['favorites'] = [f for f in processed_loras['favorites'] if f != lora_id]
# Write updated data back to file
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
logger.info(f"Successfully deleted LoRA: {lora_id}")
return web.json_response({"status": "success", "message": f"LoRA {lora_id} deleted successfully"})
except Exception as e:
logger.error(f"Error deleting LoRA {lora_id}: {str(e)}")
return web.json_response({"status": "error", "message": f"Failed to delete LoRA: {str(e)}"}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/set_version")
async def set_processed_version(request):
try:
# Get the version we should set (true = 1, false = current version)
data = await request.json()
use_old_version = data.get('use_old_version', False)
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
# Initialize with default structure if file doesn't exist
if not os.path.exists(processed_loras_file):
processed_loras = {
"version": PROCESSED_LORAS_VERSION if not use_old_version else 1,
"loras": [],
"favorites": []
}
else:
# Read existing data
with open(processed_loras_file, 'r', encoding='utf-8') as f:
processed_loras = json.load(f)
# Only update the version field
processed_loras['version'] = 1 if use_old_version else PROCESSED_LORAS_VERSION
# Write back the modified data
with open(processed_loras_file, 'w', encoding='utf-8') as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
return web.json_response({
"status": "success",
"version": processed_loras['version']
})
except Exception as e:
logger.error(f"Error setting processed version: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
#####
##### DATA REWRITE DANERGOUS STUFF #####
#####
def get_user_settings(request):
"""Get user settings using AppSettings."""
try:
# Get settings from app_settings using the user manager
settings = PromptServer.instance.user_manager.settings.get_settings(request)
return {
'sortMethod': settings.get("LoRA Sidebar.General.sortMethod", 'AlphaAsc'),
'sortModels': settings.get("LoRA Sidebar.General.sortModels", 'None'),
'tagSource': settings.get("LoRA Sidebar.General.tagSource", 'CivitAI'),
'customTags': settings.get("LoRA Sidebar.General.customTags", "").split(','),
'catNew': settings.get("LoRA Sidebar.General.catNew", True),
'nsfwFolder': settings.get("LoRA Sidebar.NSFW.nsfwFolder", True),
'nsfwString': settings.get("LoRA Sidebar.NSFW.folderString", 'NSFW')
}
except Exception as e:
logger.error(f"Error getting user settings: {str(e)}")
# Fallback to defaults
return {
'sortMethod': 'AlphaAsc',
'sortModels': 'None',
'tagSource': 'CivitAI',
'customTags': [],
'catNew': True,
'nsfwFolder': True,
'nsfwString': 'NSFW'
}
async def sort_loras_with_categories(loras, settings, favorites, sort_metadata):
"""
Process loras with their real categories and status flags.
"""
for lora in loras:
# Set status flags
lora['favorite'] = lora['id'] in favorites
# auto data handling so people don't have to run updates on all their previous loras
creation_date = None
needs_update = False
# Try metadata date first
if lora.get('createdDate') and lora['createdDate'] != 'unknown':
try:
creation_date = datetime.strptime(lora['createdDate'], '%Y-%m-%d')
# logger.debug(f"LoRA {lora.get('name', lora['id'])} using metadata date: {creation_date}")
except ValueError:
logger.debug(f"Invalid metadata date for {lora.get('name', lora['id'])}")
creation_date = None
# If no valid metadata date, try sort_metadata cache first as it's faster
if not creation_date and sort_metadata and lora['id'] in sort_metadata:
creation_date = datetime.fromtimestamp(sort_metadata[lora['id']]['ctime'])
# logger.debug(f"Using sort_metadata date for {lora['id']}: {creation_date}")
# If still no date, check LoRA folder creation time
if not creation_date:
lora_folder = os.path.join(LORA_DATA_DIR, lora['id'])
try:
folder_stat = os.stat(lora_folder)
folder_date = datetime.fromtimestamp(folder_stat.st_ctime)
creation_date = folder_date
needs_update = True
# logger.debug(f"Using folder creation date for {lora['id']}: {creation_date}")
except Exception as e:
logger.error(f"Error getting folder date for {lora['id']}: {str(e)}")
# Update info.json if we found a folder date
if needs_update and creation_date:
info_path = os.path.join(LORA_DATA_DIR, lora['id'], "info.json")
try:
with open(info_path, 'r', encoding='utf-8') as f:
info_data = json.load(f)
info_data['createdDate'] = creation_date.strftime('%Y-%m-%d')
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(info_data, f, indent=4, ensure_ascii=False)
logger.info(f"Updated {lora['id']} info.json with folder date: {creation_date}")
except Exception as e:
logger.error(f"Error updating info.json date for {lora['id']}: {str(e)}")
# Store final timestamp and calculate new status
lora['created_time'] = creation_date.timestamp() if creation_date else FALLBACK_TIMESTAMP
hours_ago = datetime.now() - timedelta(hours=NEW_ITEM_HOURS)
lora['is_new'] = creation_date >= hours_ago if creation_date else False
# Assign real category
sort_models = settings.get('sortModels', 'All LoRAs')
if sort_models == 'Tags':
category = 'Unsorted'
if lora.get('tags', []):
for tag in get_tag_categories(settings):
if tag in lora['tags']:
category = tag
break
lora['category'] = category
elif sort_models == 'Subdir':
lora['category'] = lora.get('subdir', '').split('\\')[-1] or 'Unsorted'
else:
lora['category'] = 'All LoRAs'
# Define sort key function
def sort_key(lora):
if settings['sortMethod'] == 'AlphaAsc':
name = lora.get('name') or lora.get('filename') or 'zzz'
return name.lower()
elif settings['sortMethod'] == 'AlphaDesc':
name = lora.get('name') or lora.get('filename') or '___'
return -ord(name[0].lower())
elif settings['sortMethod'] == 'DateNewest':
date_str = lora.get('createdDate', '1970-01-01')
if date_str in ('unknown', '1970-01-01'):
return FALLBACK_TIMESTAMP
# Common date formats
formats = [
'%Y-%m-%dT%H:%M:%S.%f%z', # 2024-10-16T01:33:25.4734839+00:00
'%Y-%m-%dT%H:%M:%S.%f', # 2024-10-16T01:33:25.473483
'%Y-%m-%d', # 2023-12-25
'%Y/%m/%d', # 2023/12/25
'%d-%m-%Y', # 25-12-2023
'%d/%m/%Y', # 25/12/2023
'%m/%d/%Y', # 12/25/2023 (US format)
'%Y-%m-%d %H:%M:%S', # 2023-12-25 13:45:30
'%Y-%m-%dT%H:%M:%S', # 2023-12-25T13:45:30
'%Y-%m-%dT%H:%M:%SZ', # 2023-12-25T13:45:30Z
'%d-%b-%Y', # 25-Dec-2023
'%d %b %Y', # 25 Dec 2023
'%Y%m%d' # 20231225
]
for fmt in formats:
try:
timestamp = datetime.strptime(date_str, fmt).timestamp()
return -timestamp
except (ValueError, OSError):
continue
logger.error(f"Could not parse date for LoRA {lora.get('id', 'Unknown ID')}: {date_str}")
return FALLBACK_TIMESTAMP
else: # DateOldest
date_str = lora.get('createdDate', '1970-01-01')
if date_str in ('unknown', '1970-01-01'):
return -FALLBACK_TIMESTAMP
for fmt in formats:
try:
timestamp = datetime.strptime(date_str, fmt).timestamp()
return timestamp
except (ValueError, OSError):
continue
logger.error(f"Could not parse date for LoRA {lora.get('id', 'Unknown ID')}: {date_str}")
return -FALLBACK_TIMESTAMP
# Sort based on settings
loras = sorted(loras, key=sort_key)
# Order items for initial data packet (favorites/new first)
favorites_list = [lora for lora in loras if lora['favorite']]
new_items = []
if settings['catNew']:
new_items = [
lora for lora in loras
if not lora['favorite'] and lora['is_new']
]
remaining_loras = [
lora for lora in loras
if not lora['favorite'] and
(not lora['is_new'] or not settings['catNew'])
]
return favorites_list + new_items + remaining_loras
def show_build_progress(current, total, prefix='\033[1;34m[LoRA Sidebar]:\033[0m Building LoRA cache', width=50):
"""
Show a colorized progress bar with item count and percentage.
"""
try:
percent = float(current) * 100 / total
filled = int(width * current / total)
bar = (f"{ANSI_COLORS['CYAN']}{'█' * filled}"
f"{ANSI_COLORS['BLUE']}{'▒' * (width - filled)}"
f"{ANSI_COLORS['ENDC']}")
status = (f"\r{ANSI_COLORS['BOLD']}{prefix}{ANSI_COLORS['ENDC']} "
f"|{bar}| "
f"{ANSI_COLORS['GREEN']}{percent:>5.1f}%{ANSI_COLORS['ENDC']} "
f"({current}/{total})")
print(status, end='')
if current == total:
print(f"\n\033[1;34m[LoRA Sidebar]:\033[0m {ANSI_COLORS['GREEN']}Cache build complete!{ANSI_COLORS['ENDC']}")
except Exception:
# Fallback to basic progress in case of any issues with colors
print(f"\rBuilding cache: {current}/{total}", end='')
async def build_initial_cache():
"""
Build the initial LoRA cache on startup using processed_loras.json.
"""
logger.info("Starting initial LoRA cache build...")
start_time = datetime.now()
total_loras = 0
PLUGIN_PREFIX = "\033[1;34m[LoRA Sidebar]:\033[0m "
try:
# Load processed_loras.json first
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
if not os.path.exists(processed_loras_file):
logger.warning("No processed_loras.json found - cache will be built on first /data request")
print(f"\n{PLUGIN_PREFIX}{ANSI_COLORS['YELLOW']}{get_completion_message(0)}{ANSI_COLORS['ENDC']}")
return
with open(processed_loras_file, "r", encoding="utf-8") as f:
try:
processed_loras = json.load(f)
if not isinstance(processed_loras, dict):
logger.warning("Invalid processed_loras.json format")
return
except json.JSONDecodeError:
logger.error("Error reading processed_loras.json")
return
# Get favorites list
favorites = processed_loras.get('favorites', [])
lora_entries = processed_loras.get('loras', [])
if not lora_entries:
logger.warning("No processed LoRAs found")
print(f"\n{PLUGIN_PREFIX}{ANSI_COLORS['YELLOW']}{get_completion_message(0)}{ANSI_COLORS['ENDC']}")
return
total_loras = len(lora_entries)
processed = 0
lora_data = []
# Process each LoRA from processed_loras.json
for lora_entry in lora_entries:
base_filename = lora_entry.get('filename')
if not base_filename:
continue
info_file = os.path.join(LORA_DATA_DIR, base_filename, "info.json")
if os.path.exists(info_file):
with open(info_file, "r", encoding="utf-8") as f:
try:
data = json.load(f)
data['id'] = base_filename
data['favorite'] = base_filename in favorites
data['filename'] = base_filename
data['path'] = lora_entry.get('path', '')
lora_data.append(data)
except json.JSONDecodeError:
logger.error(f"Error reading {info_file}")
processed += 1
show_build_progress(processed, total_loras)
# Sort and store in cache
if lora_data:
# Process NSFW folder flags before sorting
if CACHE_SETTINGS.get('nsfwFolder', True):
nsfw_string = CACHE_SETTINGS.get('nsfwString', 'NSFW').lower()
logger.info(f"Setting NSFW string {nsfw_string}")
for lora in lora_data:
if lora.get('path'):
path_lower = lora['path'].lower()
if nsfw_string and nsfw_string in path_lower:
logger.info(f"Checking NSFW path for {lora.get('name')}: {path_lower}")
# Check only the path components, excluding the filename
path_parts = os.path.dirname(path_lower).split(os.sep)
if any(nsfw_string in part for part in path_parts):
logger.info(f"Setting NSFW flag for {lora.get('name', 'Unknown')} due to path: {path_parts}")
lora['nsfw'] = True
lora['nsfwLevel'] = 100
sort_metadata = await get_lora_sort_metadata()
LORA_CACHE['ordered_loras'] = await sort_loras_with_categories(
lora_data, CACHE_SETTINGS, favorites, sort_metadata
)
LORA_CACHE['sent_loras'] = set() # Reset sent tracking
# Calculate initial category counts
manage_category_counts("calculate",
loras=LORA_CACHE['ordered_loras'],
settings=CACHE_SETTINGS
)
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
logger.info(f"LoRA cache built successfully in {duration:.2f} seconds")
print(f"{PLUGIN_PREFIX}LoRA cache built in{ANSI_COLORS['CYAN']} {duration:.2f} seconds{ANSI_COLORS['ENDC']}")
print(f"{PLUGIN_PREFIX}Total LoRAs processed:{ANSI_COLORS['BOLD']} {total_loras}{ANSI_COLORS['ENDC']}")
print(f"{PLUGIN_PREFIX}{ANSI_COLORS['BOLD']}{ANSI_COLORS['YELLOW']}{get_completion_message(total_loras)}{ANSI_COLORS['ENDC']}\n")
except Exception as e:
logger.error(f"Error building initial cache: {str(e)}")
print(f"\n{ANSI_COLORS['RED']}Error building cache: {str(e)}{ANSI_COLORS['ENDC']}")
@PromptServer.instance.routes.get("/lora_sidebar/category/{category_name}")
async def get_category_items(request):
category_name = request.match_info['category_name']
limit = int(request.query.get('limit', 500))
if not LORA_CACHE['ordered_loras']:
return web.json_response({"error": "No LoRA data loaded"}, status=400)
# Get all items for this category
category_items = [
lora for lora in LORA_CACHE['ordered_loras']
if lora['category'] == category_name and not lora.get('favorite', False) and not lora.get('is_new', False)
]
# Check for any unsent items
unsent_items = [
lora for lora in category_items
if lora['id'] not in LORA_CACHE['sent_loras']
]
# Get all category IDs
category_ids = [lora['id'] for lora in category_items]
response_data = {
"category_ids": category_ids,
"total": len(category_items)
}
# If we found any unsent items, include them too
if unsent_items:
items_to_send = unsent_items[:limit]
LORA_CACHE['sent_loras'].update(lora['id'] for lora in items_to_send)
response_data["items"] = items_to_send
response_data["hasMore"] = len(unsent_items) > len(items_to_send)
print(f"Category {category_name}: Found {len(items_to_send)} unsent items")
return web.json_response(response_data)
##### Initial loading stuff
print(f"\n\033[1;34m[LoRA Sidebar]:\033[0m Starting initial cache build...")
# Create and run a new event loop to execute our async cache builder
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_initial_cache())
loop.close()
except Exception as e:
print(f"\033[91m[LoRA Sidebar]: Error building cache: {str(e)}\033[0m")
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]