Files
DemonGatanjieuandClaude Opus 5.5 6f4347d88d Settings -> Network: a proxy for the plugin, and why Civitai cannot be reached
A Bilibili user's scan said Civitai could not be reached while their
browser reached it through a VPN app: the plugin's urllib requests only
use HTTPS_PROXY or the Windows proxy, not a PAC script or a browser-only
proxy.

net_proxy.py: the proxy address saved in api/config.json (NETWORK_PROXY)
that every request of the plugin goes through (Civitai, Hugging Face,
GitHub, translators), handed to the scan process as HTTP(S)_PROXY; empty
keeps the old behaviour. It also names why a request failed (timeout,
refused, dns, ssl, reset, proxy, http:<code>).

Settings -> Network has the address (SOCKS and malformed ones refused
with what to write) and Test connection, which calls 1.60's
/anomalous/civitai_check (brought here unchanged, now through the proxy
and with the failure's name). A scan that loses Civitai records why
(civitai_error) and the scan page and activity log say it with what to
try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:10:34 +08:00

170 lines
7.5 KiB
Python

"""What a scan did, model by model.
The scraper appends one JSON line per model it changed to a report file (--report-file),
plus events: "civitai_down" when Civitai stopped answering, "done" with the count of models
left as they were. When a scan ends, ScanJob turns the lines of all its folders into one
result: kept as the last scan (GET /anomalous/last_scan, shown on the scan page) and recorded
in the activity log, with each model located by type, path index and relative path so the
browser can open it (GET /anomalous/scan_model).
"""
import asyncio
import json
import os
import time
from aiohttp import web
import folder_paths
from .activity_log import MAX_CHANGES, add_entry, log_path
from .folder_types import get_active_folder_types
from .model_catalog import _model_info_for_path
from .path_utils import atomic_write_json, resolve_folder_subdir
MAX_FILES = 2000 # models kept in the last scan's result
FILE_FIELDS = ("status", "reason", "name", "base", "cover", "error")
def last_scan_path():
return os.path.join(os.path.dirname(log_path()), "last_scan.json")
def locate_root(directory):
"""(type, path_idx) of the models folder that is `directory`, shown types first; None if none."""
target = os.path.normcase(os.path.realpath(directory))
active = get_active_folder_types()
types = active + [name for name in folder_paths.folder_names_and_paths if name not in active]
for folder_type in types:
try:
paths = folder_paths.get_folder_paths(folder_type) or []
except Exception:
continue
for path_idx, path in enumerate(paths):
if os.path.normcase(os.path.realpath(path)) == target:
return folder_type, path_idx
return None
def read_report(path):
"""(model entries, events) from a report file; both empty when there is none."""
entries, events = [], []
try:
with open(path, encoding="utf-8") as source:
for line in source:
try:
item = json.loads(line)
except ValueError:
continue
if isinstance(item, dict):
(events if "event" in item else entries).append(item)
except OSError:
pass
return entries, events
class ScanJob:
"""One scan from start to end: what to call it, its options, and its folders' reports."""
def __init__(self, kind, data):
self.kind = kind # "all" | "picked" | "one"
self.options = {key: bool(data.get(key)) for key in ("offline_only", "retry_unmatched", "force_overwrite")}
self.started = time.time()
self.files = []
self.unchanged = 0
self.civitai_down = False
self.civitai_error = "" # why Civitai could not be reached (net_proxy.failure), the first one
self.errors = []
def add_folder(self, report_file, base_dir, located=None):
"""Reads (and removes) one scraper run's report. `located`: (type, path_idx) of base_dir."""
located = located or locate_root(base_dir) or ("", 0)
entries, events = read_report(report_file)
try:
os.remove(report_file)
except OSError:
pass
for event in events:
if event.get("event") == "done":
self.unchanged += int(event.get("unchanged") or 0)
elif event.get("event") == "civitai_down":
self.civitai_down = True
self.civitai_error = self.civitai_error or str(event.get("reason") or "")[:40]
for entry in entries:
path = entry.get("renamed_to") if entry.get("renamed_to") and entry.get("status") != "failed" else entry.get("file")
if not isinstance(path, str):
continue
item = {key: entry[key] for key in FILE_FIELDS if entry.get(key)}
item.update(type=located[0], path_idx=located[1], filename=os.path.basename(path),
rel=os.path.relpath(path, base_dir).replace(os.sep, "/"))
if entry.get("renamed_to") and item["status"] != "failed":
item["renamed_from"] = os.path.basename(entry.get("file") or "")
if entry.get("duplicate_of"):
item["duplicate"] = True
self.files.append(item)
def add_error(self, message):
self.errors.append(str(message)[:300])
def result(self):
files = self.files
counts = {
"matched": sum(item["status"] == "matched" for item in files),
"inferred": sum(item["status"] == "inferred" for item in files),
"failed": sum(item["status"] == "failed" for item in files),
"covers": sum(item.get("cover") == "new" for item in files),
"renamed": sum(bool(item.get("renamed_from")) for item in files),
"unchanged": self.unchanged,
}
return {
"kind": self.kind, "options": self.options, "started": self.started,
"finished": time.time(), "counts": counts, "civitai_down": self.civitai_down,
"civitai_error": self.civitai_error,
"errors": self.errors[:10], "files": files[:MAX_FILES],
}
def finish(self):
"""Keeps the result as the last scan and records it in the activity log. Never raises:
the scan itself is done, the record of it is a convenience."""
result = self.result()
try:
atomic_write_json(last_scan_path(), result)
except Exception as error: # noqa: BLE001
print(f"[Anomalous Browser] Could not keep the scan result: {error}")
try:
target = result["files"][0]["filename"] if self.kind == "one" and result["files"] else ""
detail = {"scan": {key: result[key] for key in ("kind", "options", "counts", "civitai_down", "civitai_error", "errors")},
"files": result["files"][:MAX_CHANGES], "total": len(self.files),
"seconds": round(result["finished"] - result["started"])}
add_entry("file", "scan_done", target, detail)
except Exception as error: # noqa: BLE001
print(f"[Anomalous Browser] Could not record the scan in the activity log: {error}")
return result
async def api_last_scan(request):
"""GET /anomalous/last_scan - the last scan's result, or {} before the first one."""
def read():
try:
with open(last_scan_path(), encoding="utf-8") as source:
data = json.load(source)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
return web.json_response(await asyncio.to_thread(read))
async def api_scan_model(request):
"""GET /anomalous/scan_model?type&path_idx&rel - one model as the grid lists it, to open it."""
rel = request.query.get("rel", "").replace("\\", "/").strip("/")
subfolder, filename = os.path.split(rel)
try:
path_idx = int(request.query.get("path_idx", 0))
base_dir, target_dir = resolve_folder_subdir(request.query.get("type", ""), path_idx, "/" + subfolder)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Invalid model location"}, status=400)
file_path = os.path.join(target_dir, filename)
if not filename or filename in (".", "..") or not os.path.isfile(file_path):
return web.json_response({"error": "Model not found"}, status=404)
model = await asyncio.to_thread(_model_info_for_path, request.query.get("type"), path_idx, base_dir, file_path)
return web.json_response({"model": model})