| from .metadata import get_metadata
|
| import os
|
| import sys
|
| import json
|
| import urllib.parse
|
| import subprocess |
| import threading |
| import asyncio |
| import shutil |
| from aiohttp import web |
| import folder_paths |
| import struct |
| from .utils import get_active_folder_types, get_folder_view_mode, get_active_physical_basenames, require_filename, resolve_folder_subdir, resolve_within |
|
|
|
|
| |
| |
| |
| MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi') |
| MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.bin') |
| PREVIEW_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS) |
| CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS) |
| SIDECAR_SUFFIXES = ( |
| '.info', '.civitai.info', '.json', '.txt', '.yaml', |
| *MEDIA_EXTENSIONS, |
| *PREVIEW_SUFFIXES, |
| *CIVITAI_BACKUP_SUFFIXES, |
| ) |
|
|
|
|
| def _first_existing_sidecar(base_path, suffixes): |
| for suffix in suffixes: |
| candidate = f"{base_path}{suffix}" |
| if os.path.isfile(candidate): |
| return candidate, suffix |
| return None, None |
|
|
|
|
| def _reset_model_cover(base_path): |
| """Restore a recoverable cover without destroying the only existing image.""" |
| backup_path, backup_suffix = _first_existing_sidecar(base_path, CIVITAI_BACKUP_SUFFIXES) |
| original_path, _ = _first_existing_sidecar(base_path, MEDIA_EXTENSIONS) |
| preview_paths = [ |
| f"{base_path}{suffix}" |
| for suffix in PREVIEW_SUFFIXES |
| if os.path.isfile(f"{base_path}{suffix}") |
| ] |
|
|
| if backup_path: |
| media_ext = backup_suffix[len('.civitai_bak'):] |
| restored_path = f"{base_path}.preview{media_ext}" |
| temp_path = f"{restored_path}.anomalous_tmp" |
| try: |
| |
| |
| shutil.copy2(backup_path, temp_path) |
| os.replace(temp_path, restored_path) |
| for preview_path in preview_paths: |
| if preview_path != restored_path and os.path.isfile(preview_path): |
| os.remove(preview_path) |
| except Exception as exc: |
| try: |
| if os.path.isfile(temp_path): |
| os.remove(temp_path) |
| except OSError: |
| pass |
| return False, 'restore_failed', str(exc) |
| return True, 'civitai_backup', None |
|
|
| if original_path: |
| try: |
| for preview_path in preview_paths: |
| os.remove(preview_path) |
| except Exception as exc: |
| return False, 'restore_failed', str(exc) |
| return True, 'original_cover', None |
|
|
| if preview_paths: |
| |
| |
| return False, 'preserved_current', 'No Civitai backup or original cover exists.' |
|
|
| return True, 'no_cover', None |
|
|
|
|
| def _cache_token(file_path): |
| try: |
| return os.stat(file_path).st_mtime_ns |
| except OSError: |
| return 0 |
|
|
|
|
| def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit): |
| """Collect one folder in a worker thread with a single directory listing.""" |
| try: |
| with os.scandir(target_dir) as iterator: |
| file_entries = {} |
| for entry in iterator: |
| try: |
| if entry.is_file(): |
| file_entries[entry.name] = entry |
| except OSError: |
| continue |
| except OSError: |
| return {"models": [], "total": 0, "page": page, "limit": limit} |
|
|
| valid_files = sorted( |
| (name for name in file_entries if name.endswith(('.safetensors', '.ckpt', '.pt'))), |
| key=str.lower, |
| ) |
| total = len(valid_files) |
| if limit > 0: |
| start = max(0, (page - 1) * limit) |
| sliced = valid_files[start:start + limit] |
| else: |
| sliced = valid_files |
|
|
| q_type = urllib.parse.quote(folder_type) |
| q_idx = str(path_idx) |
| q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else "" |
| models = [] |
| for filename in sliced: |
| file_path = os.path.join(target_dir, filename) |
| metadata = get_metadata(file_path) |
| base_name = os.path.splitext(filename)[0] |
| preview_file = next( |
| (base_name + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if base_name + suffix in file_entries), |
| None, |
| ) |
| preview_url = "" |
| if preview_file: |
| q_file = urllib.parse.quote(preview_file) |
| try: |
| preview_version = file_entries[preview_file].stat().st_mtime_ns |
| except OSError: |
| preview_version = 0 |
| preview_url = ( |
| f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}" |
| f"&filename={q_file}&t={preview_version}" |
| ) |
| try: |
| model_stat = file_entries[filename].stat() |
| size_bytes = model_stat.st_size |
| size_mb = round(size_bytes / (1024 * 1024), 2) |
| except OSError: |
| size_mb = 0 |
| size_bytes = 0 |
| models.append({ |
| "filename": filename, |
| "size_mb": size_mb, |
| "size_bytes": size_bytes, |
| "metadata": metadata, |
| "preview_url": preview_url, |
| "type": folder_type, |
| "path_idx": path_idx, |
| "subfolder": rel_subfolder, |
| }) |
| return {"models": models, "total": total, "page": page, "limit": limit} |
|
|
| def _collect_folders(): |
| mode = get_folder_view_mode()
|
| result = []
|
| seen_dirs = set()
|
|
|
| if mode == "physical":
|
| active_bns = get_active_physical_basenames()
|
| all_paths_info = []
|
|
|
| for t in folder_paths.folder_names_and_paths.keys():
|
| try:
|
| paths = folder_paths.get_folder_paths(t)
|
| if not paths: continue
|
| for path_idx, base_dir in enumerate(paths):
|
| if not os.path.exists(base_dir): continue
|
| real_path = os.path.realpath(base_dir)
|
| if real_path in seen_dirs: continue
|
| seen_dirs.add(real_path)
|
|
|
| bn = os.path.basename(os.path.normpath(base_dir))
|
| all_paths_info.append({
|
| "t": t,
|
| "path_idx": path_idx,
|
| "base_dir": base_dir,
|
| "bn": bn
|
| })
|
| except:
|
| pass
|
|
|
| for target_bn in active_bns:
|
| matched = [p for p in all_paths_info if p["bn"] == target_bn]
|
| if not matched: continue
|
|
|
| for item in matched:
|
| base_dir = item["base_dir"]
|
| tree = {}
|
| for root, dirs, files in os.walk(base_dir):
|
| has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
|
| rel = os.path.relpath(root, base_dir)
|
| if rel == '.':
|
| rel = '/'
|
| else:
|
| rel = '/' + rel.replace('\\', '/')
|
| tree[rel] = {
|
| "path": rel,
|
| "name": os.path.basename(root) if rel != '/' else '[Root]',
|
| "has_models": has_models,
|
| "model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
|
| }
|
|
|
| label = item["bn"]
|
| if len(matched) > 1:
|
| label += f" ({item['path_idx'] + 1})"
|
|
|
| result.append({
|
| "type": item["t"],
|
| "path_idx": item["path_idx"],
|
| "label": label,
|
| "folders": tree
|
| })
|
| else:
|
| types = get_active_folder_types()
|
| for t in types:
|
| try:
|
| paths = folder_paths.get_folder_paths(t)
|
| except Exception:
|
| continue
|
| if not paths:
|
| continue
|
|
|
| for path_idx, base_dir in enumerate(paths):
|
| if not os.path.exists(base_dir):
|
| continue
|
| real_path = os.path.realpath(base_dir)
|
| if real_path in seen_dirs:
|
| continue
|
| seen_dirs.add(real_path)
|
|
|
| tree = {}
|
| for root, dirs, files in os.walk(base_dir):
|
| has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
|
| rel = os.path.relpath(root, base_dir)
|
| if rel == '.':
|
| rel = '/'
|
| else:
|
| rel = '/' + rel.replace('\\', '/')
|
| tree[rel] = {
|
| "path": rel,
|
| "name": os.path.basename(root) if rel != '/' else '[Root]',
|
| "has_models": has_models,
|
| "model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
|
| }
|
|
|
| try:
|
| folder_basename = os.path.basename(os.path.normpath(base_dir))
|
| if not folder_basename:
|
| folder_basename = t
|
| except:
|
| folder_basename = t
|
|
|
| label = folder_basename
|
|
|
| basenames = []
|
| try:
|
| basenames = [os.path.basename(os.path.normpath(p)) for p in paths]
|
| except:
|
| pass
|
| if basenames.count(folder_basename) > 1:
|
| label += f" ({path_idx + 1})"
|
|
|
| result.append({
|
| "type": t,
|
| "path_idx": path_idx,
|
| "label": label,
|
| "folders": tree
|
| })
|
|
|
| return {"folders": result} |
|
|
|
|
| async def api_get_folders(request): |
| return web.json_response(await asyncio.to_thread(_collect_folders)) |
|
|
|
|
| async def api_get_models(request): |
| folder_type = request.query.get('type', 'checkpoints')
|
| subfolder = request.query.get('subfolder', '/')
|
| page = int(request.query.get('page', 1))
|
| limit = int(request.query.get('limit', 0))
|
| try:
|
| path_idx = int(request.query.get('path_idx', 0))
|
| except:
|
| path_idx = 0
|
| try:
|
| paths = folder_paths.get_folder_paths(folder_type)
|
| except Exception:
|
| return web.json_response({"models": [], "total": 0})
|
| try: |
| base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) |
| except (ValueError, KeyError): |
| return web.Response(status=400, text='Invalid subfolder') |
| rel_subfolder = "" if subfolder == '/' else subfolder.strip('/\\') |
| if not os.path.exists(target_dir):
|
| return web.json_response({"models": [], "total": 0})
|
| payload = await asyncio.to_thread( |
| _collect_folder_models, |
| target_dir, |
| folder_type, |
| path_idx, |
| rel_subfolder, |
| page, |
| limit, |
| ) |
| return web.json_response(payload) |
|
|
| async def api_find_model(request):
|
| search = request.query.get('search', '').lower()
|
| if not search:
|
| return web.json_response({"status": "error", "message": "No search query provided"})
|
|
|
| for folder_type in folder_paths.folder_names_and_paths.keys():
|
| paths = folder_paths.get_folder_paths(folder_type)
|
| if not paths: continue
|
| for path_idx, base_dir in enumerate(paths):
|
| if not os.path.exists(base_dir): continue
|
| for root, dirs, files in os.walk(base_dir):
|
| for f in files:
|
| if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt') or f.endswith('.bin') or f.endswith('.sft'):
|
| search_norm = search.replace(os.sep, '/')
|
| abs_path_norm = os.path.join(root, f).replace(os.sep, '/').lower()
|
| if search_norm in f.lower() or search_norm in abs_path_norm:
|
| rel_subfolder = os.path.relpath(root, base_dir)
|
| if rel_subfolder == '.': rel_subfolder = '/'
|
|
|
| file_path = os.path.join(root, f)
|
| meta = get_metadata(file_path)
|
|
|
| base_name = os.path.splitext(f)[0]
|
| preview_file = None
|
| for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS: |
| if os.path.exists(os.path.join(root, base_name + ext)):
|
| preview_file = base_name + ext
|
| break
|
|
|
| preview_url = ""
|
| if preview_file:
|
| q_type = urllib.parse.quote(folder_type)
|
| q_idx = str(path_idx)
|
| q_sub = urllib.parse.quote(rel_subfolder)
|
| q_file = urllib.parse.quote(preview_file)
|
| mtime = _cache_token(os.path.join(root, preview_file)) |
| preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
|
|
|
| try: size_mb = round(os.path.getsize(file_path) / (1024 * 1024), 1)
|
| except: size_mb = 0
|
|
|
| modelData = {
|
| "filename": f,
|
| "size_mb": size_mb,
|
| "metadata": meta,
|
| "preview_url": preview_url
|
| }
|
| return web.json_response({
|
| "status": "success",
|
| "model": modelData,
|
| "type": folder_type,
|
| "path_idx": path_idx,
|
| "subfolder": rel_subfolder
|
| })
|
|
|
| return web.json_response({"status": "error", "message": "Model not found"})
|
|
|
| async def api_delete_model(request):
|
| try:
|
| data = await request.json()
|
| folder_type = data.get('type', 'checkpoints')
|
| subfolder = data.get('subfolder', '/')
|
| filename = data.get('filename', '')
|
| try:
|
| path_idx = int(data.get('path_idx', 0))
|
| except:
|
| path_idx = 0
|
|
|
| try: |
| filename = require_filename(filename) |
| _, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) |
| model_path = resolve_within(target_dir, filename) |
| except (ValueError, KeyError): |
| return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400) |
| if not os.path.exists(model_path):
|
| return web.json_response({"status": "error", "message": "Model file not found"})
|
|
|
|
|
| try:
|
| os.remove(model_path)
|
| except Exception as e:
|
| error_msg = str(e)
|
| if "being used" in error_msg or "WinError 32" in error_msg or "Permission" in error_msg:
|
| error_msg = "文件被占用 (正在被 ComfyUI 使用)。请先重启 ComfyUI 或在工作流中卸载该模型后再删除!"
|
| return web.json_response({"status": "error", "message": f"主模型删除失败: {error_msg}"})
|
|
|
| base_name = os.path.splitext(filename)[0]
|
|
|
|
|
| |
| |
| |
| shared_stem_in_use = any( |
| os.path.isfile(os.path.join(target_dir, base_name + model_ext)) |
| for model_ext in MODEL_EXTENSIONS |
| ) |
| deleted_files = [filename] |
| if not shared_stem_in_use: |
| for suffix in SIDECAR_SUFFIXES: |
| file_to_del = os.path.join(target_dir, base_name + suffix) |
| if os.path.isfile(file_to_del): |
| try: |
| os.remove(file_to_del) |
| deleted_files.append(base_name + suffix) |
| except Exception as e: |
| print(f"[Anomalous Browser] Warning: Failed to delete {file_to_del}: {e}") |
|
|
|
|
| return web.json_response({ |
| "status": "success", |
| "deleted": deleted_files, |
| "sidecars_preserved": shared_stem_in_use, |
| }) |
|
|
| except Exception as e:
|
| import traceback
|
| traceback.print_exc()
|
| return web.json_response({"status": "error", "message": str(e)}, status=500)
|
|
|
| async def api_compatible_models(request):
|
| base_model = request.query.get('base_model', '')
|
| target_type = request.query.get('target_type', 'loras')
|
|
|
| if not base_model:
|
| return web.json_response({"models": []})
|
|
|
| target_types = [t.strip() for t in target_type.split(',')]
|
| compatible_models = []
|
| seen_files = set()
|
|
|
| for t in target_types:
|
| try:
|
| paths = folder_paths.get_folder_paths(t)
|
| except Exception:
|
| continue
|
|
|
| if not paths:
|
| continue
|
|
|
| for path_idx, base_dir in enumerate(paths):
|
| if not os.path.exists(base_dir):
|
| continue
|
|
|
| for root, _, files in os.walk(base_dir):
|
| for f in files:
|
| if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
|
| file_path = os.path.join(root, f)
|
| real_path = os.path.realpath(file_path)
|
| if real_path in seen_files:
|
| continue
|
| seen_files.add(real_path)
|
|
|
| meta = get_metadata(file_path)
|
| m_bm = str(meta.get("baseModel", "")).strip().lower().replace(" ", "")
|
| req_bm = str(base_model).strip().lower().replace(" ", "")
|
|
|
| if req_bm and m_bm and (req_bm in m_bm or m_bm in req_bm):
|
| rel_subfolder = os.path.relpath(root, base_dir)
|
| if rel_subfolder == '.':
|
| rel_subfolder = '/'
|
| else:
|
| rel_subfolder = '/' + rel_subfolder.replace('\\', '/')
|
|
|
| base_name = os.path.splitext(f)[0]
|
| preview_file = None
|
| for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS: |
| if os.path.exists(os.path.join(root, base_name + ext)):
|
| preview_file = base_name + ext
|
| break
|
|
|
| preview_url = ""
|
| if preview_file:
|
| q_type = urllib.parse.quote(t)
|
| q_idx = str(path_idx)
|
| q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
|
| q_file = urllib.parse.quote(preview_file)
|
| preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}"
|
|
|
| try:
|
| size_bytes = os.path.getsize(file_path)
|
| size_mb = round(size_bytes / (1024 * 1024), 1)
|
| except Exception:
|
| size_mb = 0
|
| size_bytes = 0
|
|
|
| compatible_models.append({
|
| "type": t,
|
| "path_idx": path_idx,
|
| "subfolder": rel_subfolder,
|
| "filename": f,
|
| "size_mb": size_mb,
|
| "size_bytes": size_bytes,
|
| "preview_url": preview_url,
|
| "metadata": meta
|
| })
|
|
|
| return web.json_response({"models": compatible_models})
|
|
|
| async def api_base_models(request):
|
| target_types = get_active_folder_types()
|
| base_models = set()
|
| seen_files = set()
|
|
|
| for t in target_types:
|
| try:
|
| paths = folder_paths.get_folder_paths(t)
|
| except Exception:
|
| continue
|
| if not paths: continue
|
|
|
| for base_dir in paths:
|
| if not os.path.exists(base_dir): continue
|
| for root, _, files in os.walk(base_dir):
|
| for f in files:
|
| if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
|
| file_path = os.path.join(root, f)
|
| real_path = os.path.realpath(file_path)
|
| if real_path in seen_files: continue
|
| seen_files.add(real_path)
|
|
|
| meta = get_metadata(file_path)
|
| m_bm = meta.get("baseModel", "")
|
| if m_bm and str(m_bm).strip():
|
|
|
| clean_bm = str(m_bm).strip()
|
| base_models.add(clean_bm)
|
|
|
| return web.json_response({"base_models": sorted(list(base_models))})
|
|
|
| RESOLVABLE_MODEL_TYPES = ('checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae') |
|
|
|
|
| def _parse_resolution_types(expected_types_raw): |
| if not expected_types_raw: |
| return RESOLVABLE_MODEL_TYPES |
| requested = tuple(dict.fromkeys( |
| value.strip() for value in str(expected_types_raw).split(',') if value.strip() |
| )) |
| if not requested or any(value not in RESOLVABLE_MODEL_TYPES for value in requested): |
| raise ValueError("Invalid model type") |
| return requested |
|
|
|
|
| def _collect_resolution_candidates(types): |
| candidates = [] |
| seen_realpaths = set() |
| for folder_type in types: |
| try: |
| paths = folder_paths.get_folder_paths(folder_type) |
| except Exception: |
| continue |
| for base_dir in paths or []: |
| if not os.path.exists(base_dir): |
| continue |
| for root, _, files in os.walk(base_dir): |
| for filename in files: |
| if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt')): |
| continue |
| file_path = os.path.join(root, filename) |
| real_path = os.path.realpath(file_path) |
| if real_path in seen_realpaths: |
| continue |
| try: |
| file_size = os.path.getsize(file_path) |
| except OSError: |
| continue |
| seen_realpaths.add(real_path) |
| candidates.append({ |
| "type": folder_type, |
| "filename": os.path.relpath(file_path, base_dir).replace('\\', '/'), |
| "path": file_path, |
| "size": file_size, |
| }) |
| return candidates |
|
|
|
|
| def _candidate_hashes(candidate): |
| from .metadata import _extract_safetensors_hash |
|
|
| if "hashes" in candidate: |
| return candidate["hashes"] |
| values = set() |
| metadata = candidate.get("metadata") |
| if metadata is None: |
| metadata = get_metadata(candidate["path"]) |
| candidate["metadata"] = metadata |
| meta_hash = metadata.get("hash", "") |
| if meta_hash: |
| values.add(str(meta_hash).upper()) |
| if candidate["path"].lower().endswith('.safetensors'): |
| header_hash = _extract_safetensors_hash(candidate["path"]) |
| if header_hash: |
| values.add(str(header_hash).upper()) |
| candidate["hashes"] = values |
| return values |
|
|
|
|
| def _resolved_payload(candidate, **details): |
| payload = { |
| "found": True, |
| "type": candidate["type"], |
| "filename": candidate["filename"], |
| } |
| payload.update(details) |
| return payload |
|
|
|
|
| def _resolve_from_candidates(candidates, target_hash="", target_size=None, filename_query=""): |
| target_hash = str(target_hash or "").strip().upper() |
| if filename_query: |
| normalized_query = str(filename_query).replace('\\', '/').lstrip('./') |
| query_basename = normalized_query.rsplit('/', 1)[-1] |
| for candidate in candidates: |
| if candidate["filename"] == normalized_query or ( |
| '/' not in normalized_query and os.path.basename(candidate["filename"]) == query_basename |
| ): |
| return _resolved_payload(candidate, matched_by_filename=True) |
|
|
| has_target_hash = bool(target_hash and target_hash != "UNKNOWN") |
| size_matches = [ |
| candidate for candidate in candidates |
| if target_size is not None and candidate["size"] == target_size |
| ] |
|
|
| if has_target_hash and target_size is not None: |
| combined_matches = [candidate for candidate in size_matches if target_hash in _candidate_hashes(candidate)] |
| if len(combined_matches) == 1: |
| return _resolved_payload(combined_matches[0], matched_by_hash=True, matched_by_size=True) |
| if len(combined_matches) > 1: |
| return {"found": False, "ambiguous": True} |
|
|
| hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)] |
| if hash_matches: |
| return {"found": False, "identity_conflict": True} |
| if len(size_matches) == 1: |
| return _resolved_payload(size_matches[0], matched_by_size=True, stale_hash=True) |
| if len(size_matches) > 1: |
| return {"found": False, "ambiguous": True} |
|
|
| if has_target_hash: |
| hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)] |
| if len(hash_matches) == 1: |
| return _resolved_payload(hash_matches[0], matched_by_hash=True) |
| if len(hash_matches) > 1: |
| return {"found": False, "ambiguous": True} |
|
|
| if target_size is not None: |
| if len(size_matches) == 1: |
| return _resolved_payload(size_matches[0], matched_by_size=True) |
| if len(size_matches) > 1: |
| return {"found": False, "ambiguous": True} |
| return {"found": False} |
|
|
|
|
| async def api_resolve_hash(request): |
| target_hash = request.query.get("hash", "").strip().upper() |
| size_str = request.query.get("size", "").strip() |
| filename_query = request.query.get("filename", "").strip() |
| target_size = int(size_str) if size_str.isdigit() else None |
| if not target_hash and target_size is None and not filename_query: |
| return web.json_response({"found": False}) |
| try: |
| types = _parse_resolution_types(request.query.get("type", "").strip()) |
| except ValueError as exc: |
| return web.json_response({"found": False, "error": str(exc)}, status=400) |
|
|
| def resolve_one(): |
| candidates = _collect_resolution_candidates(types) |
| return _resolve_from_candidates(candidates, target_hash, target_size, filename_query) |
|
|
| return web.json_response(await asyncio.to_thread(resolve_one)) |
|
|
|
|
| async def api_resolve_hash_batch(request): |
| try: |
| data = await request.json() |
| items = data.get("items", []) |
| except Exception: |
| return web.json_response({"error": "Invalid JSON body"}, status=400) |
| if not isinstance(items, list) or len(items) > 256: |
| return web.json_response({"error": "items must be a list with at most 256 entries"}, status=400) |
|
|
| parsed_items = [] |
| try: |
| for index, item in enumerate(items): |
| if not isinstance(item, dict): |
| raise ValueError("Invalid batch item") |
| size_value = item.get("size") |
| size_string = str(size_value).strip() if size_value is not None else "" |
| parsed_items.append({ |
| "key": str(item.get("key", index)), |
| "hash": str(item.get("hash", "")).strip().upper(), |
| "size": int(size_string) if size_string.isdigit() else None, |
| "types": _parse_resolution_types(str(item.get("type", "")).strip()), |
| }) |
| except (TypeError, ValueError) as exc: |
| return web.json_response({"error": str(exc)}, status=400) |
|
|
| def resolve_batch(): |
| candidate_groups = {} |
| results = [] |
| for item in parsed_items: |
| types = item["types"] |
| if types not in candidate_groups: |
| candidate_groups[types] = _collect_resolution_candidates(types) |
| result = _resolve_from_candidates( |
| candidate_groups[types], |
| item["hash"], |
| item["size"], |
| ) |
| results.append({"key": item["key"], "result": result}) |
| return results |
|
|
| return web.json_response({"results": await asyncio.to_thread(resolve_batch)}) |
|
|
| async def api_get_all_hashes(request):
|
| """
|
| Returns a dictionary of all scanned models with their hash and size.
|
| Keyed by both relative path and basename for maximum frontend resilience.
|
| """
|
| import asyncio
|
|
|
| def fetch_all(): |
| hashes = {} |
| ambiguous_keys = set() |
|
|
| def add_hash(key, value): |
| if key in ambiguous_keys: |
| return |
| existing = hashes.get(key) |
| if existing is None or existing == value: |
| hashes[key] = value |
| else: |
| hashes.pop(key, None) |
| ambiguous_keys.add(key) |
|
|
| types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
| seen_dirs = set()
|
| for t in types:
|
| try:
|
| paths = folder_paths.get_folder_paths(t)
|
| if not paths: continue
|
| for base_dir in paths:
|
| if base_dir in seen_dirs: continue
|
| seen_dirs.add(base_dir)
|
| if not os.path.exists(base_dir): continue
|
| for root, dirs, files in os.walk(base_dir):
|
| for file in files:
|
| if file.endswith('.safetensors') or file.endswith('.ckpt') or file.endswith('.pt'):
|
| file_path = os.path.join(root, file)
|
| try:
|
| size_bytes = os.path.getsize(file_path)
|
| except Exception:
|
| size_bytes = 0
|
|
|
| meta = get_metadata(file_path)
|
| hash_val = ""
|
| if meta and meta.get("hash"):
|
| hash_val = meta["hash"]
|
|
|
| rel_path = os.path.relpath(file_path, base_dir)
|
| if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
| rel_path = rel_path[2:]
|
| rel_path = rel_path.replace('\\', '/')
|
| basename = os.path.basename(file_path)
|
|
|
| val = {"hash": hash_val, "size": size_bytes}
|
| add_hash(rel_path, val) |
| add_hash(basename, val) |
| except Exception:
|
| pass
|
| return hashes
|
|
|
| hashes = await asyncio.to_thread(fetch_all)
|
| return web.json_response(hashes)
|
|
|
| async def api_update_metadata(request):
|
| try:
|
| data = await request.json()
|
| folder_type = data.get('type', 'checkpoints')
|
| subfolder = data.get('subfolder', '/')
|
| filename = data.get('filename', '')
|
| custom_name = data.get('custom_name', '')
|
| custom_notes = data.get('custom_notes', '')
|
| physical_rename = data.get('physical_rename', False)
|
| try: path_idx = int(data.get('path_idx', 0))
|
| except: path_idx = 0
|
|
|
| try: |
| filename = require_filename(filename) |
| _, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) |
| file_path = resolve_within(target_dir, filename) |
| except (ValueError, KeyError): |
| return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400) |
|
|
| if not os.path.exists(file_path):
|
| return web.json_response({"status": "error", "message": "Model not found"})
|
|
|
| base_name = os.path.splitext(file_path)[0]
|
| model_ext = os.path.splitext(file_path)[1] |
| info_file = f"{base_name}.civitai.info" |
|
|
| info_data = {}
|
| if os.path.exists(info_file):
|
| parsed = False
|
| for enc in ['utf-8', 'utf-8-sig', 'mbcs', 'latin-1']:
|
| try:
|
| with open(info_file, 'r', encoding=enc) as f:
|
| info_data = json.load(f)
|
| parsed = True
|
| break
|
| except Exception:
|
| pass
|
| if not parsed:
|
| return web.json_response({"status": "error", "message": "Failed to parse existing .civitai.info file due to encoding or corruption. Rename aborted to prevent data loss."})
|
|
|
| info_data["anomalous_custom_name"] = custom_name
|
| info_data["anomalous_custom_notes"] = custom_notes
|
|
|
| reset_cover = data.get('reset_cover', False) |
| cover_reset = None |
| cover_reset_source = None |
| cover_reset_warning = None |
| if reset_cover: |
| cover_reset, cover_reset_source, cover_reset_warning = _reset_model_cover(base_name) |
|
|
| with open(info_file, 'w', encoding='utf-8') as f:
|
| json.dump(info_data, f, indent=4, ensure_ascii=False)
|
|
|
| new_filename = filename
|
|
|
| if physical_rename and custom_name: |
| import re |
| safe_name = re.sub(r'[<>:"/\\|?*]', '_', custom_name).strip(' .') |
| if not safe_name: |
| return web.json_response({"status": "error", "message": "The physical filename cannot be empty."}, status=400) |
| new_file_path = os.path.join(target_dir, f"{safe_name}{model_ext}") |
|
|
| if new_file_path != file_path and not os.path.exists(new_file_path):
|
| os.rename(file_path, new_file_path)
|
|
|
| for suffix in SIDECAR_SUFFIXES: |
| old_sidecar = f"{base_name}{suffix}" |
| if os.path.isfile(old_sidecar): |
| os.rename(old_sidecar, os.path.join(target_dir, f"{safe_name}{suffix}")) |
|
|
| new_filename = f"{safe_name}{model_ext}" |
| elif os.path.exists(new_file_path) and new_file_path != file_path:
|
| return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
|
|
|
| response_data = {"status": "success", "new_filename": new_filename} |
| if reset_cover: |
| response_data.update({ |
| "cover_reset": cover_reset, |
| "cover_reset_source": cover_reset_source, |
| "cover_reset_warning": cover_reset_warning, |
| }) |
| return web.json_response(response_data) |
| except Exception as e:
|
| return web.json_response({"status": "error", "message": str(e)})
|
|
|
| async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png'):
|
| base_name = os.path.splitext(filename)[0]
|
|
|
|
|
| if source_ext.startswith('.preview.'):
|
| preview_ext = source_ext
|
| else:
|
| preview_ext = f".preview{source_ext}"
|
|
|
| dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
|
|
|
|
|
| for ext in PREVIEW_SUFFIXES: |
| p = os.path.join(target_dir, f"{base_name}{ext}")
|
| if os.path.exists(p) and p != dest_path:
|
| try: os.remove(p)
|
| except: pass
|
|
|
| await save_func(dest_path)
|
|
|
| async def api_set_custom_cover(request):
|
| try:
|
| data = await request.json()
|
| folder_type = data.get('type', 'checkpoints')
|
| subfolder = data.get('subfolder', '/')
|
| filename = data.get('filename', '')
|
| source_image = data.get('source_image', '')
|
| try: path_idx = int(data.get('path_idx', 0))
|
| except: path_idx = 0
|
|
|
| try: |
| filename = require_filename(filename) |
| _, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) |
| output_dir = folder_paths.get_output_directory() |
| src_path = resolve_within(output_dir, source_image) |
| except (ValueError, KeyError): |
| return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400) |
| if not os.path.exists(src_path):
|
| return web.json_response({"status": "error", "message": "Source image not found in output directory"})
|
|
|
| source_ext = os.path.splitext(src_path)[1].lower() |
| if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}: |
| return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415) |
|
|
| async def save_copy(dest_path):
|
| import shutil
|
| import asyncio
|
| await asyncio.to_thread(shutil.copy2, src_path, dest_path)
|
|
|
| await _handle_custom_cover(target_dir, filename, save_copy, source_ext)
|
|
|
| return web.json_response({"status": "success"})
|
| except Exception as e:
|
| return web.json_response({"status": "error", "message": str(e)})
|
|
|
| async def api_upload_custom_cover(request):
|
| try:
|
| data = await request.post()
|
| folder_type = data.get('type', 'checkpoints')
|
| subfolder = data.get('subfolder', '/')
|
| filename = data.get('filename', '')
|
| try: path_idx = int(data.get('path_idx', 0))
|
| except: path_idx = 0
|
|
|
| image_field = data.get('image')
|
|
|
| try: |
| filename = require_filename(filename) |
| _, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) |
| except (ValueError, KeyError): |
| return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400) |
| if image_field is None: |
| return web.json_response({"status": "error", "message": "Image is required"}, status=400) |
|
|
| image_data = image_field.file.read() |
| if len(image_data) > 100 * 1024 * 1024: |
| return web.json_response({"status": "error", "message": "Cover file is too large"}, status=413) |
|
|
| upload_filename = image_field.filename
|
| source_ext = os.path.splitext(upload_filename)[1].lower() |
| if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}: |
| return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415) |
|
|
| async def save_upload(dest_path):
|
| def write_file():
|
| with open(dest_path, 'wb') as f:
|
| f.write(image_data)
|
| import asyncio
|
| await asyncio.to_thread(write_file)
|
|
|
| await _handle_custom_cover(target_dir, filename, save_upload, source_ext)
|
|
|
| return web.json_response({"status": "success"})
|
| except Exception as e:
|
| return web.json_response({"status": "error", "message": str(e)})
|
|
|
|
|
| import struct |
|
|
|
|
| def _preview_url_for_model(folder_type, path_idx, base_dir, file_path): |
| root = os.path.dirname(file_path) |
| filename = os.path.basename(file_path) |
| base_name = os.path.splitext(filename)[0] |
| preview_file = next( |
| ( |
| base_name + suffix |
| for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS |
| if os.path.isfile(os.path.join(root, base_name + suffix)) |
| ), |
| None, |
| ) |
| if not preview_file: |
| return "" |
| rel_subfolder = os.path.relpath(root, base_dir) |
| if rel_subfolder == '.': |
| rel_subfolder = '/' |
| q_type = urllib.parse.quote(folder_type) |
| q_idx = str(path_idx) |
| q_sub = urllib.parse.quote(rel_subfolder) |
| q_file = urllib.parse.quote(preview_file) |
| version = _cache_token(os.path.join(root, preview_file)) |
| return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}" |
|
|
|
|
| def _model_info_for_path(folder_type, path_idx, base_dir, file_path): |
| root = os.path.dirname(file_path) |
| rel_subfolder = os.path.relpath(root, base_dir) |
| if rel_subfolder == '.': |
| rel_subfolder = '/' |
| else: |
| rel_subfolder = '/' + rel_subfolder.replace(os.sep, '/') |
| return { |
| "type": folder_type, |
| "path_idx": path_idx, |
| "subfolder": rel_subfolder, |
| "filename": os.path.basename(file_path), |
| "preview_url": _preview_url_for_model(folder_type, path_idx, base_dir, file_path), |
| "metadata": get_metadata(file_path), |
| } |
|
|
|
|
| def _allowed_folder_types(requested_types=None): |
| available = list(folder_paths.folder_names_and_paths.keys()) |
| if requested_types is None: |
| return available |
| if not isinstance(requested_types, list): |
| return [] |
| allowed = set(available) |
| return list(dict.fromkeys( |
| folder_type |
| for folder_type in requested_types |
| if isinstance(folder_type, str) and folder_type in allowed |
| )) |
|
|
|
|
| def _resolve_paths_to_model_info_sync(paths, folder_types=None): |
| requested = [ |
| (path, path.replace('\\', '/').lower(), path.replace('\\', '/')) |
| for path in paths |
| if isinstance(path, str) |
| ] |
| exact_results = {} |
| roots = [] |
| for folder_type in _allowed_folder_types(folder_types): |
| try: |
| folder_dirs = folder_paths.get_folder_paths(folder_type) |
| except Exception: |
| continue |
| for path_idx, base_dir in enumerate(folder_dirs or []): |
| if not os.path.isdir(base_dir): |
| continue |
| real_base_dir = os.path.realpath(base_dir) |
| roots.append((folder_type, path_idx, real_base_dir)) |
| for original, _, relative_path in requested: |
| relative = relative_path.replace('/', os.sep) |
| candidate = os.path.realpath(os.path.join(real_base_dir, relative)) |
| try: |
| if os.path.commonpath((real_base_dir, candidate)) != real_base_dir: |
| continue |
| except ValueError: |
| continue |
| if os.path.isfile(candidate) and candidate.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')): |
| exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate) |
|
|
| unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results] |
| if not unresolved: |
| return exact_results |
|
|
| wanted_relpaths = {normalized for _, normalized in unresolved} |
| wanted_basenames = {normalized.rsplit('/', 1)[-1] for _, normalized in unresolved} |
| rel_matches = {} |
| basename_matches = {} |
| for folder_type, path_idx, base_dir in roots: |
| for root, _, files in os.walk(base_dir): |
| for filename in files: |
| if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')): |
| continue |
| rel_path = os.path.relpath(os.path.join(root, filename), base_dir).replace(os.sep, '/').lower() |
| basename = filename.lower() |
| if rel_path not in wanted_relpaths and basename not in wanted_basenames: |
| continue |
| candidate_info = _model_info_for_path( |
| folder_type, |
| path_idx, |
| base_dir, |
| os.path.join(root, filename), |
| ) |
| if rel_path in wanted_relpaths: |
| rel_matches[rel_path] = candidate_info |
| if basename in wanted_basenames: |
| basename_matches[basename] = candidate_info |
|
|
| model_info = dict(exact_results) |
| for original, normalized in unresolved: |
| if normalized in rel_matches: |
| model_info[original] = rel_matches[normalized] |
| else: |
| basename = normalized.rsplit('/', 1)[-1] |
| if basename in basename_matches: |
| model_info[original] = basename_matches[basename] |
| return model_info |
|
|
|
|
| def _resolve_paths_to_previews_sync(paths, folder_types=None): |
| model_info = _resolve_paths_to_model_info_sync(paths, folder_types) |
| return {path: item.get("preview_url", "") for path, item in model_info.items()} |
|
|
|
|
| async def api_resolve_paths_to_previews(request): |
| try: |
| data = await request.json() |
| paths = data.get('paths', []) |
| folder_types = data.get('folder_types') |
| context_requests = data.get('context_requests', []) |
| except: |
| return web.json_response({"previews": {}, "models": {}, "context_models": {}}) |
|
|
| model_info = await asyncio.to_thread(_resolve_paths_to_model_info_sync, paths, folder_types) |
| context_models = {} |
| if isinstance(context_requests, list): |
| for item in context_requests[:16]: |
| if not isinstance(item, dict): |
| continue |
| context_path = item.get('path') |
| if not isinstance(context_path, str) or not context_path: |
| continue |
| resolved = await asyncio.to_thread( |
| _resolve_paths_to_model_info_sync, |
| [context_path], |
| item.get('folder_types'), |
| ) |
| if context_path in resolved: |
| context_models[context_path] = resolved[context_path] |
|
|
| previews = {path: item.get("preview_url", "") for path, item in model_info.items()} |
| return web.json_response({ |
| "previews": previews, |
| "models": model_info, |
| "context_models": context_models, |
| }) |
|
|
|
|
|
|
|
|
| def _collect_all_scan_models(page, limit): |
| target_types = get_active_folder_types() |
| all_tuples = []
|
| seen_dirs = set()
|
| for t in target_types:
|
| try:
|
| paths = folder_paths.get_folder_paths(t)
|
| except Exception:
|
| continue
|
| if not paths: continue
|
| for path_idx, base_dir in enumerate(paths):
|
| if base_dir in seen_dirs: continue
|
| seen_dirs.add(base_dir)
|
| if not os.path.exists(base_dir): continue
|
| for root, dirs, files in os.walk(base_dir):
|
| for f in files:
|
| if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
| all_tuples.append((t, path_idx, root, base_dir, f))
|
|
|
| all_tuples.sort(key=lambda x: (x[0], x[4].lower()))
|
| total = len(all_tuples)
|
|
|
| if limit > 0:
|
| start = (page - 1) * limit
|
| end = start + limit
|
| sliced = all_tuples[start:end]
|
| else:
|
| sliced = all_tuples
|
|
|
| all_models = []
|
| for t, path_idx, root, base_dir, f in sliced:
|
| file_path = os.path.join(root, f)
|
| rel_subfolder = os.path.relpath(root, base_dir)
|
| if rel_subfolder == '.': rel_subfolder = ''
|
| else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
| try:
|
| size_bytes = os.path.getsize(file_path)
|
| size_mb = round(size_bytes / (1024 * 1024), 2)
|
| except:
|
| size_bytes = 0; size_mb = 0
|
| meta = get_metadata(file_path)
|
| base_name = os.path.splitext(f)[0]
|
| preview_file = None
|
| for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS: |
| if os.path.exists(os.path.join(root, base_name + ext)):
|
| preview_file = base_name + ext
|
| break
|
| preview_url = ""
|
| if preview_file:
|
| q_type = urllib.parse.quote(t)
|
| q_idx = str(path_idx)
|
| q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
|
| q_file = urllib.parse.quote(preview_file)
|
| mtime = _cache_token(os.path.join(root, preview_file)) |
| preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
|
| all_models.append({
|
| "type": t, "path_idx": path_idx, "subfolder": rel_subfolder,
|
| "filename": f, "size_mb": size_mb, "size_bytes": size_bytes,
|
| "preview_url": preview_url, "metadata": meta
|
| })
|
| return {"models": all_models, "total": total, "page": page, "limit": limit} |
|
|
|
|
| async def api_get_all_scan_models(request): |
| page = int(request.query.get('page', 1)) |
| limit = int(request.query.get('limit', 0)) |
| payload = await asyncio.to_thread(_collect_all_scan_models, page, limit) |
| return web.json_response(payload) |
|
|
| async def api_batch_select(request):
|
| folder_key = request.query.get('folderKey', 'ALL')
|
| action = request.query.get('action', 'all')
|
|
|
| def matches_condition(file_path, root, base_name):
|
| if action == 'all':
|
| return True
|
| elif action == 'no_preview':
|
| for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS: |
| if os.path.exists(os.path.join(root, base_name + ext)):
|
| return False
|
| return True
|
| elif action == 'no_desc':
|
| info_file = file_path + '.info'
|
| civitai_info = os.path.join(root, base_name + '.civitai.info')
|
| if os.path.exists(civitai_info):
|
| try:
|
| with open(civitai_info, 'r', encoding='utf-8') as f:
|
| data = json.load(f)
|
| if data.get('description', '').strip():
|
| return False
|
| except: pass
|
| if os.path.exists(info_file):
|
| try:
|
| with open(info_file, 'r', encoding='utf-8') as f:
|
| data = json.load(f)
|
| if data.get('description', '').strip():
|
| return False
|
| except: pass
|
| return True
|
| return False
|
|
|
| results = {}
|
|
|
| if folder_key == 'ALL':
|
| target_types = get_active_folder_types()
|
| seen_dirs = set()
|
| for t in target_types:
|
| try: paths = folder_paths.get_folder_paths(t)
|
| except Exception: continue
|
| if not paths: continue
|
| for path_idx, base_dir in enumerate(paths):
|
| if base_dir in seen_dirs: continue
|
| seen_dirs.add(base_dir)
|
| if not os.path.exists(base_dir): continue
|
| for root, dirs, files in os.walk(base_dir):
|
| for f in files:
|
| if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
| file_path = os.path.join(root, f)
|
| base_name = os.path.splitext(f)[0]
|
| if matches_condition(file_path, root, base_name):
|
| rel_subfolder = os.path.relpath(root, base_dir)
|
| if rel_subfolder == '.': rel_subfolder = ''
|
| else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
| fkey = f"{t}|{path_idx}|{rel_subfolder}"
|
| if fkey not in results: results[fkey] = []
|
| results[fkey].append(f)
|
| else:
|
| parts = folder_key.split('|')
|
| if len(parts) >= 3:
|
| t = parts[0]
|
| path_idx = int(parts[1])
|
| subfolder = parts[2]
|
|
|
| try: paths = folder_paths.get_folder_paths(t)
|
| except Exception: paths = []
|
|
|
| if paths and path_idx < len(paths):
|
| try: |
| base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder) |
| except ValueError: |
| return web.json_response({"selected": {}}) |
|
|
| if os.path.exists(target_dir):
|
| try: entries = os.listdir(target_dir)
|
| except: entries = []
|
| for f in entries:
|
| if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
| file_path = os.path.join(target_dir, f)
|
| if os.path.isfile(file_path):
|
| base_name = os.path.splitext(f)[0]
|
| if matches_condition(file_path, target_dir, base_name):
|
| if folder_key not in results: results[folder_key] = []
|
| results[folder_key].append(f)
|
|
|
| return web.json_response({"selected": results})
|
|
|