import sqlite3
import threading
import urllib
import requests
import hashlib
import json
import os
import re
from collections import Counter
import folder_paths
import time
import statistics
from safetensors import safe_open
from tqdm import tqdm
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
from . import api
try:
import orjson as json_lib
print("[Civitai Toolkit] orjson library found, using for faster JSON operations.")
except ImportError:
import json as json_lib
print("[Civitai Toolkit] orjson not found, falling back to standard json library.")
HASH_CACHE_REFRESH_INTERVAL = 3600
SINGLE_FILE_HASH_TIMEOUT = 90 # 为单个文件哈希设置90秒的超时
SUPPORTED_MODEL_TYPES = { "checkpoints": "checkpoints", "loras": "Lora", "vae": "VAE", "embeddings": "embeddings", "diffusion_models":"diffusion_models", "text_encoders":"text_encoders","hypernetworks": "hypernetworks" }
# =================================================================================
# 1. 核心数据库管理器 (Core Database Manager)
# =================================================================================
class DatabaseManager:
_instance = None
_lock = threading.Lock()
def __new__(cls, *args, **kwargs):
if not cls._instance:
with cls._lock:
if not cls._instance:
cls._instance = super(DatabaseManager, cls).__new__(cls)
return cls._instance
def __init__(self):
if hasattr(self, "_initialized") and self._initialized:
return
project_root = os.path.dirname(os.path.abspath(__file__))
self.db_path = os.path.join(project_root, "data", "civitai_helper.db")
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
self._create_tables()
self._initialized = True
print(f"[Civitai Toolkit] Database initialized at: {self.db_path}")
def get_connection(self):
conn = sqlite3.connect(self.db_path, timeout=10)
conn.row_factory = sqlite3.Row
return conn
def _create_tables(self):
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)"
)
cursor.execute(
"CREATE TABLE IF NOT EXISTS models (model_id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT)"
)
cursor.execute("""
CREATE TABLE IF NOT EXISTS versions (
hash TEXT PRIMARY KEY, version_id INTEGER UNIQUE, model_id INTEGER, model_type TEXT, name TEXT,
local_path TEXT UNIQUE, local_mtime REAL, trained_words TEXT, api_response TEXT, last_api_check INTEGER,
FOREIGN KEY (model_id) REFERENCES models (model_id)
)""")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_versions_model_type ON versions (model_type)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_versions_version_id ON versions (version_id)"
)
cursor.execute("""
CREATE TABLE IF NOT EXISTS images (
image_id INTEGER PRIMARY KEY, version_id INTEGER, url TEXT UNIQUE NOT NULL, meta TEXT, local_filename TEXT,
FOREIGN KEY (version_id) REFERENCES versions (version_id)
)""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_url ON images (url)")
cursor.execute("""
CREATE TABLE IF NOT EXISTS analysis_cache (
fingerprint TEXT PRIMARY KEY,
analysis_data TEXT,
last_updated INTEGER
)""")
def get_setting(self, key, default=None):
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT value FROM settings WHERE key = ?", (key,))
row = cursor.fetchone()
if row and row["value"]:
try:
return json_lib.loads(row["value"])
except Exception:
return row["value"]
return default
def set_setting(self, key, value):
with self.get_connection() as conn:
value_str = (
json_lib.dumps(value).decode("utf-8")
if isinstance(json_lib.dumps(value), bytes)
else json_lib.dumps(value)
)
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value_str),
)
def get_analysis_cache(self, fingerprint):
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT analysis_data FROM analysis_cache WHERE fingerprint = ?",
(fingerprint,),
)
row = cursor.fetchone()
if row and row["analysis_data"]:
return json_lib.loads(row["analysis_data"])
return None
def set_analysis_cache(self, fingerprint, data):
with self.get_connection() as conn:
data_str = (
json_lib.dumps(data).decode("utf-8")
if isinstance(json_lib.dumps(data), bytes)
else json_lib.dumps(data)
)
conn.execute(
"INSERT OR REPLACE INTO analysis_cache (fingerprint, analysis_data, last_updated) VALUES (?, ?, ?)",
(fingerprint, data_str, int(time.time())),
)
def clear_analysis_cache(self):
with self.get_connection() as conn:
conn.execute("DELETE FROM analysis_cache")
print("[Civitai Toolkit] Analysis cache cleared.")
def clear_api_responses(self):
with self.get_connection() as conn:
conn.execute("UPDATE versions SET api_response = NULL, last_api_check = 0")
print("[Civitai Toolkit] All API response caches cleared.")
def clear_all_triggers(self):
with self.get_connection() as conn:
conn.execute("UPDATE versions SET trained_words = NULL")
print("[Civitai Toolkit] All trigger word caches cleared.")
def get_version_by_hash(self, file_hash):
if not file_hash:
return None
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM versions WHERE hash = ?", (file_hash.lower(),)
)
return cursor.fetchone()
def get_version_by_id(self, version_id):
if not version_id:
return None
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM versions WHERE version_id = ?", (version_id,))
return cursor.fetchone()
def get_model_by_id(self, model_id):
if not model_id:
return None
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM models WHERE model_id = ?", (model_id,))
return cursor.fetchone()
def get_image_by_url(self, url):
if not url:
return None
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM images WHERE url = ?", (url,))
return cursor.fetchone()
def add_or_update_version_from_api(self, data, original_hash=None):
"""
从API数据更新数据库,并精确处理多文件版本。
"""
model_id = data.get("modelId") or data.get("model", {}).get("id")
version_id = data.get("id")
if not version_id or not model_id:
print(f"[DB Manager] Error: Missing version_id({version_id}) or model_id({model_id}). Aborting.")
return
files = data.get("files", [])
if not files:
return
target_file_info = None
if original_hash:
for f in files:
if f.get("hashes", {}).get("SHA256", "").lower() == original_hash.lower():
target_file_info = f
break
# 如果找不到匹配的哈希(例如API重定向到其他版本),则默认使用主文件
if not target_file_info:
target_file_info = next((f for f in files if f.get("primary")), files[0])
file_hash = target_file_info.get("hashes", {}).get("SHA256")
if not file_hash:
return
file_hash = file_hash.lower()
def robust_dumps(data_obj):
try:
return json_lib.dumps(data_obj, ensure_ascii=False)
except TypeError:
return json_lib.dumps(data_obj)
api_response_str = robust_dumps(data)
trained_words_str = robust_dumps(data.get("trainedWords", []))
with self.get_connection() as conn:
# 在写入前,删除任何使用相同 version_id 但 hash 不同的旧记录,避免 UNIQUE 冲突
conn.execute(
"DELETE FROM versions WHERE version_id = ? AND hash != ?",
(version_id, file_hash),
)
# 使用更可靠的model数据源
model_data = data.get("model", {})
conn.execute(
"""
INSERT INTO models (model_id, name, type) VALUES (?, ?, ?)
ON CONFLICT(model_id) DO UPDATE SET name = excluded.name, type = excluded.type
""",
(model_id, model_data.get("name"), model_data.get("type")),
)
conn.execute(
"""
INSERT INTO versions (hash, version_id, model_id, name, trained_words, api_response, last_api_check)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(hash) DO UPDATE SET
version_id = excluded.version_id,
model_id = excluded.model_id,
name = excluded.name,
trained_words = excluded.trained_words,
api_response = excluded.api_response,
last_api_check = excluded.last_api_check
""",
(
file_hash,
version_id,
model_id,
data.get("name"),
trained_words_str,
api_response_str,
int(time.time()),
),
)
def add_downloaded_image(self, url, local_filename, version_id=None, meta=None):
with self.get_connection() as conn:
meta_str = (
json_lib.dumps(meta).decode("utf-8")
if meta and isinstance(json_lib.dumps(meta), bytes)
else json_lib.dumps(meta)
)
conn.execute(
"""
INSERT INTO images (url, local_filename, version_id, meta) VALUES (?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET local_filename = excluded.local_filename,
version_id = COALESCE(excluded.version_id, version_id), meta = COALESCE(excluded.meta, meta)
""",
(url, local_filename, version_id, meta_str),
)
def get_db_stats(self):
stats = {}
with self.get_connection() as conn:
cursor = conn.cursor()
# 统计各类模型的数量
for model_type in ["checkpoints", "loras"]:
cursor.execute(
"SELECT COUNT(*) FROM versions WHERE model_type = ? AND local_path IS NOT NULL",
(model_type,),
)
count = cursor.fetchone()[0]
stats[model_type] = count
return stats
def get_scanned_models(self, model_type):
"""从数据库中获取指定类型的所有模型相对路径列表"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT local_path FROM versions WHERE model_type = ? AND local_path IS NOT NULL ORDER BY local_path ASC",
(model_type,),
)
rows = cursor.fetchall()
known_relative_paths = folder_paths.get_filename_list(model_type)
full_path_map = {
os.path.normpath(folder_paths.get_full_path(model_type, f)): f
for f in known_relative_paths
}
db_relative_paths = []
for row in rows:
full_path = os.path.normpath(row["local_path"])
relative_path = full_path_map.get(full_path)
if relative_path:
db_relative_paths.append(relative_path)
return db_relative_paths
def mark_hash_as_not_found(self, file_hash):
"""为未在Civitai上找到的哈希存入一个空标记,避免重复查询。"""
with self.get_connection() as conn:
# 存入一个空的JSON对象作为标记
empty_response = json_lib.dumps({})
if isinstance(empty_response, bytes):
empty_response = empty_response.decode("utf-8")
conn.execute(
"UPDATE versions SET api_response = ?, last_api_check = ? WHERE hash = ?",
(empty_response, int(time.time()), file_hash.lower()),
)
def get_version_by_path(self, local_path):
"""通过绝对路径从数据库获取版本信息"""
if not local_path:
return None
# 规范化路径以确保跨平台匹配
norm_path = os.path.normpath(local_path)
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT v.*, m.name AS model_name, v.name as version_name
FROM versions v
LEFT JOIN models m ON v.model_id = m.model_id
WHERE v.local_path = ?
""",
(norm_path,),
)
return cursor.fetchone()
db_manager = DatabaseManager()
# =================================================================================
# 2. 配置与全局函数
# =================================================================================
def _get_active_domain() -> str:
network_choice = db_manager.get_setting("network_choice", "com")
return "civitai.work" if network_choice == "work" else "civitai.com"
def load_selections():
return db_manager.get_setting("selections", {})
def save_selections(data):
db_manager.set_setting("selections", data)
SAMPLER_SCHEDULER_MAP = {
"Euler a": "euler_ancestral",
"Euler": "euler",
"LMS": "lms",
"Heun": "heun",
"DPM2": "dpm_2",
"DPM2 a": "dpm_2_ancestral",
"DPM++ 2S a": "dpmpp_2s_ancestral",
"DPM++ 2M": "dpmpp_2m",
"DPM++ SDE": "dpmpp_sde",
"DPM++ 2M SDE": "dpmpp_2m_sde",
"DPM fast": "dpm_fast",
"DPM adaptive": "dpm_adaptive",
"DDIM": "ddim",
"PLMS": "plms",
"UniPC": "uni_pc",
"normal": "normal",
"karras": "karras",
"Karras": "karras",
"exponential": "exponential",
"sgm_uniform": "sgm_uniform",
"simple": "simple",
"ddim_uniform": "ddim_uniform",
"turbo": "turbo",
}
# =================================================================================
# 3. Civitai API & 本地文件核心工具
# =================================================================================
class CivitaiAPIUtils:
@staticmethod
def _request_with_retry(url, params=None, timeout=15, retries=3, delay=5):
# 伪装成一个普通的 Windows Chrome 浏览器
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36"
}
# 从数据库读取 API Key
api_key = db_manager.get_setting("civitai_api_key")
if api_key and isinstance(api_key, str):
headers['Authorization'] = f'Bearer {api_key}'
for i in range(retries + 1):
try:
if params:
response = requests.get(
url, params=params, timeout=timeout, headers=headers
)
else:
response = requests.get(url, timeout=timeout, headers=headers)
response.raise_for_status()
return response
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
print(f"[Civitai Toolkit] API rate limit hit. Waiting for {delay} seconds before retrying...")
time.sleep(delay)
delay *= 2
else:
raise e
except requests.exceptions.RequestException as e:
print(f"[Civitai Toolkit] Network error: {e}. Retrying in {delay} seconds...")
time.sleep(delay)
raise Exception(f"Failed to fetch data from {url} after {retries} retries.")
@staticmethod
def calculate_sha256(file_path):
print(f"[Civitai Toolkit] Calculating SHA256 for: {os.path.basename(file_path)}...")
sha256_hash = hashlib.sha256()
block_size = 1 << 20 # 1MB chunk size
try:
with open(file_path, "rb") as f:
while chunk := f.read(block_size):
sha256_hash.update(chunk)
return sha256_hash.hexdigest()
except Exception as e:
print(f"[Civitai Toolkit] Error calculating hash for {file_path}: {e}")
return None
@classmethod
def get_model_version_info_by_id(cls, version_id, domain, force_refresh=False):
if not version_id:
return None
if not force_refresh:
version = db_manager.get_version_by_id(version_id)
if version and version["api_response"]:
return json_lib.loads(version["api_response"])
url = f"https://{domain}/api/v1/model-versions/{version_id}"
try:
resp = cls._request_with_retry(url)
data = resp.json()
if data:
db_manager.add_or_update_version_from_api(data)
return data
except Exception as e:
print(f"[Civitai Toolkit] API Error (ID {version_id}): {e}")
return None
@classmethod
def get_model_info_by_id(cls, model_id, domain):
"""根据模型ID获取最详细的模型主页信息"""
if not model_id:
return None
url = f"https://{domain}/api/v1/models/{model_id}"
print(
f"[Civitai Toolkit] Step 2 Fetch: Getting full model details for ID: {model_id}"
)
try:
resp = cls._request_with_retry(url)
return resp.json()
except Exception as e:
print(f"[Civitai Toolkit] API Error fetching model by ID {model_id}: {e}")
return None
@classmethod
def get_model_version_info_by_hash(cls, sha256_hash, force_refresh=False, more_info=False):
"""
根据模型文件的 SHA256 哈希值获取对应的模型版本信息,并尝试与完整模型信息进行智能合并。
此方法首先尝试从缓存中获取数据,如果缓存未命中或强制刷新,则通过 Civitai API 获取模型版本信息,
并进一步获取完整的模型信息进行合并,保留版本描述和模型主页描述。
Parameters:
sha256_hash (str): 模型文件的 SHA256 哈希值,用于唯一标识模型版本。
force_refresh (bool): 是否跳过缓存直接请求 API,默认为 False。
more_info (bool): 是否获取并合并完整模型信息,默认为 False。
Returns:
dict or None: 包含模型版本信息的字典,若未找到或出错则返回 None。
"""
if not sha256_hash:
return None
sha256_hash = sha256_hash.lower()
# 尝试从数据库缓存中获取版本信息
if not force_refresh:
version_entry = db_manager.get_version_by_hash(sha256_hash)
if version_entry and version_entry["api_response"] is not None:
try:
cached_data = json_lib.loads(version_entry["api_response"])
if cached_data == {}:
return None
print(
f"[Civitai Toolkit] Using cache for hash: {sha256_hash[:12]}"
)
return cached_data
except Exception:
pass
domain = _get_active_domain()
try:
# 第一步:通过哈希获取模型版本信息
url_by_hash = (
f"https://{domain}/api/v1/model-versions/by-hash/{sha256_hash}"
)
print(
f"[Civitai Toolkit] Step 1 Fetch: Getting version info for hash: {sha256_hash[:12]}..."
)
resp_version = cls._request_with_retry(url_by_hash)
version_data = resp_version.json()
if not version_data or not version_data.get("id"):
db_manager.mark_hash_as_not_found(sha256_hash)
return None
final_data = version_data
model_id = version_data.get("modelId")
merge_successful = False
# 第二步:如果存在 modelId,尝试获取完整模型信息并合并
if more_info:
if model_id:
full_model_data = cls.get_model_info_by_id(model_id, domain)
if full_model_data:
print(f"[Civitai Toolkit] Step 2 Success: Merging data for model ID: {model_id}")
# 保留版本描述和模型主页描述
final_data["version_description"] = final_data.pop("description", "")
final_data["model_description"] = full_model_data.get("description", "")
# 替换为完整模型对象(包含 tags 等信息)
final_data["model"] = full_model_data
merge_successful = True
# 如果合并成功或无 modelId,将结果缓存到数据库
if merge_successful or not model_id:
db_manager.add_or_update_version_from_api(final_data, original_hash=sha256_hash)
else:
print(f"[Civitai Toolkit] Merge failed for model ID {model_id}, API response will not be cached to allow retries.")
return final_data
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"[Civitai Toolkit] Hash not found on Civitai: {sha256_hash[:12]}.")
db_manager.mark_hash_as_not_found(sha256_hash)
else:
print(f"[Civitai Toolkit] API HTTP Error (hash {sha256_hash[:12]}): {e}")
return None
except Exception as e:
import traceback
traceback.print_exc()
print(f"[Civitai Toolkit] General Error on API call (hash {sha256_hash[:12]}): {e}")
return None
@classmethod
def get_civitai_info_from_hash(cls, model_hash):
try:
data = CivitaiAPIUtils.get_model_version_info_by_hash(model_hash)
if data and data.get("modelId"):
domain = _get_active_domain()
model_id, model_name = (
data.get("modelId"),
data.get("model", {}).get("name", "Unknown Name"),
)
return {
"name": model_name,
"url": f"https://{domain}/models/{model_id}",
}
except Exception as e:
print(
f"[CivitaiRecipeFinder] Could not fetch info for hash {model_hash[:12]}: {e}"
)
return None
@staticmethod
def _parse_prompts(prompt_text: str):
if not isinstance(prompt_text, str) or not prompt_text.strip():
return []
pattern = re.compile(r"\(.+?:\d+\.\d+\)|<[^>]+>|\[[^\]]+\]|\([^)]+\)|[^,]+")
tags = pattern.findall(prompt_text)
return [tag.strip() for tag in tags if tag.strip()]
def scan_all_supported_model_types(force=False):
"""遍历所有支持的模型类型并与数据库同步"""
print("[Civitai Toolkit] Starting scan for all supported model types...")
# The keys of SUPPORTED_MODEL_TYPES are what folder_paths uses (e.g., "checkpoints", "loras")
for model_type in SUPPORTED_MODEL_TYPES.keys():
try:
if folder_paths.get_filename_list(model_type) is not None:
sync_local_files_with_db(model_type, force=force)
else:
print(
f"[Civitai Toolkit] Skipping scan for '{model_type}', directory not found."
)
except Exception as e:
print(
f"[Civitai Toolkit] Skipping scan for '{model_type}', directory not configured or error occurred: {e}"
)
def update_hash_in_db(file_info):
"""
一个线程安全的函数,用于将单个文件的哈希结果写入数据库。
它在自己的上下文中获取数据库连接。
"""
if not file_info or not file_info.get("hash"):
return
try:
with db_manager.get_connection() as conn:
conn.execute(
"UPDATE versions SET local_path = NULL, local_mtime = NULL WHERE local_path = ?",
(file_info["path"],),
)
conn.execute(
"""
INSERT INTO versions (hash, local_path, local_mtime, name, model_type) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(hash) DO UPDATE SET
local_path = excluded.local_path,
local_mtime = excluded.local_mtime,
model_type = excluded.model_type
""",
(file_info["hash"].lower(), file_info["path"], file_info["mtime"], os.path.basename(file_info["path"]), file_info["model_type"]),
)
conn.commit()
return True
except Exception as e:
print(f"\n[Civitai Toolkit] Database write error for {os.path.basename(file_info['path'])}: {e}")
return False
def sync_local_files_with_db(model_type: str, force=False):
if model_type not in SUPPORTED_MODEL_TYPES:
return {"new": 0, "modified": 0, "hashed": 0}
# 当 force=False 时,使用时间间隔缓存避免不必要的重复扫描
last_sync_key = f"last_sync_{model_type}"
last_sync_time = db_manager.get_setting(last_sync_key, 0)
if not force and time.time() - last_sync_time < HASH_CACHE_REFRESH_INTERVAL:
return {"skipped": True}
print(f"[Civitai Toolkit] Performing smart sync for local {model_type}...")
local_files_on_disk = folder_paths.get_filename_list(model_type)
with db_manager.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT local_path, local_mtime FROM versions WHERE model_type = ?", (model_type,))
db_files = {
os.path.normcase(os.path.normpath(row["local_path"])): row["local_mtime"]
for row in cursor.fetchall() if row["local_path"]
}
files_to_hash = []
for relative_path in local_files_on_disk:
full_path = folder_paths.get_full_path(model_type, relative_path)
if not full_path or not os.path.exists(full_path) or os.path.isdir(full_path):
continue
norm_full_path = os.path.normcase(os.path.normpath(full_path))
try:
mtime = os.path.getmtime(norm_full_path)
# 关键逻辑:文件是全新的,或者修改时间不一致时,才需要哈希
if norm_full_path not in db_files or db_files[norm_full_path] != mtime:
files_to_hash.append({"path": full_path, "mtime": mtime})
except Exception as e:
print(f"[Civitai Toolkit] Warning: Could not process file {relative_path}: {e}")
if not files_to_hash:
db_manager.set_setting(last_sync_key, time.time())
print(f"[Civitai Toolkit] Smart sync for {model_type} complete. No new or modified files found.")
return {"found": 0, "hashed": 0}
print(f"[Civitai Toolkit] Found {len(files_to_hash)} new/modified {model_type} files. Hashing now...")
def hash_worker(file_info):
return {**file_info, "hash": CivitaiAPIUtils.calculate_sha256(file_info["path"])}
hashed_count = 0
with ThreadPoolExecutor(max_workers=(os.cpu_count()/2 or 4)) as executor:
# 创建所有哈希计算的future
futures = {executor.submit(hash_worker, f): f for f in files_to_hash}
for future in tqdm(as_completed(futures), total=len(files_to_hash), desc=f"Hashing {model_type}"):
try:
res = future.result(timeout=SINGLE_FILE_HASH_TIMEOUT)
if res and res.get("hash"):
res['model_type'] = model_type
if update_hash_in_db(res):
hashed_count += 1
except TimeoutError:
failed_file_info = futures[future]
print(f"\n[Civitai Toolkit] Hashing timed out for file: {os.path.basename(failed_file_info['path'])}. Skipping.")
except Exception as e:
failed_file_info = futures[future]
print(f"\n[Civitai Toolkit] Error hashing file {os.path.basename(failed_file_info['path'])}: {e}. Skipping.")
db_manager.set_setting(last_sync_key, time.time())
print(f"[Civitai Toolkit] Smart sync for {model_type} complete. Hashed {hashed_count} files.")
return {"found": len(files_to_hash), "hashed": hashed_count}
def get_local_model_maps(model_type: str, force_sync=False):
sync_local_files_with_db(model_type, force=force_sync)
# 1. 从数据库获取所有已知文件的绝对路径 -> 哈希映射
with db_manager.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT hash, local_path FROM versions WHERE hash IS NOT NULL AND local_path IS NOT NULL AND model_type = ?",
(model_type,),
)
rows = cursor.fetchall()
abs_path_to_hash = {
os.path.normpath(row["local_path"]): row["hash"] for row in rows
}
# 2. 获取ComfyUI认可的相对路径列表
known_relative_paths = folder_paths.get_filename_list(model_type)
hash_to_filename = {}
filename_to_hash = {}
# 3. 遍历列表,构建新的映射
for relative_path in known_relative_paths:
full_path = os.path.normpath(
folder_paths.get_full_path(model_type, relative_path)
)
# 从我们的数据库中查找这个文件的哈希
file_hash = abs_path_to_hash.get(full_path)
if file_hash:
hash_to_filename[file_hash] = relative_path
filename_to_hash[relative_path] = file_hash
return hash_to_filename, filename_to_hash
def get_model_filenames_from_db_cached_only(model_type: str):
"""
一个绝对安全的函数,只从数据库缓存中读取模型列表,绝不触发扫描。
专门用于UI加载,确保启动速度。
如果数据库为空,则回退到显示文件夹中的所有模型。
"""
with db_manager.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT local_path FROM versions WHERE model_type = ? AND local_path IS NOT NULL ORDER BY local_path ASC",
(model_type,),
)
rows = cursor.fetchall()
known_relative_paths = folder_paths.get_filename_list(model_type)
if not known_relative_paths:
return []
full_path_map = {
os.path.normpath(folder_paths.get_full_path(model_type, f)): f
for f in known_relative_paths
}
db_relative_paths = []
for row in rows:
full_path = os.path.normpath(row["local_path"])
relative_path = full_path_map.get(full_path)
if relative_path:
db_relative_paths.append(relative_path)
if not db_relative_paths:
print(f"[Civitai Toolkit] No DB entries for {model_type}, showing all models from folder_paths")
return sorted(list(set(known_relative_paths)))
return sorted(list(set(db_relative_paths)))
def get_model_filenames_from_db(model_type: str, force_sync=False):
"""
这是获取模型列表的权威函数。
它确保数据库已同步,然后基于数据库内容构建列表,
并与ComfyUI的已知路径交叉引用以确保准确性。
"""
sync_local_files_with_db(model_type, force=force_sync)
with db_manager.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT local_path FROM versions WHERE model_type = ? AND local_path IS NOT NULL ORDER BY local_path ASC",
(model_type,),
)
rows = cursor.fetchall()
known_relative_paths = folder_paths.get_filename_list(model_type)
full_path_map = {
os.path.normpath(folder_paths.get_full_path(model_type, f)): f
for f in known_relative_paths
}
db_relative_paths = []
for row in rows:
full_path = os.path.normpath(row["local_path"])
relative_path = full_path_map.get(full_path)
if relative_path:
db_relative_paths.append(relative_path)
return sorted(list(set(db_relative_paths)))
def get_legacy_cache_files():
"""返回所有存在的旧版缓存文件的路径字典"""
project_root = os.path.dirname(__file__)
data_dir = os.path.join(project_root, "data")
files_to_check = {
"checkpoints_format1": os.path.join(data_dir, "hash_cache.json"),
"loras_format2": os.path.join(data_dir, "loras_hash_cache.json"),
}
return {key: path for key, path in files_to_check.items() if os.path.exists(path)}
def check_legacy_cache_exists():
"""检查任何一种旧版缓存文件是否存在"""
return bool(get_legacy_cache_files())
def migrate_legacy_caches():
"""统一的迁移函数,现在精确处理两种指定的旧JSON缓存"""
legacy_files = get_legacy_cache_files()
if not legacy_files:
return {
"migrated": 0,
"skipped": 0,
"message": "No legacy cache files found to migrate.",
}
total_migrated = 0
total_skipped = 0
with db_manager.get_connection() as conn:
# 处理格式1: hash_cache.json (Checkpoints)
if "checkpoints_format1" in legacy_files:
path = legacy_files["checkpoints_format1"]
model_type = "checkpoints"
print(f"Migrating {model_type} from {os.path.basename(path)}...")
with open(path, "r", encoding="utf-8") as f:
hash_data = json.load(f)
for key, hash_value in hash_data.items():
try:
parts = key.split("|")
if len(parts) != 3:
total_skipped += 1
continue
file_path, mtime_str, _ = parts
file_path = os.path.normpath(file_path)
# 直接将此文件中的所有条目视为checkpoints
conn.execute(
"""
INSERT INTO versions (hash, local_path, local_mtime, name, model_type) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(hash) DO UPDATE SET
local_path = excluded.local_path,
local_mtime = excluded.local_mtime,
model_type = excluded.model_type
""",
(
hash_value.lower(),
file_path,
float(mtime_str),
os.path.basename(file_path),
model_type,
),
)
total_migrated += 1
except Exception:
total_skipped += 1
os.rename(path, path + ".migrated")
# 处理格式2: loras_hash_cache.json (LoRAs)
if "loras_format2" in legacy_files:
path = legacy_files["loras_format2"]
model_type = "loras"
print(f"Migrating {model_type} from {os.path.basename(path)}...")
with open(path, "r", encoding="utf-8") as f:
hash_data = json.load(f)
for relative_path, data in hash_data.items():
try:
full_path = folder_paths.get_full_path(model_type, relative_path)
if not full_path or not os.path.exists(full_path):
total_skipped += 1
continue
conn.execute(
"""
INSERT INTO versions (hash, local_path, local_mtime, name, model_type) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(hash) DO UPDATE SET
local_path = excluded.local_path,
local_mtime = excluded.local_mtime,
model_type = excluded.model_type
""",
(
data["hash"].lower(),
os.path.normpath(full_path),
data["mtime"],
os.path.basename(full_path),
model_type,
),
)
total_migrated += 1
except Exception:
total_skipped += 1
os.rename(path, path + ".migrated")
return {
"migrated": total_migrated,
"skipped": total_skipped,
"message": f"Migration complete! Migrated: {total_migrated}, Skipped: {total_skipped}. Old cache files have been renamed to '.migrated'. A restart of ComfyUI is recommended.",
}
def prepare_models_and_get_list(model_type: str, force_sync=True):
sync_local_files_with_db(model_type, force=force_sync)
# 直接使用 folder_paths 作为最可靠的列表来源
return folder_paths.get_filename_list(model_type)
# =================================================================================
# 4. 数据获取与处理
# =================================================================================
def fetch_civitai_data_by_hash(model_hash, sort, limit, nsfw_level, filter_type=None):
version_info = CivitaiAPIUtils.get_model_version_info_by_hash(model_hash)
if not version_info or "id" not in version_info:
raise ValueError(
"Could not find model version ID on Civitai using provided hash."
)
version_id = version_info["id"]
domain = _get_active_domain()
filtered_results, page, API_PAGE_LIMIT = [], 1, 100
with tqdm(total=limit, desc="Fetching Recipes") as pbar:
while len(filtered_results) < limit:
params = {
"modelVersionId": version_id,
"limit": API_PAGE_LIMIT,
"sort": sort,
"nsfw": nsfw_level,
"page": page,
}
api_url = f"https://{domain}/api/v1/images"
try:
response = CivitaiAPIUtils._request_with_retry(api_url, params=params)
items = response.json().get("items", [])
except Exception as e:
print(f"[Civitai Toolkit] Halting fetch due to persistent API error: {e}")
break
if not items:
print("[Civitai Toolkit] Reached the end of available results from API.")
if pbar.n < limit:
pbar.update(limit - pbar.n)
break
items_with_meta = [img for img in items if img.get("meta")]
page_filtered = []
if filter_type == "video":
page_filtered = [
img for img in items_with_meta if img.get("type") == "video"
]
elif filter_type == "image":
page_filtered = [
img for img in items_with_meta if img.get("type") != "video"
]
else:
page_filtered = items_with_meta
filtered_results.extend(page_filtered)
pbar.update(min(len(filtered_results), limit) - pbar.n)
page += 1
if len(filtered_results) >= limit:
break
time.sleep(0.1)
final_results = filtered_results[:limit]
for img in final_results:
db_manager.add_downloaded_image(
url=img["url"],
local_filename=None,
version_id=version_id,
meta=img.get("meta"),
)
return final_results
def extract_resources_from_meta(meta, filename_to_lora_hash_map, session_cache=None):
if not isinstance(meta, dict):
return {"ckpt_hash": None, "ckpt_name": "unknown", "loras": []}
if session_cache is None:
session_cache = {}
ckpt_hash, ck_name = meta.get("Model hash"), meta.get("Model")
loras, vaes, seen_hashes, seen_names = [], [], set(), set()
def add_lora(lora_info):
lora_hash, lora_name = lora_info.get("hash"), lora_info.get("name")
if lora_hash and lora_hash in seen_hashes:
return
if not lora_hash and lora_name and lora_name in seen_names:
return
loras.append(lora_info)
if lora_hash:
seen_hashes.add(lora_hash)
if lora_name:
seen_names.add(lora_name)
if isinstance(meta.get("civitaiResources"), list):
for res in meta["civitaiResources"]:
if not isinstance(res, dict) or not (
version_id := res.get("modelVersionId")
):
continue
cached_resource = session_cache.get(str(version_id))
if not cached_resource:
continue
version_info = cached_resource.get("info")
res_hash = cached_resource.get("hash")
res_type = res.get("type", "").lower()
if version_info and not res_type:
res_type = version_info.get("model", {}).get("type", "").lower()
if res_type == "lora":
add_lora(
{
"hash": res_hash,
"name": res.get("modelVersionName")
or (
version_info.get("model", {}).get("name")
if version_info
else None
),
"weight": safe_float_conversion(res.get("weight")),
"modelVersionId": version_id,
}
)
elif res_type in ["checkpoint", "model"] and not ckpt_hash:
ckpt_hash = res_hash
if res.get("modelVersionName") and not ck_name:
ck_name = res["modelVersionName"]
if isinstance(meta.get("resources"), list):
for res in meta["resources"]:
if isinstance(res, dict) and res.get("type", "").lower() == "lora":
lora_name, lora_hash = res.get("name"), res.get("hash")
if not lora_hash and lora_name:
lora_hash = filename_to_lora_hash_map.get(
lora_name
) or filename_to_lora_hash_map.get(f"{lora_name}.safetensors")
add_lora(
{
"hash": lora_hash,
"name": lora_name,
"weight": safe_float_conversion(res.get("weight")),
}
)
elif (
isinstance(res, dict)
and res.get("type", "").lower() == "model"
and not ckpt_hash
):
ckpt_hash, ck_name = res.get("hash"), res.get("name")
if isinstance(meta.get("hashes"), dict):
is_new_hash_format = any(':' in k for k in meta['hashes'].keys())
if is_new_hash_format:
print("[Civitai Toolkit] Detected new hash format, applying special parsing logic.")
for key, short_hash in meta['hashes'].items():
key_lower = key.lower()
if key_lower.startswith("lora:"):
# 从键名中提取文件名,并清理路径分隔符
lora_filename = key[5:].replace('\\', '/').split('/')[-1]
# 通过文件名反查完整的哈希值
full_hash = filename_to_lora_hash_map.get(lora_filename)
if not full_hash:
print(f"[Civitai Toolkit] Warning: LoRA '{lora_filename}' found in metadata, but not in local file map. Cannot get full hash.")
add_lora({
"hash": full_hash or short_hash, # 优先使用完整的哈希
"name": lora_filename,
"weight": 1.0, # 这种格式没有提供权重,默认为 1.0
})
elif key_lower.startswith("model:"):
ckpt_hash = short_hash
ck_name = key[6:] # 提取模型名
elif key_lower == "model":
if not ckpt_hash:
ckpt_hash = short_hash
elif "vae" in key_lower:
vaes.append({
"hash": short_hash,
"name": key,})
# 保留原有的逻辑以兼容旧格式
elif isinstance(meta["hashes"].get("lora"), dict):
for hash_val, weight in meta["hashes"]["lora"].items():
add_lora(
{
"hash": hash_val,
"name": None,
"weight": safe_float_conversion(weight),
}
)
for i in range(1, 10):
if meta.get(f"AddNet Module {i}") == "LoRA" and f"AddNet Model {i}" in meta:
model_str = meta.get(f"AddNet Model {i}", "")
match = re.search(r"\((\w+)\)", model_str)
if match:
add_lora(
{
"hash": match.group(1),
"name": model_str.split("(")[0].strip(),
"weight": safe_float_conversion(
meta.get(f"AddNet Weight A {i}")
),
}
)
return {"ckpt_hash": ckpt_hash, "ckpt_name": ck_name, "loras": loras, "vaes": vaes}
# =================================================================================
# 5. 格式化与辅助函数 (Formatting & Helper Functions)
# =================================================================================
def get_metadata(filepath, model_type):
filepath = folder_paths.get_full_path(model_type, filepath)
if not filepath:
return None
try:
with open(filepath, "rb") as file:
header_size = int.from_bytes(file.read(8), "little", signed=False)
if header_size <= 0:
return None
header = file.read(header_size)
return json_lib.loads(header).get("__metadata__")
except Exception as e:
print(
f"[Civitai Toolkit] Error reading metadata from {os.path.basename(filepath)}: {e}"
)
return None
def sort_tags_by_frequency(meta_tags):
if not meta_tags or "ss_tag_frequency" not in meta_tags:
return []
try:
tag_freq_json = json_lib.loads(meta_tags["ss_tag_frequency"])
tag_counts = Counter()
for _, dataset in tag_freq_json.items():
for tag, count in dataset.items():
tag_counts[str(tag).strip()] += count
return [tag for tag, _ in tag_counts.most_common()]
except Exception as e:
print(f"[Civitai Toolkit] Error parsing tag frequency: {e}")
return []
def safe_float_conversion(value, default=1.0):
if value is None:
return default
if isinstance(value, (float, int)):
return float(value)
try:
return float(value)
except (ValueError, TypeError):
return default
def get_civitai_triggers(file_name, file_hash, force_refresh):
if force_refresh == "no":
version = db_manager.get_version_by_hash(file_hash)
if version and version["trained_words"]:
try:
return json_lib.loads(version["trained_words"])
except Exception:
pass
print(f"[Civitai Toolkit] Requesting civitai triggers from API for: {file_name}")
model_info = CivitaiAPIUtils.get_model_version_info_by_hash(
file_hash, force_refresh=False
)
triggers = (
model_info.get("trainedWords", [])
if model_info and isinstance(model_info.get("trainedWords"), list)
else []
)
return triggers
def format_tags_as_markdown(pos_items, neg_items, top_n):
md_lines = ["## Prompt Tag Analysis\n"]
if pos_items:
md_lines.extend(
["### Positive Tags", "| Rank | Tag | Count |", "|:----:|:----|:-----:|"]
)
md_lines.extend(
[
f"| {i + 1} | `{tag}` | **{count}** |"
for i, (tag, count) in enumerate(pos_items[:top_n])
]
)
else:
md_lines.append("_No positive tags found._")
md_lines.append("\n")
if neg_items:
md_lines.extend(
["### Negative Tags", "| Rank | Tag | Count |", "|:----:|:----|:-----:|"]
)
md_lines.extend(
[
f"| {i + 1} | `{tag}` | **{count}** |"
for i, (tag, count) in enumerate(neg_items[:top_n])
]
)
else:
md_lines.append("_No negative tags found._")
return "\n".join(md_lines)
def format_parameters_as_markdown(param_counts, total_images, summary_top_n=5):
if total_images == 0:
return "No parameter data found."
md_lines = ["### Generation Parameters Analysis\n"]
param_map = {
"sampler": "Sampler",
"scheduler": "Scheduler",
"cfgScale": "CFG Scale",
"steps": "Steps",
"Size": "Size",
"Hires upscaler": "Hires Upscaler",
"Denoising strength": "Hires Denoising Strength",
"clipSkip": "Clip Skip",
"VAE": "VAE",
}
for key, title in param_map.items():
md_lines.append(f"#### {title}\n")
stats = Counter(param_counts.get(key, {})).most_common(summary_top_n)
if not stats:
md_lines.append("_No data found._\n")
continue
md_lines.extend(
["| Rank | Value | Count (Usage) |", "|:----:|:------|:-------------:|"]
)
for i, (value, count) in enumerate(stats):
percentage = (count / total_images) * 100
md_lines.append(
f"| {i + 1} | `{value}` | **{count}** ({percentage:.1f}%) |"
)
md_lines.append("\n")
return "\n".join(md_lines)
def format_resources_as_markdown(assoc_stats, total_images, summary_top_n=5):
domain = _get_active_domain()
md_lines = ["### Associated Resources Analysis\n"]
for res_type in ["lora", "model", "vae"]:
stats_dict = assoc_stats.get(res_type, {})
title = "LoRAs"
if res_type == "model":
title = "Checkpoints"
elif res_type == "vae":
title = "VAEs"
md_lines.append(f"#### Top {summary_top_n} Associated {title}\n")
if not stats_dict or total_images == 0:
md_lines.append("_No data found_\n")
continue
sorted_resources = sorted(
stats_dict.values(), key=lambda item: item["count"], reverse=True
)
if res_type == "lora":
md_lines.extend(
[
"| Rank | LoRA Name | Usage | Avg. Weight | Mode Weight |",
"|:----:|:----------|:-----:|:-----------:|:-----------:|",
]
)
for i, data in enumerate(sorted_resources[:summary_top_n]):
actual_name, model_id = data.get("name", "Unknown"), data.get("modelId")
display_name = (
f"[{actual_name}](https://{domain}/models/{model_id})"
if model_id
else f"`{actual_name}`"
)
percentage = (data["count"] / total_images) * 100
weights = data.get("weights", [])
avg_weight = statistics.mean(weights) if weights else 0
common_weight = statistics.mode(weights) if weights else 0
md_lines.append(
f"| {i + 1} | {display_name} | **{percentage:.1f}%** | `{avg_weight:.2f}` | `{common_weight:.2f}` |"
)
else:
md_lines.extend(
[
f"| Rank | {title.rstrip('s')} Name | Usage |",
"|:----:|:----------------|:-----:|",
]
)
for i, data in enumerate(sorted_resources[:summary_top_n]):
actual_name, model_id = data.get("name", "Unknown"), data.get("modelId")
display_name = (
f"[{actual_name}](https://{domain}/models/{model_id})"
if model_id
else f"`{actual_name}`"
)
percentage = (data["count"] / total_images) * 100
md_lines.append(f"| {i + 1} | {display_name} | **{percentage:.1f}%** |")
md_lines.append("\n")
return "\n".join(md_lines)
def format_info_as_markdown(meta, recipe_loras, lora_hash_map, missing_ckpt_hash=None):
if not meta:
return "No metadata available."
def create_table(data_dict):
filtered_data = {
k: v for k, v in data_dict.items() if v is not None and str(v).strip()
}
if not filtered_data:
return ""
lines = ["| Parameter | Value |", "|:---|:---|"]
lines.extend(
[f"| **{key}** | `{value}` |" for key, value in filtered_data.items()]
)
return "\n".join(lines)
md_parts = []
model_name, model_hash = meta.get("Model"), meta.get("Model hash")
if (not model_name or not model_hash) and isinstance(meta.get("hashes"), dict):
for key, hash_val in meta['hashes'].items():
if key.lower().startswith("model:"):
model_name = key[6:]
model_hash = hash_val
break
if not model_hash:
model_hash = meta['hashes'].get("model")
vae_info = meta.get("VAE")
if not vae_info and isinstance(meta.get("hashes"), dict):
for key in meta['hashes']:
if "vae" in key.lower():
vae_info = f"{key} (hash: {meta['hashes'][key]})"
break
model_params = {
"Model": model_name,
"Model Hash": model_hash,
"VAE": vae_info,
"Clip Skip": meta.get("Clip skip") or meta.get("clipSkip"),
}
md_parts.append("### Models & VAE\n" + create_table(model_params))
core_params = {
"Seed": meta.get("seed"),
"Steps": meta.get("steps"),
"CFG Scale": meta.get("cfgScale"),
"Sampler": meta.get("sampler"),
"Scheduler": meta.get("scheduler"),
"Size": meta.get("Size"),
}
md_parts.append("\n### Core Parameters\n" + create_table(core_params))
hires_params = {
"Upscaler": meta.get("Hires upscaler"),
"Upscale By": meta.get("Hires upscale"),
"Hires Steps": meta.get("Hires steps"),
"Denoising": meta.get("Denoising strength"),
}
if any(hires_params.values()):
md_parts.append("\n### Hires. Fix\n" + create_table(hires_params))
md_parts.append("\n### Local Checkpoint Diagnosis\n")
if missing_ckpt_hash:
civitai_info = CivitaiAPIUtils.get_civitai_info_from_hash(missing_ckpt_hash)
if civitai_info:
md_parts.append(
f"- ❌ **[MISSING]** [{civitai_info['name']}]({civitai_info['url']})"
)
else:
md_parts.append(
f"- ❓ **[UNKNOWN]** Checkpoint with hash `{missing_ckpt_hash}` used, but not found locally or on Civitai."
)
else:
md_parts.append("✅ Recipe checkpoint found locally or not specified.")
md_parts.append("\n### Local LoRA Diagnosis\n")
if not recipe_loras:
md_parts.append("_No LoRAs Used in Recipe_")
else:
for lora in recipe_loras:
lora_hash, strength_val = (
lora.get("hash"),
safe_float_conversion(lora.get("weight", 1.0)),
)
filename = lora_hash_map.get(lora_hash.lower()) if lora_hash else None
if filename:
md_parts.append(
f"- ✅ **[FOUND]** `{filename}` (Strength: **{strength_val:.2f}**)"
)
else:
civitai_info = (
CivitaiAPIUtils.get_civitai_info_from_hash(lora_hash)
if lora_hash
else None
)
if civitai_info:
md_parts.append(
f"- ❌ **[MISSING]** [{civitai_info['name']}]({civitai_info['url']}) (Strength: **{strength_val:.2f}**)"
)
else:
name_to_show = lora.get("name") or "Unknown LoRA"
details = (
f"Hash: `{lora_hash}`" if lora_hash else "*(Hash not found)*"
)
md_parts.append(
f"- ❓ **[UNKNOWN]** `{name_to_show}` (Strength: **{strength_val:.2f}**) - {details}"
)
positive_prompt, negative_prompt = (
meta.get("prompt", ""),
meta.get("negativePrompt", ""),
)
md_parts.append("\n\n### Prompts")
if positive_prompt:
md_parts.append(
"📦 Positive Prompt
\n\n```\n"
+ positive_prompt
+ "\n```\n "
)
if negative_prompt:
md_parts.append(
"📦 Negative Prompt
\n\n```\n"
+ negative_prompt
+ "\n```\n "
)
try:
full_json_string = json_lib.dumps(meta, indent=2, ensure_ascii=False)
except TypeError:
import json
full_json_string = json.dumps(meta, indent=2, ensure_ascii=False)
if isinstance(full_json_string, bytes):
full_json_string = full_json_string.decode("utf-8")
md_parts.append("\n\n### Original JSON Data")
md_parts.append(
"\n📄 Metadata
\n\n```json\n"
+ full_json_string
+ "\n```\n "
)
return "\n".join(md_parts)
def fetch_missing_model_info_from_civitai():
"""
联网为数据库中缺少API信息的模型获取数据。
这个函数会阻塞,直到所有后台的获取和写入任务都完成。
"""
print("[Civitai Toolkit] Checking for models missing Civitai info...")
with db_manager.get_connection() as conn:
cursor = conn.cursor()
# 查找那些 api_response 字段为 NULL 的模型
cursor.execute(
"SELECT hash FROM versions WHERE hash IS NOT NULL AND api_response IS NULL"
)
hashes_to_fetch = [row["hash"] for row in cursor.fetchall()]
if not hashes_to_fetch:
print("[Civitai Toolkit] All models have Civitai info, nothing to fetch.")
return
print(f"[Civitai Toolkit] Found {len(hashes_to_fetch)} models to fetch info for...")
def fetch_worker(model_hash):
# 每个线程独立调用 get_model_version_info_by_hash。
# 该函数内部会自己处理数据库的写入和提交操作。
CivitaiAPIUtils.get_model_version_info_by_hash(model_hash,more_info= True)
return model_hash # 返回哈希以便记录错误
with ThreadPoolExecutor(max_workers=5) as executor:
# 创建所有 future 任务
futures = {executor.submit(fetch_worker, h): h for h in hashes_to_fetch}
# 使用 as_completed 迭代,每完成一个任务就更新一次进度条
# 由于 fetch_worker 内部的函数会自行提交数据库,这里只需等待任务完成即可
for future in tqdm(as_completed(futures), total=len(hashes_to_fetch), desc="Fetching Civitai Info"):
try:
# .result() 会等待该任务完成,并在此处重新引发在线程中发生的任何异常
future.result()
except Exception as e:
# 记录在获取单个模型信息时发生的错误,但不会中断整个流程
failed_hash = futures[future]
print(f"\n[Civitai Toolkit] Error fetching info for hash {failed_hash[:12]}: {e}")
print("[Civitai Toolkit] Finished fetching and caching missing model info.")
def download_image_safely(job):
final_path = job["path"]
temp_path = final_path + ".tmp"
if os.path.exists(temp_path):
os.remove(temp_path)
try:
with requests.get(
job["url"].split("?")[0] + "?width=450&format=png", stream=True, timeout=15
) as r:
r.raise_for_status()
expected_size = int(r.headers.get("content-length", 0))
downloaded_size = 0
with open(temp_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
downloaded_size += len(chunk)
if expected_size != 0 and downloaded_size != expected_size:
raise IOError(
f"Incomplete download. Expected {expected_size}, got {downloaded_size}"
)
os.rename(temp_path, final_path)
return True
except Exception:
if os.path.exists(temp_path):
os.remove(temp_path)
return False
def download_missing_covers():
"""
为数据库中已有API信息但本地缺少封面的模型下载封面图。
"""
print("[Civitai Toolkit] Checking for models missing local cover images...")
# 1. 查询所有需要检查的模型
with db_manager.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT v.local_path, v.model_type, v.api_response
FROM versions v
WHERE v.local_path IS NOT NULL AND v.api_response IS NOT NULL AND v.api_response != '{}'
""")
all_versions = cursor.fetchall()
download_jobs = []
# 2. 遍历模型,检查封面是否存在
for version in tqdm(all_versions, desc="Checking for Missing Covers"):
try:
name_no_ext = os.path.splitext(os.path.basename(version["local_path"]))[0]
cover_filename = f"{name_no_ext}.png"
cover_abs_path = os.path.join(os.path.dirname(version['local_path']), cover_filename)
# 如果本地封面已存在,则跳过
if os.path.exists(cover_abs_path):
continue
api_data = json_lib.loads(version['api_response'])
images = api_data.get("images", [])
if not images:
continue
# 选择一个合适的图片URL
sfw_images = [i for i in images if i.get("nsfw") == "None" or i.get("nsfwLevel") == 1]
target_image = (sfw_images[0] if sfw_images else images[0])
img_url = target_image.get("url")
if img_url:
download_jobs.append({"url": img_url, "path": cover_abs_path})
except Exception as e:
print(f"Error preparing cover download for {version['local_path']}: {e}")
if not download_jobs:
print("[Civitai Toolkit] All existing models have local covers.")
return
print(f"[Civitai Toolkit] Found {len(download_jobs)} missing covers to download...")
# 3. 多线程下载
with ThreadPoolExecutor(max_workers=10) as executor:
list(
tqdm(
executor.map(download_image_safely, download_jobs),
total=len(download_jobs),
desc="Downloading Missing Covers",
)
)
print("[Civitai Toolkit] Finished downloading missing covers.")
def initiate_background_scan(loop):
if db_manager.get_setting("initial_scan_complete", False):
print("[Civitai Toolkit] Initial scan already completed. Skipping.")
return
scan_thread = threading.Thread(target=background_scan_worker, args=(loop,))
scan_thread.daemon = True
scan_thread.start()
def background_scan_worker(loop):
"""
统一的后台工作流:按顺序执行哈希扫描、信息补全、封面下载。
"""
print("[Civitai Toolkit] Starting one-time background workflow (Scan, Fetch, Download Covers)...")
api.send_ws_message("scan_started", {"message": "The first scan in the background starts, and the local model will be indexed ..."})
try:
# 阶段一:哈希扫描
print("\n--- Background Task: Phase 1/3 - Hashing Local Files ---")
scan_all_supported_model_types(force=True)
# 阶段二:Civitai 信息补全
print("\n--- Background Task: Phase 2/3 - Fetching Civitai Info ---")
fetch_missing_model_info_from_civitai()
# 阶段三:远程封面下载
print("\n--- Background Task: Phase 3/3 - Downloading Missing Covers ---")
download_missing_covers()
db_manager.set_setting("initial_scan_complete", True)
print("\n[Civitai Toolkit] Background workflow finished successfully.")
api.send_ws_message("scan_complete", {
"success": True,
"message": "所有本地模型已索引完毕!刷新浏览器即可在菜单和侧边栏中看到它们。"
})
except Exception as e:
print(f"[Civitai Toolkit] Error during background workflow: {e}")
api.send_ws_message("scan_complete", {
"success": False,
"message": f"后台任务发生错误: {e}"
})
import traceback
traceback.print_exc()
def get_local_models_for_ui():
"""
[功能完整版] 快速为UI提供数据,包含纯本地的封面查找逻辑。
"""
print("[Civitai Toolkit] Reading model list from database for UI...")
models_details = []
with db_manager.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT v.hash, v.local_path, v.model_type, v.name as version_name, v.api_response, m.name as model_name
FROM versions v LEFT JOIN models m ON v.model_id = m.model_id
WHERE v.local_path IS NOT NULL
""")
all_versions = cursor.fetchall()
all_model_paths = {}
all_base_folders = {}
for mt in SUPPORTED_MODEL_TYPES.keys():
relative_paths = folder_paths.get_filename_list(mt)
if relative_paths:
path_map = {}
for f in relative_paths:
full_path = folder_paths.get_full_path(mt, f)
if full_path:
path_map[os.path.normpath(full_path)] = f
all_model_paths[mt] = path_map
all_base_folders[mt] = folder_paths.get_folder_paths(mt)
for db_entry in all_versions:
model_type = db_entry["model_type"]
norm_path = os.path.normpath(db_entry["local_path"])
relative_path = all_model_paths.get(model_type, {}).get(norm_path)
if not relative_path:
continue
api_data = json_lib.loads(db_entry['api_response']) if db_entry['api_response'] else None
# --- 快速的本地封面查找逻辑 ---
local_cover_path, found_cover = None, False
path_index = -1
base_folders_for_type = all_base_folders.get(model_type, [])
for i, folder in enumerate(base_folders_for_type):
if folder and os.path.commonpath([norm_path, os.path.normpath(folder)]) == os.path.normpath(folder):
path_index = i
break
if path_index != -1:
name_no_ext = os.path.splitext(relative_path)[0]
for ext in [".png", ".jpg", ".jpeg", ".webp"]:
cover_rel_path = name_no_ext + ext
full_cover_path = folder_paths.get_full_path(model_type, cover_rel_path)
if full_cover_path and os.path.exists(full_cover_path):
encoded = urllib.parse.quote(cover_rel_path, safe="~()*!.'")
local_cover_path = f"/api/experiment/models/preview/{model_type}/{path_index}/{encoded}"
found_cover = True
break
if not found_cover and norm_path.lower().endswith(".safetensors"):
try:
with safe_open(norm_path, framework="pt", device="cpu") as sf:
metadata = sf.metadata()
if metadata:
keys_to_check = [
"modelspec.thumbnail",
"thumbnail",
"image",
"icon",
]
image_uri = None
for key in keys_to_check:
if key in metadata and isinstance(metadata[key], str):
image_uri = metadata[key]
break
if image_uri and image_uri.startswith("data:image"):
local_cover_path = image_uri
found_cover = True
except Exception:
pass
local_metadata = None
model_info_from_api = api_data.get("model", {}) if api_data else {}
version_description = api_data.get("version_description") if api_data else None
model_description = api_data.get("model_description") if api_data else None
trained_words = api_data.get("trainedWords", []) if api_data else []
base_model = api_data.get("baseModel") if api_data else None
tags = model_info_from_api.get("tags", [])
civitai_model_name = db_entry["model_name"]
version_name = db_entry["version_name"]
model_abs_path = norm_path
if model_abs_path.lower().endswith(".safetensors"):
try:
with safe_open(model_abs_path, framework="pt", device="cpu") as sf:
metadata = sf.metadata()
if metadata:
local_metadata = metadata
except Exception as e:
print(f" - [WARNING] Could not read safetensors metadata from {os.path.basename(model_abs_path)}. Error: {e}")
if local_metadata:
if not civitai_model_name:
civitai_model_name = local_metadata.get("modelspec.title")
if not version_name:
version_name = local_metadata.get("modelspec.version")
if not version_description and not model_description:
local_desc = local_metadata.get("modelspec.description") or local_metadata.get("description")
if local_desc:
model_description = local_desc
if not trained_words:
tags_str = local_metadata.get("ss_tag_frequency")
if tags_str and isinstance(tags_str, str):
try:
tags_json = json_lib.loads(tags_str)
first_category = next(iter(tags_json))
trained_words = list(tags_json[first_category].keys())
except Exception:
trained_words = [tag.strip() for tag in tags_str.split(",") if tag.strip()]
if not base_model or base_model == "N/A":
base_model = local_metadata.get("modelspec.architecture") or local_metadata.get("ss_base_model_version")
full_model_info = {
"hash": db_entry["hash"], "filename": relative_path, "model_type": model_type,
"civitai_model_name": civitai_model_name or os.path.basename(norm_path),
"version_name": version_name, "local_cover_path": local_cover_path,
"version_description": version_description or "",
"model_description": model_description or "No description found.",
"trained_words": trained_words, "base_model": base_model or "N/A",
"civitai_stats": api_data.get("stats", {}) if api_data else {},
"tags": tags,
}
models_details.append(full_model_info)
return models_details
def get_all_local_models_with_details(force_refresh=False):
if force_refresh:
print("[Civitai Toolkit] Manual refresh triggered: re-scanning, fetching info, and downloading covers...")
# 手动刷新时,也按顺序执行完整的后台任务流,但这是同步的
scan_all_supported_model_types(force=True)
fetch_missing_model_info_from_civitai()
download_missing_covers()
# 无论是否刷新,最终都只从数据库快速读取数据给UI
return get_local_models_for_ui()