Files
ComfyUI-Lora-Manager/py/utils/metadata_manager.py
T
Will Miao f1d3ac0cdc fix(metadata): fill local file facts when self-heal recreates sidecar
Refresh after manual .metadata.json deletion rebuilds the payload without
file_name/size/modified, which are required by BaseModelMetadata.from_dict.
The recreated sidecar then fails to parse and the scanner skips the model.

- load_metadata_payload fills missing file facts from os.stat
- hydrate_model_data restores every missing key from the cache snapshot
  only when the sidecar is missing entirely (disk stays authoritative
  otherwise), preferring the cached import timestamp for modified
- save_metadata fills file facts on write so no write path can produce
  an unparseable sidecar
2026-08-11 14:57:23 +08:00

405 lines
17 KiB
Python

from datetime import datetime
import os
import json
import logging
import time
from typing import Any, Dict, Optional, Type, Union, cast
from .models import BaseModelMetadata, CheckpointMetadata, EmbeddingMetadata, LoraMetadata
from .file_utils import normalize_path, find_preview_file, calculate_sha256, calculate_autov3
from .lora_metadata import extract_lora_metadata, extract_checkpoint_metadata
logger = logging.getLogger(__name__)
class MetadataManager:
"""
Centralized manager for all metadata operations.
This class is responsible for:
1. Loading metadata safely with fallback mechanisms
2. Saving metadata with atomic operations
3. Creating default metadata for models
4. Handling unknown fields gracefully
"""
@staticmethod
async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> tuple[Optional[BaseModelMetadata], bool]:
"""
Load metadata safely.
Returns:
tuple: (metadata, should_skip)
- metadata: BaseModelMetadata instance or None
- should_skip: True if corrupted metadata file exists and model should be skipped
"""
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
# Check if metadata file exists
if not os.path.exists(metadata_path):
return None, False
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Create model instance
metadata = model_class.from_dict(data)
# Normalize paths
await MetadataManager._normalize_metadata_paths(metadata, file_path)
return metadata, False
except (json.JSONDecodeError, Exception) as e:
error_type = "Invalid JSON" if isinstance(e, json.JSONDecodeError) else "Parse error"
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
return None, True # should_skip = True
@staticmethod
def _fill_local_file_facts(payload: Dict[str, Any], file_path: str) -> None:
"""Fill missing local file facts (``file_name``/``size``/``modified``) from disk.
These three fields are part of the required metadata schema but describe
the local file, not remote metadata. Payloads rebuilt by the self-heal
refresh flow (sidecar deleted, then recreated from remote data) lack
them, which makes the recreated sidecar unparseable by
``BaseModelMetadata.from_dict`` and causes the scanner to skip the model.
Fill them from the actual file whenever absent.
"""
if not file_path:
return
if payload.get("file_name") and "size" in payload and "modified" in payload:
return
try:
stat_result = os.stat(file_path)
except OSError:
return
if not payload.get("file_name"):
payload["file_name"] = os.path.splitext(os.path.basename(file_path))[0]
if "size" not in payload:
payload["size"] = stat_result.st_size
if "modified" not in payload:
payload["modified"] = stat_result.st_mtime
@staticmethod
async def load_metadata_payload(file_path: str) -> Dict[str, Any]:
"""
Load metadata and return it as a dictionary, including any unknown fields.
Falls back to reading the raw JSON file if parsing into a model class fails.
"""
payload: Dict[str, Any] = {}
metadata_obj, should_skip = await MetadataManager.load_metadata(file_path)
if metadata_obj:
payload = metadata_obj.to_dict()
unknown_fields = getattr(metadata_obj, "_unknown_fields", None)
if isinstance(unknown_fields, dict):
payload.update(unknown_fields)
else:
if not should_skip:
metadata_path = (
file_path
if file_path.endswith(".metadata.json")
else f"{os.path.splitext(file_path)[0]}.metadata.json"
)
if os.path.exists(metadata_path):
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
raw = json.load(handle)
if isinstance(raw, dict):
payload = raw
except json.JSONDecodeError:
logger.warning(
"Failed to parse metadata file %s while loading payload",
metadata_path,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning("Failed to read metadata file %s: %s", metadata_path, exc)
if not isinstance(payload, dict):
payload = {}
if file_path:
payload.setdefault("file_path", normalize_path(file_path))
# Required schema fields that are local filesystem facts. When the
# sidecar is missing (e.g. deleted and being recreated by the
# self-heal refresh flow), restore them so the recreated sidecar
# and cache entries stay parseable.
MetadataManager._fill_local_file_facts(payload, file_path)
return payload
@staticmethod
async def hydrate_model_data(model_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Replace the provided model data with the authoritative payload from disk.
Preserves the cached folder entry if present.
When the sidecar is missing entirely (self-heal after manual deletion),
the disk payload is nearly empty and the cache snapshot is the only
source for the schema fields required by ``BaseModelMetadata.from_dict``
(file_name/model_name/size/modified/sha256/base_model/preview_url), so
every missing key is restored from it to keep any recreated sidecar
parseable and avoid data loss on failed refreshes. When the sidecar
exists, disk data stays authoritative and no cache key is resurrected.
"""
file_path = model_data.get("file_path")
if not file_path:
return model_data
folder = model_data.get("folder")
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
sidecar_exists = os.path.exists(metadata_path)
cached = model_data.copy()
payload = await MetadataManager.load_metadata_payload(file_path)
if folder is not None:
payload["folder"] = folder
model_data.clear()
model_data.update(payload)
if not sidecar_exists:
for key, value in cached.items():
if key not in model_data and key != "folder":
model_data[key] = value
# The schema defines `modified` as the import timestamp; keep the
# cache's value over the stat-derived fallback from
# load_metadata_payload.
if "modified" in cached:
model_data["modified"] = cached["modified"]
# file_name/size are local file facts; prefer fresh stat values over
# the possibly stale cache snapshot.
MetadataManager._fill_local_file_facts(model_data, file_path)
return model_data
@staticmethod
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict[str, Any]]) -> bool:
"""
Save metadata with atomic write operations.
Args:
path: Path to the model file or directly to the metadata file
metadata: Metadata to save (either BaseModelMetadata object or dict)
Returns:
bool: Success or failure
"""
# Determine if the input is a metadata path or a model file path
if path.endswith('.metadata.json'):
metadata_path = path
else:
# Use existing logic for model file paths
file_path = path
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
temp_path = f"{metadata_path}.tmp"
try:
# Convert to dict if needed
if isinstance(metadata, BaseModelMetadata):
metadata_dict = metadata.to_dict()
# Preserve unknown fields if present
if hasattr(metadata, '_unknown_fields'):
metadata_dict.update(metadata._unknown_fields)
else:
metadata_dict = metadata.copy()
# Normalize paths
if 'file_path' in metadata_dict:
metadata_dict['file_path'] = normalize_path(metadata_dict['file_path'])
if 'preview_url' in metadata_dict:
metadata_dict['preview_url'] = normalize_path(metadata_dict['preview_url'])
# Local file facts are required schema fields; fill them when a
# payload rebuilt without them (e.g. self-heal) is being persisted.
if metadata_dict.get("file_path"):
MetadataManager._fill_local_file_facts(metadata_dict, metadata_dict["file_path"])
# Write to temporary file first
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(metadata_dict, f, indent=2, ensure_ascii=False)
# Atomic rename operation
os.replace(temp_path, metadata_path)
return True
except Exception as e:
logger.error(f"Error saving metadata to {metadata_path}: {str(e)}")
# Clean up temporary file if it exists
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
return False
@staticmethod
async def create_default_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
"""
Create basic metadata structure for a model file.
This replaces the old get_file_info function with a more appropriately named method.
Args:
file_path: Path to the model file
model_class: Class to instantiate
Returns:
BaseModelMetadata instance or None if file doesn't exist
"""
# First check if file actually exists and resolve symlinks
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
return None
except Exception as e:
logger.error(f"Error checking file existence for {file_path}: {e}")
return None
try:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
# Calculate file hash
start_hash_time = time.perf_counter()
logger.debug(f"Calculating SHA256 hash for {real_path}...")
sha256 = await calculate_sha256(real_path)
hash_duration = time.perf_counter() - start_hash_time
logger.info(f"SHA256 hash calculated for {real_path} in {hash_duration:.3f}s")
# AutoV3 reads only the safetensors header, so it is cheap even for
# large files. At creation time we always know the checked state:
# store "" when no recognized hash is embedded (checked-unavailable).
autov3 = calculate_autov3(real_path)
# Create instance based on model type
if model_class.__name__ == "CheckpointMetadata":
metadata = cast(Type[CheckpointMetadata], model_class)(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256=sha256,
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
sub_type="checkpoint",
from_civitai=True
)
elif model_class.__name__ == "EmbeddingMetadata":
metadata = cast(Type[EmbeddingMetadata], model_class)(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256=sha256,
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
sub_type="embedding",
from_civitai=True
)
else: # Default to LoraMetadata
metadata = cast(Type[LoraMetadata], model_class)(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256=sha256,
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
from_civitai=True,
usage_tips="{}"
)
# Record the AutoV3 state explicitly ("" = checked, no value).
metadata.autov3 = autov3 or ""
# Try to extract model-specific metadata
# await MetadataManager._enrich_metadata(metadata, real_path)
# Save the created metadata
logger.info(f"Creating new .metadata.json for {file_path} (Reason: No existing metadata found)")
await MetadataManager.save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(f"Error creating default metadata for {file_path}: {e}")
return None
@staticmethod
async def _enrich_metadata(metadata: BaseModelMetadata, file_path: str) -> None:
"""
Enrich metadata with model-specific information
Args:
metadata: Metadata to enrich
file_path: Path to the model file
"""
try:
if metadata.__class__.__name__ == "LoraMetadata":
model_info = await extract_lora_metadata(file_path)
metadata.base_model = model_info['base_model']
# elif metadata.__class__.__name__ == "CheckpointMetadata":
# model_info = await extract_checkpoint_metadata(file_path)
# metadata.base_model = model_info['base_model']
# if 'model_type' in model_info:
# metadata.model_type = model_info['model_type']
except Exception as e:
logger.error(f"Error enriching metadata: {str(e)}")
@staticmethod
async def _normalize_metadata_paths(metadata: BaseModelMetadata, file_path: str) -> None:
"""
Normalize paths in metadata object
Args:
metadata: Metadata object to update
file_path: Current file path for the model
"""
need_update = False
# Check if file_name matches the actual file name
base_name = os.path.splitext(os.path.basename(file_path))[0]
if metadata.file_name != base_name:
metadata.file_name = base_name
need_update = True
# Check if file path is different from what's in metadata
if normalize_path(file_path) != metadata.file_path:
metadata.file_path = normalize_path(file_path)
need_update = True
# Check if preview exists at the current location
preview_url = metadata.preview_url
if preview_url:
# Get directory parts of both paths
file_dir = os.path.dirname(file_path)
preview_dir = os.path.dirname(preview_url)
# Update preview if it doesn't exist OR if model and preview are in different directories
if not os.path.exists(preview_url) or file_dir != preview_dir:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
new_preview_url = find_preview_file(base_name, dir_path)
if new_preview_url:
metadata.preview_url = normalize_path(new_preview_url)
need_update = True
# If path attributes were changed, save the metadata back to disk
if need_update:
await MetadataManager.save_metadata(file_path, metadata)