Add metadata size audit for model sidecar metadata

This commit is contained in:
Dariusz L
2026-07-05 18:03:29 +02:00
parent 6c1e940203
commit 85bbbef06d
4 changed files with 931 additions and 0 deletions
+23
View File
@@ -187,6 +187,7 @@ class ModelResolverExtension:
search_local_matches,
)
from .core.path_templates import infer_download_path_templates
from .core.metadata_audit import audit_metadata_sizes
from .core.scanner import get_model_files, invalidate_model_files_cache, find_local_file_path
from .core.path_utils import (
extract_safetensors_header_sha256,
@@ -1119,6 +1120,28 @@ class ModelResolverExtension:
models = get_model_files(force_rescan=force_rescan)
return web.json_response(models)
@routes.post("/model_resolver/metadata-size-audit")
@json_api_endpoint("metadata-size-audit")
async def metadata_size_audit_route(request):
"""Check local model .metadata.json sidecars for stale size values."""
payload = {}
try:
if request.can_read_body:
payload = await request.json()
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
force_rescan = to_bool(payload.get("force_rescan"), True)
result = await asyncio.to_thread(
audit_metadata_sizes,
force_rescan=force_rescan,
worker_count=payload.get("worker_count"),
batch_size=payload.get("batch_size"),
)
return web.json_response(result)
@routes.post("/model_resolver/loaded")
@json_api_endpoint("get_loaded_models")
async def get_loaded_models(request):
+466
View File
@@ -0,0 +1,466 @@
"""
Metadata maintenance helpers.
Provides audits for local model sidecar metadata files.
"""
import math
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, Iterable, List, Optional, Tuple
from .log_system import create_module_logger
from .path_utils import get_filename_from_path, get_path_identity, read_json_safe
from .type_utils import MODEL_EXTENSIONS, format_size_bytes
log = create_module_logger(__name__)
BYTE_SIZE_KEYS = ("size", "sizeBytes", "size_bytes", "fileSize", "file_size", "bytes")
KIB_SIZE_KEYS = ("sizeKB", "size_kb")
MAX_AUDIT_WORKERS = 64
MIN_AUDIT_WORKERS = 1
MIN_AUDIT_BATCH_SIZE = 16
MAX_AUDIT_BATCH_SIZE = 256
def _empty_audit_counts() -> Dict[str, Any]:
return {
"scanned_models": 0,
"metadata_files": 0,
"checked_metadata": 0,
"missing_metadata": 0,
"missing_size": 0,
"invalid_metadata": 0,
"skipped_directories": 0,
"skipped_non_model_files": 0,
"errors": [],
"mismatches": [],
}
def _coerce_size_bytes(value: Any, multiplier: int = 1) -> Optional[int]:
if value is None or value == "" or isinstance(value, bool):
return None
try:
if isinstance(value, str):
text = value.strip().replace(",", "").replace("_", "")
if not text or text.lower() in {"none", "null", "undefined"}:
return None
number = float(text)
else:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number) or number < 0:
return None
return int(number * multiplier)
def _iter_size_fields(data: Dict[str, Any], prefix: str = "") -> Iterable[Tuple[str, Any, int]]:
for key in BYTE_SIZE_KEYS:
if key in data:
yield (f"{prefix}{key}", data.get(key), 1)
for key in KIB_SIZE_KEYS:
if key in data:
yield (f"{prefix}{key}", data.get(key), 1024)
def _extract_size_from_object(data: Any, prefix: str = "") -> Optional[Tuple[int, str]]:
if not isinstance(data, dict):
return None
for field_path, value, multiplier in _iter_size_fields(data, prefix):
size = _coerce_size_bytes(value, multiplier=multiplier)
if size is not None:
return size, field_path
return None
def _file_entry_matches_model(entry: Dict[str, Any], model_filename: str) -> bool:
entry_name = get_filename_from_path(
entry.get("name")
or entry.get("filename")
or entry.get("file_name")
or entry.get("fileName")
or ""
).lower()
return bool(entry_name and entry_name == model_filename.lower())
def _select_file_entry(entries: Any, model_filename: str) -> Optional[Dict[str, Any]]:
if not isinstance(entries, list):
return None
candidates = [entry for entry in entries if isinstance(entry, dict)]
if not candidates:
return None
for entry in candidates:
if _file_entry_matches_model(entry, model_filename):
return entry
for entry in candidates:
if entry.get("primary") and str(entry.get("type") or "").lower() == "model":
return entry
for entry in candidates:
if entry.get("primary"):
return entry
if len(candidates) == 1:
return candidates[0]
return None
def extract_metadata_size(metadata: Dict[str, Any], model_filename: str = "") -> Optional[Tuple[int, str]]:
"""
Return the stored model size and the field path it came from.
Top-level size is preferred because Model Resolver writes LoRA
Manager-compatible sidecars with the authoritative value there.
"""
top_level = _extract_size_from_object(metadata)
if top_level:
return top_level
for key in ("file", "file_info", "selected_file", "selectedFile"):
nested = metadata.get(key)
found = _extract_size_from_object(nested, f"{key}.")
if found:
return found
nested_sources = [
("files", metadata.get("files")),
("selected_version.files", (metadata.get("selected_version") or {}).get("files") if isinstance(metadata.get("selected_version"), dict) else None),
("selectedVersion.files", (metadata.get("selectedVersion") or {}).get("files") if isinstance(metadata.get("selectedVersion"), dict) else None),
("civitai.files", (metadata.get("civitai") or {}).get("files") if isinstance(metadata.get("civitai"), dict) else None),
]
for prefix, entries in nested_sources:
entry = _select_file_entry(entries, model_filename)
found = _extract_size_from_object(entry, f"{prefix}[].")
if found:
return found
return None
def _is_model_file_path(path: str) -> bool:
if not path or not os.path.isfile(path):
return False
filename = get_filename_from_path(path).lower()
if filename.endswith((".metadata.json", ".civitai.info")):
return False
return os.path.splitext(filename)[1].lower() in MODEL_EXTENSIONS
def _metadata_sidecar_paths(model_path: str) -> List[str]:
directory = os.path.dirname(model_path)
filename = get_filename_from_path(model_path)
base_name = os.path.splitext(filename)[0]
candidates = [
os.path.join(directory, f"{base_name}.metadata.json"),
os.path.join(directory, f"{filename}.metadata.json"),
]
result: List[str] = []
seen = set()
for candidate in candidates:
try:
key = os.path.normcase(os.path.abspath(candidate))
except (OSError, ValueError):
key = os.path.normcase(candidate)
if key in seen:
continue
seen.add(key)
result.append(candidate)
return result
def _format_signed_size(bytes_value: int) -> str:
if bytes_value == 0:
return "0 B"
sign = "+" if bytes_value > 0 else "-"
return f"{sign}{format_size_bytes(abs(bytes_value))}"
def _model_key(model_path: str, metadata_path: str) -> Tuple[str, str]:
try:
model_identity = get_path_identity(model_path)
except (OSError, ValueError):
model_identity = os.path.normcase(os.path.abspath(model_path))
try:
metadata_identity = get_path_identity(metadata_path)
except (OSError, ValueError):
metadata_identity = os.path.normcase(os.path.abspath(metadata_path))
return model_identity, metadata_identity
def _model_identity_key(model: Dict[str, Any]) -> str:
model_path = str(model.get("path") or "").strip()
if not model_path:
return ""
try:
return get_path_identity(model_path)
except (OSError, ValueError):
try:
return os.path.normcase(os.path.abspath(model_path))
except (OSError, ValueError):
return os.path.normcase(model_path)
def _dedupe_models(models: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
result: List[Dict[str, Any]] = []
seen = set()
for model in models or []:
if not isinstance(model, dict):
continue
identity = _model_identity_key(model)
if not identity:
continue
if identity in seen:
continue
seen.add(identity)
result.append(model)
return result
def _get_worker_count(total_models: int, requested_workers: Optional[int] = None) -> Tuple[int, int]:
cpu_count = os.cpu_count() or 4
if requested_workers is not None:
try:
workers = int(requested_workers)
except (TypeError, ValueError):
workers = 0
if workers > 0:
return min(total_models or 1, max(MIN_AUDIT_WORKERS, min(workers, MAX_AUDIT_WORKERS))), cpu_count
# The audit is mostly disk I/O plus small JSON parsing, so a few workers per
# CPU core keeps the drive busy without creating an unbounded thread swarm.
workers = max(MIN_AUDIT_WORKERS, min(MAX_AUDIT_WORKERS, cpu_count * 4))
return min(total_models or 1, workers), cpu_count
def _get_batch_size(
total_models: int,
worker_count: int,
requested_batch_size: Optional[int] = None,
) -> int:
if requested_batch_size is not None:
try:
batch_size = int(requested_batch_size)
except (TypeError, ValueError):
batch_size = 0
if batch_size > 0:
return max(1, batch_size)
if total_models <= 0:
return MIN_AUDIT_BATCH_SIZE
target_batches = max(1, worker_count * 4)
batch_size = math.ceil(total_models / target_batches)
return max(MIN_AUDIT_BATCH_SIZE, min(MAX_AUDIT_BATCH_SIZE, batch_size))
def _make_batches(models: List[Dict[str, Any]], batch_size: int) -> List[List[Dict[str, Any]]]:
if not models:
return []
safe_batch_size = max(1, int(batch_size or 1))
return [
models[index:index + safe_batch_size]
for index in range(0, len(models), safe_batch_size)
]
def _audit_one_model(model: Dict[str, Any]) -> Dict[str, Any]:
result = _empty_audit_counts()
model_path = str(model.get("path") or "").strip()
if not model_path:
return result
if os.path.isdir(model_path):
result["skipped_directories"] += 1
return result
if not _is_model_file_path(model_path):
result["skipped_non_model_files"] += 1
return result
result["scanned_models"] += 1
try:
actual_size = os.path.getsize(model_path)
except OSError as exc:
result["errors"].append(
{
"model_path": model_path,
"metadata_path": "",
"message": str(exc),
}
)
return result
existing_metadata_paths = [
metadata_path
for metadata_path in _metadata_sidecar_paths(model_path)
if os.path.isfile(metadata_path)
]
if not existing_metadata_paths:
result["missing_metadata"] += 1
return result
model_filename = get_filename_from_path(model_path)
seen_entries = set()
for metadata_path in existing_metadata_paths:
entry_key = _model_key(model_path, metadata_path)
if entry_key in seen_entries:
continue
seen_entries.add(entry_key)
result["metadata_files"] += 1
metadata = read_json_safe(metadata_path, None)
if not isinstance(metadata, dict):
result["invalid_metadata"] += 1
result["errors"].append(
{
"model_path": model_path,
"metadata_path": metadata_path,
"message": "Metadata JSON is missing, invalid, or not an object.",
}
)
continue
size_info = extract_metadata_size(metadata, model_filename)
if not size_info:
result["missing_size"] += 1
continue
result["checked_metadata"] += 1
metadata_size, size_field = size_info
if metadata_size == actual_size:
continue
difference = actual_size - metadata_size
result["mismatches"].append(
{
"filename": model_filename,
"relative_path": model.get("relative_path") or model_filename,
"category": model.get("category") or "",
"base_directory": model.get("base_directory") or "",
"model_path": model_path,
"metadata_path": metadata_path,
"metadata_size": metadata_size,
"actual_size": actual_size,
"difference": difference,
"metadata_size_label": format_size_bytes(metadata_size),
"actual_size_label": format_size_bytes(actual_size),
"difference_label": _format_signed_size(difference),
"size_field": size_field,
}
)
return result
def _merge_audit_counts(target: Dict[str, Any], source: Dict[str, Any]) -> None:
for key in (
"scanned_models",
"metadata_files",
"checked_metadata",
"missing_metadata",
"missing_size",
"invalid_metadata",
"skipped_directories",
"skipped_non_model_files",
):
target[key] += int(source.get(key) or 0)
target["errors"].extend(source.get("errors") or [])
target["mismatches"].extend(source.get("mismatches") or [])
def _audit_model_batch(batch: List[Dict[str, Any]]) -> Dict[str, Any]:
result = _empty_audit_counts()
for model in batch:
_merge_audit_counts(result, _audit_one_model(model))
return result
def audit_metadata_sizes(
models: Optional[List[Dict[str, Any]]] = None,
*,
force_rescan: bool = True,
worker_count: Optional[int] = None,
batch_size: Optional[int] = None,
) -> Dict[str, Any]:
"""
Compare sidecar metadata size values against actual local model file sizes.
"""
if models is None:
from .scanner import get_model_files
models = get_model_files(force_rescan=force_rescan)
audit_models = _dedupe_models(models or [])
total_models = len(audit_models)
resolved_worker_count, cpu_count = _get_worker_count(total_models, worker_count)
resolved_batch_size = _get_batch_size(total_models, resolved_worker_count, batch_size)
batches = _make_batches(audit_models, resolved_batch_size)
active_worker_count = min(resolved_worker_count, len(batches) or 1)
aggregate = _empty_audit_counts()
if batches:
if active_worker_count <= 1:
for batch in batches:
_merge_audit_counts(aggregate, _audit_model_batch(batch))
else:
with ThreadPoolExecutor(max_workers=active_worker_count) as executor:
futures = [executor.submit(_audit_model_batch, batch) for batch in batches]
for future in as_completed(futures):
_merge_audit_counts(aggregate, future.result())
mismatches = aggregate["mismatches"]
errors = aggregate["errors"]
mismatches.sort(
key=lambda item: (
str(item.get("category") or "").lower(),
str(item.get("relative_path") or item.get("filename") or "").lower(),
str(item.get("metadata_path") or "").lower(),
)
)
result = {
"success": True,
"cpu_count": cpu_count,
"worker_count": active_worker_count,
"batch_size": resolved_batch_size,
"batch_count": len(batches),
"scanned_models": aggregate["scanned_models"],
"metadata_files": aggregate["metadata_files"],
"checked_metadata": aggregate["checked_metadata"],
"missing_metadata": aggregate["missing_metadata"],
"missing_size": aggregate["missing_size"],
"invalid_metadata": aggregate["invalid_metadata"],
"skipped_directories": aggregate["skipped_directories"],
"skipped_non_model_files": aggregate["skipped_non_model_files"],
"error_count": len(errors),
"errors": errors[:50],
"mismatch_count": len(mismatches),
"mismatches": mismatches,
}
log.info(
"Metadata size audit finished: "
f"models={aggregate['scanned_models']}, metadata={aggregate['metadata_files']}, "
f"size_values={aggregate['checked_metadata']}, mismatches={len(mismatches)}, "
f"missing_size={aggregate['missing_size']}, errors={len(errors)}, "
f"workers={active_worker_count}, batches={len(batches)}, batch_size={resolved_batch_size}"
)
return result
+197
View File
@@ -6246,6 +6246,13 @@ a.mr-image-preview-resource-name:hover {
background: rgba(255, 255, 255, 0.48);
}
.mr-options-main.mr-options-main-fill-section {
grid-auto-rows: minmax(0, 1fr);
align-content: stretch;
align-items: stretch;
overflow-y: hidden;
}
.mr-options-card {
background: transparent;
border: none;
@@ -6313,6 +6320,30 @@ a.mr-image-preview-resource-name:hover {
min-width: 0;
}
.mr-options-section-fill {
grid-template-rows: auto minmax(0, 1fr);
align-self: stretch;
align-content: stretch;
align-items: stretch;
height: 100%;
min-height: 0;
}
.mr-options-section-fill .mr-options-grid,
.mr-options-section-fill .mr-options-panel {
height: 100%;
min-height: 0;
align-content: stretch;
align-items: stretch;
}
.mr-options-section-fill .mr-options-stack {
grid-template-rows: auto auto auto minmax(0, 1fr);
height: 100%;
min-height: 0;
align-content: stretch;
}
.mr-options-panel-title {
margin: 0 0 6px 0;
font-size: 16px;
@@ -6408,6 +6439,172 @@ a.mr-image-preview-resource-name:hover {
margin-top: 0;
}
.mr-options-db-message.is-pending {
color: var(--mr-text-secondary);
}
.mr-options-db-message.is-valid {
color: var(--mr-color-success);
}
.mr-options-db-message.is-invalid {
color: var(--mr-color-error-light);
}
.mr-options-audit-results {
margin-top: 12px;
min-width: 0;
}
.mr-options-section-fill .mr-options-audit-results:not([hidden]) {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.mr-options-audit-table-wrap {
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: rgba(0, 0, 0, 0.14);
}
.mr-options-section-fill .mr-options-audit-table-wrap {
flex: 1 1 auto;
min-height: 160px;
max-height: none;
}
.mr-options-audit-table {
width: 100%;
min-width: 780px;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.mr-options-audit-table th,
.mr-options-audit-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: var(--mr-text-secondary);
vertical-align: top;
text-align: left;
}
.mr-options-audit-table th {
position: sticky;
top: 0;
z-index: 1;
color: var(--mr-text);
font-weight: 700;
background: rgba(20, 20, 24, 0.96);
}
.mr-options-audit-table tr:last-child td {
border-bottom: none;
}
.mr-options-audit-row.is-context-menu {
cursor: context-menu;
}
.mr-options-audit-row.is-context-menu:hover td {
background: rgba(255, 255, 255, 0.045);
}
.mr-options-audit-table th:nth-child(1),
.mr-options-audit-table td:nth-child(1) {
width: 28%;
}
.mr-options-audit-table th:nth-child(2),
.mr-options-audit-table td:nth-child(2) {
width: 11%;
}
.mr-options-audit-table th:nth-child(3),
.mr-options-audit-table td:nth-child(3),
.mr-options-audit-table th:nth-child(4),
.mr-options-audit-table td:nth-child(4),
.mr-options-audit-table th:nth-child(5),
.mr-options-audit-table td:nth-child(5) {
width: 12%;
}
.mr-options-audit-model {
color: var(--mr-text);
font-weight: 700;
word-break: break-word;
}
.mr-options-audit-path {
margin-top: 3px;
color: var(--mr-text-muted);
font-size: 11px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.mr-options-audit-delta {
font-weight: 700;
}
.mr-options-audit-delta.is-positive {
color: var(--mr-color-success);
}
.mr-options-audit-delta.is-negative {
color: var(--mr-color-error-light);
}
.mr-options-audit-empty {
padding: 12px;
border: 1px solid rgba(34, 197, 94, 0.24);
border-radius: 6px;
color: var(--mr-color-success);
background: rgba(34, 197, 94, 0.08);
font-size: 12px;
}
.mr-options-audit-errors {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid rgba(248, 113, 113, 0.22);
border-radius: 6px;
background: rgba(248, 113, 113, 0.08);
}
.mr-options-audit-errors-title {
margin-bottom: 6px;
color: var(--mr-color-error-light);
font-size: 12px;
font-weight: 700;
}
.mr-options-audit-errors ul {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.mr-options-audit-errors li {
display: grid;
gap: 2px;
color: var(--mr-text-secondary);
font-size: 12px;
}
.mr-options-audit-errors code {
color: var(--mr-text-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 11px;
overflow-wrap: anywhere;
}
#model-resolver-modal .mr-options-shell .mr-btn.mr-btn-primary,
#model-resolver-modal .mr-options-shell .mr-btn.mr-btn-primary:hover:not(:disabled) {
color: #101010;
+245
View File
@@ -243,6 +243,13 @@ export const optionsMethods = {
</span>
<span class="mr-options-nav-meta">07</span>
</button>
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-metadata-audit">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('hardDrive')}</span>
<span>Metadata Sizes</span>
</span>
<span class="mr-options-nav-meta">08</span>
</button>
</div>
</div>
<div class="mr-options-actions">
@@ -723,6 +730,44 @@ export const optionsMethods = {
</div>
</div>
</section>
<section id="mr-options-section-metadata-audit" class="mr-options-card mr-options-section mr-options-section-fill is-hidden">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">Metadata Size Audit</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-db-summary">
<div class="mr-options-db-row">
<span>Models scanned</span>
<strong id="mr-options-metadata-size-scanned">0</strong>
</div>
<div class="mr-options-db-row">
<span>Metadata checked</span>
<strong id="mr-options-metadata-size-checked">0</strong>
</div>
<div class="mr-options-db-row">
<span>Mismatches</span>
<strong id="mr-options-metadata-size-mismatches">0</strong>
</div>
<div class="mr-options-db-row">
<span>Missing size</span>
<strong id="mr-options-metadata-size-missing">0</strong>
</div>
<div class="mr-options-db-row">
<span>Errors</span>
<strong id="mr-options-metadata-size-errors">0</strong>
</div>
</div>
<div class="mr-options-db-actions">
<button id="mr-options-metadata-size-scan" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('refreshCw')} Check Metadata Sizes</button>
</div>
<div id="mr-options-metadata-size-status" class="mr-options-db-message">Not checked yet.</div>
<div id="mr-options-metadata-size-results" class="mr-options-audit-results" hidden></div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
@@ -803,6 +848,15 @@ export const optionsMethods = {
const hfIndexRefreshBtn = this.contentElement.querySelector('#mr-options-hf-index-refresh');
const clearAllCacheBtn = this.contentElement.querySelector('#mr-options-clear-all-cache');
const clearAllCacheStatus = this.contentElement.querySelector('#mr-options-clear-cache-status');
const metadataSizeAuditBtn = this.contentElement.querySelector('#mr-options-metadata-size-scan');
const metadataSizeAuditStatus = this.contentElement.querySelector('#mr-options-metadata-size-status');
const metadataSizeAuditResults = this.contentElement.querySelector('#mr-options-metadata-size-results');
const metadataSizeScannedEl = this.contentElement.querySelector('#mr-options-metadata-size-scanned');
const metadataSizeCheckedEl = this.contentElement.querySelector('#mr-options-metadata-size-checked');
const metadataSizeMismatchesEl = this.contentElement.querySelector('#mr-options-metadata-size-mismatches');
const metadataSizeMissingEl = this.contentElement.querySelector('#mr-options-metadata-size-missing');
const metadataSizeErrorsEl = this.contentElement.querySelector('#mr-options-metadata-size-errors');
const optionsMain = this.contentElement.querySelector('.mr-options-main');
const navButtons = Array.from(this.contentElement.querySelectorAll('.mr-options-nav-btn'));
const optionSections = Array.from(this.contentElement.querySelectorAll('.mr-options-section'));
const trackedInputs = [
@@ -937,6 +991,187 @@ export const optionsMethods = {
if (hfIndexRefreshBtn) hfIndexRefreshBtn.disabled = busy;
};
const setMetadataSizeAuditBusy = (busy) => {
if (metadataSizeAuditBtn) metadataSizeAuditBtn.disabled = busy;
};
const setStatusMode = (element, mode = '') => {
if (!element) return;
element.classList.remove('is-valid', 'is-invalid', 'is-pending');
if (mode) element.classList.add(mode);
};
const formatAuditNumber = (value) => {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString() : '0';
};
const setMetadataSizeAuditStatus = (text, mode = '') => {
if (!metadataSizeAuditStatus) return;
metadataSizeAuditStatus.textContent = text;
setStatusMode(metadataSizeAuditStatus, mode);
};
const setMetadataSizeSummaryValue = (element, value, mode = '') => {
if (!element) return;
element.textContent = formatAuditNumber(value);
setStatusMode(element, mode);
};
const updateMetadataSizeAuditSummary = (data = {}) => {
const mismatchCount = Number(data.mismatch_count || 0);
const missingSizeCount = Number(data.missing_size || 0);
const errorCount = Number(data.error_count || 0);
setMetadataSizeSummaryValue(metadataSizeScannedEl, data.scanned_models || 0);
setMetadataSizeSummaryValue(metadataSizeCheckedEl, data.metadata_files ?? data.checked_metadata ?? 0);
setMetadataSizeSummaryValue(metadataSizeMismatchesEl, mismatchCount, mismatchCount > 0 ? 'is-invalid' : '');
setMetadataSizeSummaryValue(metadataSizeMissingEl, missingSizeCount, missingSizeCount > 0 ? 'is-pending' : '');
setMetadataSizeSummaryValue(metadataSizeErrorsEl, errorCount, errorCount > 0 ? 'is-invalid' : '');
};
const renderMetadataSizeAuditResults = (data = null) => {
if (!metadataSizeAuditResults) return;
if (!data) {
metadataSizeAuditResults.hidden = true;
metadataSizeAuditResults.innerHTML = '';
return;
}
const mismatches = Array.isArray(data.mismatches) ? data.mismatches : [];
const errors = Array.isArray(data.errors) ? data.errors : [];
const visibleMismatches = mismatches.slice(0, 500);
let html = '';
if (mismatches.length) {
const rows = visibleMismatches.map((item) => {
const modelLabel = item.relative_path || item.filename || item.model_path || 'Model';
const modelPath = item.model_path || '';
const metadataPath = item.metadata_path || '';
const metadataSize = item.metadata_size_label || this.formatBytes(item.metadata_size);
const actualSize = item.actual_size_label || this.formatBytes(item.actual_size);
const difference = Number(item.difference || 0);
const differenceClass = difference > 0 ? 'is-positive' : (difference < 0 ? 'is-negative' : '');
const differenceLabel = item.difference_label || `${difference >= 0 ? '+' : '-'}${this.formatBytes(Math.abs(difference))}`;
const sizeField = item.size_field || 'size';
const auditContext = modelPath ? {
context_scope: 'local_model',
open_folder_label: 'Show File in Folder',
name: item.filename || modelLabel,
filename: item.filename || modelLabel,
relative_path: item.relative_path || modelLabel,
path: modelPath,
resolved_path: modelPath,
open_path: modelPath,
folder_path: item.base_directory || '',
category: item.category || '',
metadata_path: metadataPath,
size: item.actual_size || 0,
file_size: item.actual_size || 0,
metadata_size: item.metadata_size || 0,
context_source: 'metadata_size_audit'
} : null;
const contextAttrs = auditContext
? this.getContextMenuAttrs(auditContext, 'Right-click for model options')
: '';
return `
<tr class="mr-options-audit-row ${contextAttrs ? 'is-context-menu' : ''}"${contextAttrs}>
<td>
<div class="mr-options-audit-model">${this.escapeHtml(modelLabel)}</div>
<div class="mr-options-audit-path" title="${this.escapeHtml(modelPath)}">${this.escapeHtml(modelPath)}</div>
</td>
<td>${this.escapeHtml(item.category || '')}</td>
<td>
<div>${this.escapeHtml(metadataSize)}</div>
<div class="mr-options-audit-path">${this.escapeHtml(sizeField)}</div>
</td>
<td>${this.escapeHtml(actualSize)}</td>
<td class="mr-options-audit-delta ${differenceClass}">${this.escapeHtml(differenceLabel)}</td>
<td>
<div class="mr-options-audit-path" title="${this.escapeHtml(metadataPath)}">${this.escapeHtml(metadataPath)}</div>
</td>
</tr>
`;
}).join('');
html += `
<div class="mr-options-audit-table-wrap">
<table class="mr-options-audit-table">
<thead>
<tr>
<th>Model</th>
<th>Folder</th>
<th>Metadata</th>
<th>Actual</th>
<th>Delta</th>
<th>Metadata file</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
if (mismatches.length > visibleMismatches.length) {
html += `<div class="mr-options-db-message">Showing first ${visibleMismatches.length.toLocaleString()} of ${mismatches.length.toLocaleString()} mismatches.</div>`;
}
} else {
html += '<div class="mr-options-audit-empty">No metadata size mismatches found.</div>';
}
if (errors.length) {
const errorRows = errors.slice(0, 10).map((item) => {
const targetPath = item.metadata_path || item.model_path || '';
return `<li><span>${this.escapeHtml(item.message || 'Error')}</span><code>${this.escapeHtml(targetPath)}</code></li>`;
}).join('');
html += `
<div class="mr-options-audit-errors">
<div class="mr-options-audit-errors-title">Read errors</div>
<ul>${errorRows}</ul>
</div>
`;
}
metadataSizeAuditResults.innerHTML = html;
metadataSizeAuditResults.hidden = false;
};
const runMetadataSizeAudit = async () => {
setMetadataSizeAuditBusy(true);
updateMetadataSizeAuditSummary({});
renderMetadataSizeAuditResults(null);
setMetadataSizeAuditStatus('Checking metadata sizes...', 'is-pending');
try {
const data = await this.fetchJson('/model_resolver/metadata-size-audit', {
method: 'POST',
body: JSON.stringify({ force_rescan: true })
}, 'Check metadata sizes');
updateMetadataSizeAuditSummary(data);
renderMetadataSizeAuditResults(data);
const mismatchCount = Number(data?.mismatch_count || 0);
const checkedCount = Number(data?.metadata_files ?? data?.checked_metadata ?? 0);
const errorCount = Number(data?.error_count || 0);
const workerCount = Number(data?.worker_count || 0);
const batchCount = Number(data?.batch_count || 0);
const workerText = workerCount > 1
? ` using ${workerCount.toLocaleString()} workers across ${batchCount.toLocaleString()} batch${batchCount === 1 ? '' : 'es'}`
: '';
if (mismatchCount > 0) {
setMetadataSizeAuditStatus(`${mismatchCount.toLocaleString()} metadata size mismatch${mismatchCount === 1 ? '' : 'es'} found${workerText}.`, 'is-invalid');
this.showNotification('Metadata size mismatches found', 'warning');
} else if (errorCount > 0) {
setMetadataSizeAuditStatus(`Checked ${checkedCount.toLocaleString()} metadata file${checkedCount === 1 ? '' : 's'}${workerText} with ${errorCount.toLocaleString()} read error${errorCount === 1 ? '' : 's'}.`, 'is-invalid');
this.showNotification('Metadata size audit finished with errors', 'warning');
} else {
setMetadataSizeAuditStatus(`Checked ${checkedCount.toLocaleString()} metadata file${checkedCount === 1 ? '' : 's'}${workerText}. No mismatches found.`, 'is-valid');
this.showNotification('Metadata sizes checked', 'success');
}
} catch (error) {
setMetadataSizeAuditStatus(error.message || 'Metadata size audit failed.', 'is-invalid');
renderMetadataSizeAuditResults(null);
} finally {
setMetadataSizeAuditBusy(false);
}
};
const setTokenCheckStatus = (statusEl, text, mode = '') => {
if (!statusEl) return;
statusEl.textContent = text;
@@ -1493,6 +1728,10 @@ export const optionsMethods = {
optionSections.forEach((section) => {
section.classList.toggle('is-hidden', section.id !== targetId);
});
optionsMain?.classList.toggle(
'mr-options-main-fill-section',
targetId === 'mr-options-section-metadata-audit'
);
setActiveNav(targetId);
};
@@ -1969,6 +2208,12 @@ export const optionsMethods = {
});
}
if (metadataSizeAuditBtn) {
metadataSizeAuditBtn.addEventListener('click', () => {
runMetadataSizeAudit();
});
}
if (detectTemplatesBtn) {
detectTemplatesBtn.addEventListener('click', () => {
applyDetectedPathTemplates();