With many models, or models on a network drive, every visit to the scan page read every folder and, after a restart, every new model's header again (79 s for 1041 models on an AutoDL model disk). api/model_heads.py keeps what each file's header says by real path, size and time in user/anomalous/model_heads.json (at most 50,000 files, the longest unused dropped first, written a few seconds after the last change, only once setup_routes called keep_on_disk); get_metadata reads headers through it, so a header is read once per file version. scan_summary keeps the last summary (memory, and scan_summary.json when it changed); ?quick=1 hands it out at once and the page shows it with "showing the last counts while they are updated", then the fresh ones. Requests meanwhile await one count (_fresh). The models found on Civitai (components apart, at most 5000 kept) are listed by GET /anomalous/scan_matched; the page's "Found on Civitai" count opens them, with Civitai's name, base model, a box to find one by name and Open. Checked on the isolated ComfyUI reading the real loras and checkpoints read-only (scans stubbed; no file there changed): the last counts showed at once also after a restart, the fresh ones replaced them, the found list had 68 models with names, the box filtered, Open and Back returned to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
13 KiB
Python
263 lines
13 KiB
Python
"""What the scan page shows before a scan: how many models are matched on Civitai, unmatched or
|
|
new, and which ones the last two are (located like the scan report's models, so the page can
|
|
open them); also the model files a scan never reads (other formats than .safetensors), so the
|
|
page can say why they are not there, the models the user set to "don't scan", and the new ones
|
|
whose header looks like no drawing model (a voice model, say), for the page to ask about. The
|
|
components a drawing model loads (VAE, text encoders…) are counted and listed on their own too,
|
|
since many are not on Civitai and that is no fault; and some covers of the scanned models, for
|
|
the scan card's band of pictures. And for the scan page's picker, every model
|
|
a scan reads with its name, size, cover and state.
|
|
|
|
Working the summary out reads every model folder, which takes a while with many models or on
|
|
a network drive. So the last one is kept (in memory, and in user/anomalous/scan_summary.json
|
|
for after a restart) and handed out at once (`?quick=1`) while a fresh one is worked out;
|
|
requests that come meanwhile wait for that same one instead of reading the folders again. The
|
|
models found on Civitai are kept with it and listed on request (GET /anomalous/scan_matched),
|
|
so the page's summary stays small."""
|
|
|
|
import asyncio
|
|
import os
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from aiohttp import web
|
|
|
|
from .activity_log import log_path
|
|
from .folder_types import get_active_scan_paths
|
|
from .metadata import get_metadata
|
|
from .path_utils import atomic_write_json
|
|
from .model_media import _preview_url_for_model
|
|
from .scan_report import locate_root
|
|
|
|
try:
|
|
from ..model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
|
|
except ImportError: # loaded outside the package (tests)
|
|
from model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
|
|
|
|
MAX_LISTED = 2000 # models listed per kind; the counts stay exact
|
|
MAX_PICKABLE = 5000 # models the picker lists; its total stays exact
|
|
COVER_SAMPLE = 48 # covers the scan card's band gets (it shows as many as fit)
|
|
# Model files in other formats: listed as "not scanned" (scans read only .safetensors).
|
|
OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sft", ".engine")
|
|
# Folders of the parts a drawing model loads: many are not on Civitai, and that is no fault.
|
|
COMPONENT_TYPES = ("vae", "clip", "text_encoders", "clip_vision", "upscale_models")
|
|
# What the picker needs of a model's metadata: its card badge (model_source.js) and its name.
|
|
PICKER_FIELDS = ("info_source", "unmatched_reason", "scan_choice", "not_drawing", "baseModel", "base_guess", "custom_name")
|
|
# New models' headers are read this many at a time: on a network drive (a NAS, a cloud server's
|
|
# model disk) each read waits on the network, and a thousand one after another take minutes.
|
|
# They are read through get_metadata, whose cache keeps them until the file changes (the page
|
|
# opened again reads none, and the picker after it finds them there).
|
|
HEAD_READERS = 16
|
|
MAX_SAVED_BYTES = 16 * 1024 * 1024 # the kept summary; MAX_LISTED keeps it far smaller
|
|
_last = {} # the last summary worked out ("summary", "at") and its models found on Civitai ("matched")
|
|
_working = None # the summary being worked out (an asyncio future), shared by the requests meanwhile
|
|
|
|
|
|
def _walk():
|
|
"""Each model file in the active scan folders once, as ((type, path_idx), base_dir, path,
|
|
other): the .safetensors files scans read, and (`other`) model files in other formats outside
|
|
hidden folders (tools keep their working files there)."""
|
|
seen = set()
|
|
for base_dir in get_active_scan_paths():
|
|
located = locate_root(base_dir) or ("", 0)
|
|
for root, _dirs, files in os.walk(base_dir):
|
|
hidden = any(part.startswith(".") for part in os.path.relpath(root, base_dir).split(os.sep) if part != ".")
|
|
for filename in files:
|
|
other = filename.lower().endswith(OTHER_MODEL_EXTENSIONS)
|
|
if not filename.endswith(".safetensors") and not (other and not hidden):
|
|
continue
|
|
path = os.path.join(root, filename)
|
|
real = os.path.realpath(path)
|
|
if real in seen:
|
|
continue
|
|
seen.add(real)
|
|
yield located, base_dir, path, other
|
|
|
|
|
|
def _looks_not_drawing(path):
|
|
"""Whether the header of a new model reads but names no drawing model kind."""
|
|
return bool(get_metadata(path).get("not_drawing"))
|
|
|
|
|
|
def _found_item(item, info):
|
|
"""A model found on Civitai, for the list of them: where it is, Civitai's name, its base model."""
|
|
model = info.get("model") if isinstance(info.get("model"), dict) else {}
|
|
names = [str(model.get("name") or ""), str(info.get("name") or "")]
|
|
return {**item, "name": " · ".join(name for name in names if name), "base": str(info.get("baseModel") or "")}
|
|
|
|
|
|
def _summarize(with_matched=False):
|
|
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
|
|
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
|
|
which the next online scan looks up. `skipped`: model files in other formats. `excluded`:
|
|
models set to "don't scan", not in the other counts. A new model not chosen about whose
|
|
header reads but names no drawing model kind carries `not_drawing` (counted too); only new
|
|
models' headers are read. Models in a component folder carry `component` (counted too) and
|
|
are listed again, matched ones as well, in `component_models` with their `state`
|
|
(matched | unmatched | new); `components` counts them. `covers`: see `_covers`."""
|
|
total = matched = 0
|
|
unmatched, new, skipped, excluded, components, scanned, to_read, found = [], [], [], [], [], [], [], []
|
|
for located, base_dir, path, other in _walk():
|
|
item = {"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
|
|
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
|
|
if other:
|
|
skipped.append(item)
|
|
continue
|
|
base = os.path.splitext(path)[0]
|
|
choice = scan_choice(base)
|
|
if choice == "skip":
|
|
excluded.append(item)
|
|
continue
|
|
total += 1
|
|
info = sidecar_info(base)
|
|
if located[0] in COMPONENT_TYPES:
|
|
item["component"] = True
|
|
components.append(item)
|
|
elif info is not None:
|
|
scanned.append((located, base_dir, path))
|
|
if info is not None and not is_unmatched(info):
|
|
matched += 1
|
|
item["state"] = "matched"
|
|
if with_matched and not item.get("component"):
|
|
found.append(_found_item(item, info))
|
|
continue
|
|
if info is None:
|
|
if not choice:
|
|
to_read.append((item, path))
|
|
item["state"] = "new"
|
|
new.append(item)
|
|
else:
|
|
item.update(state="unmatched", reason=unmatched_reason(info), base=info.get("baseModel") or "")
|
|
unmatched.append(item)
|
|
if to_read:
|
|
with ThreadPoolExecutor(max_workers=HEAD_READERS) as pool:
|
|
for (item, _path), not_drawing in zip(to_read, pool.map(_looks_not_drawing, [path for _item, path in to_read])):
|
|
if not_drawing:
|
|
item["not_drawing"] = True
|
|
return {
|
|
**({"matched_models": found} if with_matched else {}),
|
|
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
|
|
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
|
|
"damaged": sum(item["reason"] == "damaged" for item in unmatched),
|
|
"not_drawing": sum(bool(item.get("not_drawing")) for item in new),
|
|
"excluded": len(excluded), "excluded_models": excluded[:MAX_LISTED],
|
|
"unmatched_models": unmatched[:MAX_LISTED], "new_models": new[:MAX_LISTED],
|
|
"skipped": len(skipped), "skipped_models": skipped[:MAX_LISTED],
|
|
"components": {
|
|
"total": len(components),
|
|
**{state: sum(item["state"] == state for item in components) for state in ("matched", "unmatched", "new")},
|
|
"pending": sum(item.get("reason") in ("offline", "network") for item in components),
|
|
},
|
|
"component_models": components[:MAX_LISTED],
|
|
"covers": _covers(scanned),
|
|
}
|
|
|
|
|
|
def _modified(entry):
|
|
try:
|
|
return os.path.getmtime(entry[2])
|
|
except OSError:
|
|
return 0
|
|
|
|
|
|
def _covers(scanned):
|
|
"""Up to COVER_SAMPLE covers of the scanned models (components left out, they seldom have
|
|
one), the newest model files first, as {name, preview_url}: the scan card's band of pictures."""
|
|
covers = []
|
|
for located, base_dir, path in sorted(scanned, key=_modified, reverse=True):
|
|
url = _preview_url_for_model(located[0], located[1], base_dir, path)
|
|
if url:
|
|
covers.append({"name": os.path.splitext(os.path.basename(path))[0], "preview_url": url})
|
|
if len(covers) >= COVER_SAMPLE:
|
|
break
|
|
return covers
|
|
|
|
|
|
def _pickable():
|
|
"""Every model a scan reads (the models set to "don't scan" too), located like the summary's,
|
|
with its size, cover and what its card badge needs; `roots`: the name of each models folder
|
|
they are in and of the folder holding it, to tell two folders of one type apart."""
|
|
models, roots, total = [], {}, 0
|
|
for located, base_dir, path, other in _walk():
|
|
if other:
|
|
continue
|
|
total += 1
|
|
if len(models) >= MAX_PICKABLE:
|
|
continue
|
|
key = f"{located[0]}|{located[1]}"
|
|
if key not in roots:
|
|
roots[key] = {"type": located[0], "path_idx": located[1], "name": os.path.basename(os.path.normpath(base_dir)),
|
|
"parent": os.path.basename(os.path.dirname(os.path.normpath(base_dir)))}
|
|
meta = get_metadata(path)
|
|
try:
|
|
size = os.path.getsize(path)
|
|
except OSError:
|
|
size = 0
|
|
models.append({
|
|
"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
|
|
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/"), "size": size,
|
|
"preview_url": _preview_url_for_model(located[0], located[1], base_dir, path),
|
|
"metadata": {field: meta.get(field) for field in PICKER_FIELDS},
|
|
})
|
|
return {"models": models, "total": total, "roots": list(roots.values())}
|
|
|
|
|
|
def _summary_path():
|
|
return os.path.join(os.path.dirname(log_path()), "scan_summary.json")
|
|
|
|
|
|
def _work_out():
|
|
"""A fresh summary, kept as the last one (and on disk when it changed)."""
|
|
summary = _summarize(with_matched=True)
|
|
matched = summary.pop("matched_models")
|
|
changed = summary != _last.get("summary")
|
|
_last.update(summary=summary, matched=matched[:MAX_PICKABLE], matched_total=len(matched), at=time.time())
|
|
if changed:
|
|
try:
|
|
atomic_write_json(_summary_path(), {"at": _last["at"], "summary": summary}, max_bytes=MAX_SAVED_BYTES)
|
|
except (OSError, ValueError) as error:
|
|
print(f"[Anomalous Browser] Scan summary not kept: {error}")
|
|
return summary
|
|
|
|
|
|
async def _fresh():
|
|
global _working
|
|
if _working is None or _working.done():
|
|
_working = asyncio.ensure_future(asyncio.to_thread(_work_out))
|
|
return await asyncio.shield(_working)
|
|
|
|
|
|
def _kept():
|
|
"""The last summary ({"at", "summary"}), from memory or from disk; None before the first."""
|
|
if _last.get("summary"):
|
|
return {"at": _last["at"], "summary": _last["summary"]}
|
|
try:
|
|
if os.path.getsize(_summary_path()) > MAX_SAVED_BYTES:
|
|
return None
|
|
except OSError:
|
|
return None
|
|
data = read_json(_summary_path())
|
|
return data if data and isinstance(data.get("summary"), dict) else None
|
|
|
|
|
|
async def api_scan_summary(request):
|
|
"""GET /anomalous/scan_summary - counts, and the unmatched and new models. `?quick=1`: the
|
|
last one at once ({"at", "summary"}, or {} before the first), for the page to show while it
|
|
asks for a fresh one."""
|
|
if request.query.get("quick"):
|
|
return web.json_response(await asyncio.to_thread(_kept) or {})
|
|
return web.json_response(await _fresh())
|
|
|
|
|
|
async def api_scan_matched(request):
|
|
"""GET /anomalous/scan_matched - the models found on Civitai (components apart), as the last
|
|
summary saw them: {"models": [{type, path_idx, filename, rel, name, base}], "total"}."""
|
|
if "matched" not in _last:
|
|
await _fresh()
|
|
return web.json_response({"models": _last.get("matched") or [], "total": _last.get("matched_total", 0)})
|
|
|
|
|
|
async def api_scan_pickable(request):
|
|
"""GET /anomalous/scan_pickable - the models the scan settings' picker lists."""
|
|
return web.json_response(await asyncio.to_thread(_pickable))
|