combuddy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
combuddy/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
combuddy/__main__.py ADDED
@@ -0,0 +1,25 @@
1
+ import os, sys, threading, webbrowser
2
+ from .api import create_app
3
+
4
+ def default_db_path() -> str:
5
+ d = os.path.join(os.path.expanduser("~"), ".combuddy")
6
+ os.makedirs(d, exist_ok=True)
7
+ return os.path.join(d, "combuddy.sqlite")
8
+
9
+ def _static_dir() -> str:
10
+ return os.path.join(os.path.dirname(__file__), "web")
11
+
12
+ def build_app(db_path: str | None = None, static_dir: str | None = None):
13
+ return create_app(db_path or default_db_path(),
14
+ static_dir if static_dir is not None else _static_dir())
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ import uvicorn
18
+ host, port = "127.0.0.1", 8511
19
+ app = build_app()
20
+ threading.Timer(1.0, lambda: webbrowser.open(f"http://{host}:{port}")).start()
21
+ uvicorn.run(app, host=host, port=port)
22
+ return 0
23
+
24
+ if __name__ == "__main__":
25
+ sys.exit(main())
combuddy/api.py ADDED
@@ -0,0 +1,129 @@
1
+ import os, re, threading
2
+ from fastapi import FastAPI
3
+ from fastapi.responses import JSONResponse, FileResponse
4
+ from fastapi.staticfiles import StaticFiles
5
+ from . import db as dbm, config, stats, scan_service, queries, trash
6
+
7
+ def _sniff_image(path: str) -> str:
8
+ with open(path, "rb") as f:
9
+ head = f.read(12)
10
+ if head[:3] == b"\xff\xd8\xff":
11
+ return "image/jpeg"
12
+ if head[:8] == b"\x89PNG\r\n\x1a\n":
13
+ return "image/png"
14
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
15
+ return "image/webp"
16
+ return "application/octet-stream"
17
+
18
+ def create_app(db_path: str, static_dir: str | None = None) -> FastAPI:
19
+ app = FastAPI(title="combuddy")
20
+
21
+ def conn():
22
+ c = dbm.connect(db_path); dbm.init_schema(c); return c
23
+
24
+ @app.get("/api/stats")
25
+ def get_stats():
26
+ c = conn()
27
+ s = stats.get_stats(c)
28
+ s["scanning"] = scan_service.STATUS["running"]
29
+ s["scan"] = dict(scan_service.STATUS)
30
+ c.close()
31
+ return s
32
+
33
+ @app.get("/api/roots")
34
+ def get_roots():
35
+ c = conn()
36
+ rows = [dict(r) for r in config.get_roots(c)]
37
+ c.close()
38
+ return {"roots": rows}
39
+
40
+ @app.post("/api/roots")
41
+ def post_roots(body: dict):
42
+ c = conn(); config.set_roots(c, body.get("roots", [])); c.close()
43
+ return {"ok": True}
44
+
45
+ @app.get("/api/unreferenced")
46
+ def get_unreferenced():
47
+ c = conn(); rows = stats.get_unreferenced(c); c.close()
48
+ return {"models": rows}
49
+
50
+ @app.post("/api/scan")
51
+ def post_scan():
52
+ if scan_service.STATUS["running"]:
53
+ return JSONResponse({"started": False, "reason": "already running"}, status_code=409)
54
+ def _bg():
55
+ c = conn()
56
+ try:
57
+ scan_service.run_scan(c)
58
+ finally:
59
+ c.close()
60
+ threading.Thread(target=_bg, daemon=True).start()
61
+ return {"started": True}
62
+
63
+ @app.post("/api/scan/cancel")
64
+ def post_scan_cancel():
65
+ scan_service.STATUS["cancel"] = True
66
+ return {"ok": True}
67
+
68
+ @app.get("/api/settings")
69
+ def get_settings():
70
+ c = conn(); s = config.get_settings(c); c.close()
71
+ return s
72
+
73
+ @app.post("/api/settings")
74
+ def post_settings(body: dict):
75
+ c = conn(); config.set_settings(c, body or {}); s = config.get_settings(c); c.close()
76
+ return s
77
+
78
+ @app.get("/api/models")
79
+ def api_models(search: str = "", type: str = "", flag: str = ""):
80
+ c = conn(); rows = queries.list_models(c, search, type, flag); c.close()
81
+ return {"models": rows}
82
+
83
+ @app.get("/api/models/{model_id}")
84
+ def api_model(model_id: int):
85
+ c = conn(); d = queries.get_model_detail(c, model_id); c.close()
86
+ if d is None:
87
+ return JSONResponse({"error": "not found"}, status_code=404)
88
+ return d
89
+
90
+ @app.get("/api/workflows")
91
+ def api_workflows():
92
+ c = conn(); rows = queries.list_workflows(c); c.close()
93
+ return {"workflows": rows}
94
+
95
+ @app.get("/api/workflows/{workflow_id}")
96
+ def api_workflow(workflow_id: int):
97
+ c = conn(); d = queries.get_workflow_resolution(c, workflow_id); c.close()
98
+ if d is None:
99
+ return JSONResponse({"error": "not found"}, status_code=404)
100
+ return d
101
+
102
+ @app.post("/api/cleanup/trash")
103
+ def api_trash(body: dict):
104
+ c = conn(); res = trash.move_to_trash(c, body.get("model_ids", [])); c.close()
105
+ return res
106
+
107
+ @app.get("/api/cleanup/trash")
108
+ def api_list_trash():
109
+ c = conn(); rows = trash.list_trash(c); c.close()
110
+ return {"trash": rows}
111
+
112
+ @app.post("/api/cleanup/restore")
113
+ def api_restore(body: dict):
114
+ c = conn(); res = trash.restore(c, body.get("trash_ids", [])); c.close()
115
+ return res
116
+
117
+ @app.get("/api/preview/{sha256}")
118
+ def api_preview(sha256: str, hd: int = 0):
119
+ if not re.fullmatch(r"[0-9a-fA-F]{64}", sha256):
120
+ return JSONResponse({"error": "bad hash"}, status_code=404)
121
+ name = sha256 + ("_hd.jpg" if hd else ".jpg")
122
+ path = os.path.join(os.path.dirname(db_path), "previews", name)
123
+ if not os.path.isfile(path):
124
+ return JSONResponse({"error": "not found"}, status_code=404)
125
+ return FileResponse(path, media_type=_sniff_image(path))
126
+
127
+ if static_dir and os.path.isdir(static_dir):
128
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
129
+ return app
combuddy/civitai.py ADDED
@@ -0,0 +1,111 @@
1
+ import json, os, time, urllib.request, urllib.error
2
+
3
+ _API = "https://civitai.com/api/v1/model-versions/by-hash/"
4
+ _UA = "combuddy (model identity lookup)"
5
+ _TIMEOUT = 10
6
+ _DELAY = 0.3 # 礼貌延迟(秒),顺序请求
7
+
8
+ def _pick_preview(imgs: list) -> dict:
9
+ """预览优先图片;没有图片再退视频;都没有 type 标记则取第一张。"""
10
+ for want in ("image", "video"):
11
+ for im in imgs:
12
+ if im.get("type") == want:
13
+ return im
14
+ return imgs[0] if imgs else {}
15
+
16
+ def parse_version(data: dict) -> dict:
17
+ model = data.get("model") or {}
18
+ img = _pick_preview(data.get("images") or [])
19
+ return {
20
+ "name": model.get("name"),
21
+ "version_name": data.get("name"),
22
+ "base_model": data.get("baseModel"),
23
+ "model_type": model.get("type"),
24
+ "trigger_words": json.dumps(data.get("trainedWords") or []),
25
+ "nsfw_level": img.get("nsfwLevel"),
26
+ "civitai_url": f"https://civitai.com/models/{data.get('modelId')}?modelVersionId={data.get('id')}",
27
+ "image_url": img.get("url"),
28
+ }
29
+
30
+ def fetch_by_hash(sha256: str):
31
+ req = urllib.request.Request(_API + sha256, headers={"User-Agent": _UA})
32
+ try:
33
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
34
+ data = json.loads(r.read().decode())
35
+ return ("found", parse_version(data))
36
+ except urllib.error.HTTPError as e:
37
+ return ("notfound", None) if e.code == 404 else ("skip", None)
38
+ except Exception:
39
+ return ("skip", None)
40
+
41
+ def _sized(url: str, width: int) -> str:
42
+ """把 Civitai 图片 URL 的变换段改成 anim=false,width=N(视频取静态帧 + 限宽)。"""
43
+ parts = url.rsplit("/", 2)
44
+ if len(parts) == 3 and "civitai" in parts[0]:
45
+ return f"{parts[0]}/anim=false,width={width}/{parts[2]}"
46
+ return url
47
+
48
+ def download_image(url: str, dest: str) -> bool:
49
+ try:
50
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
51
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
52
+ data = r.read()
53
+ with open(dest, "wb") as f:
54
+ f.write(data)
55
+ return True
56
+ except Exception:
57
+ return False
58
+
59
+ def _previews_dir(conn) -> str:
60
+ for r in conn.execute("PRAGMA database_list"):
61
+ if r["name"] == "main" and r["file"]:
62
+ return os.path.join(os.path.dirname(r["file"]), "previews")
63
+ return "previews"
64
+
65
+ def _upsert(conn, model_id, sha256, found, ident=None, image_path=None):
66
+ ident = ident or {}
67
+ conn.execute(
68
+ """INSERT INTO civitai(model_id,sha256,found,name,version_name,base_model,model_type,
69
+ trigger_words,nsfw_level,civitai_url,image_path,checked_at)
70
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
71
+ ON CONFLICT(model_id) DO UPDATE SET sha256=excluded.sha256, found=excluded.found,
72
+ name=excluded.name, version_name=excluded.version_name, base_model=excluded.base_model,
73
+ model_type=excluded.model_type, trigger_words=excluded.trigger_words,
74
+ nsfw_level=excluded.nsfw_level, civitai_url=excluded.civitai_url,
75
+ image_path=excluded.image_path, checked_at=excluded.checked_at""",
76
+ (model_id, sha256, found, ident.get("name"), ident.get("version_name"),
77
+ ident.get("base_model"), ident.get("model_type"), ident.get("trigger_words"),
78
+ ident.get("nsfw_level"), ident.get("civitai_url"), image_path, time.time()))
79
+
80
+ def enrich_models(conn, progress=None, should_cancel=None, fetch=fetch_by_hash, download=download_image) -> dict:
81
+ pv = _previews_dir(conn); os.makedirs(pv, exist_ok=True)
82
+ rows = conn.execute(
83
+ """SELECT m.id, m.sha256 FROM models m LEFT JOIN civitai c ON c.model_id = m.id
84
+ WHERE m.sha256 IS NOT NULL AND (c.model_id IS NULL OR c.sha256 != m.sha256)
85
+ ORDER BY m.id""").fetchall()
86
+ total = len(rows); done = found = 0
87
+ if progress:
88
+ progress(0, total)
89
+ for r in rows:
90
+ if should_cancel and should_cancel():
91
+ break
92
+ kind, ident = fetch(r["sha256"])
93
+ if kind == "found":
94
+ image_path = None
95
+ url = ident.get("image_url")
96
+ if url:
97
+ sha = r["sha256"]
98
+ if download(_sized(url, 256), os.path.join(pv, sha + ".jpg")):
99
+ image_path = sha + ".jpg"
100
+ download(_sized(url, 1024), os.path.join(pv, sha + "_hd.jpg")) # HD 尽力而为
101
+ _upsert(conn, r["id"], r["sha256"], 1, ident, image_path)
102
+ conn.commit(); found += 1
103
+ elif kind == "notfound":
104
+ _upsert(conn, r["id"], r["sha256"], 0)
105
+ conn.commit()
106
+ # kind == "skip": 不写,下次重试
107
+ done += 1
108
+ if progress:
109
+ progress(done, total)
110
+ time.sleep(_DELAY)
111
+ return {"found": found, "checked": done, "total": total}
combuddy/config.py ADDED
@@ -0,0 +1,68 @@
1
+ import os, sqlite3
2
+
3
+ # 只 stat 这些已知精确路径的相对形态;绝不递归遍历。
4
+ def _classify(path: str) -> str | None:
5
+ base = os.path.basename(path.rstrip("/"))
6
+ if base == "workflows":
7
+ return "workflow"
8
+ if base == "models":
9
+ return "model"
10
+ # models 目录也可能直接被指认;有典型子目录则认作 model root
11
+ if os.path.isdir(os.path.join(path, "checkpoints")) or os.path.isdir(os.path.join(path, "loras")):
12
+ return "model"
13
+ return None
14
+
15
+ def detect_candidates(explicit: list[str] | None = None) -> list[dict]:
16
+ out: list[dict] = []
17
+ for p in (explicit or []):
18
+ rp = os.path.realpath(p)
19
+ if not os.path.isdir(rp):
20
+ continue
21
+ kind = _classify(rp)
22
+ if kind is None:
23
+ continue
24
+ out.append({"kind": kind, "path": rp, "label": os.path.basename(rp) or rp, "source": "manual"})
25
+ return out
26
+
27
+ def set_roots(conn: sqlite3.Connection, roots: list[dict]) -> None:
28
+ for r in roots:
29
+ conn.execute(
30
+ "INSERT OR IGNORE INTO roots(kind,path,label,source,enabled) VALUES(?,?,?,?,1)",
31
+ (r["kind"], os.path.realpath(r["path"]), r.get("label"), r.get("source", "manual")),
32
+ )
33
+ conn.commit()
34
+
35
+ def get_roots(conn: sqlite3.Connection, kind: str | None = None) -> list[sqlite3.Row]:
36
+ if kind:
37
+ return conn.execute("SELECT * FROM roots WHERE kind=? AND enabled=1", (kind,)).fetchall()
38
+ return conn.execute("SELECT * FROM roots WHERE enabled=1").fetchall()
39
+
40
+ def get_settings(conn: sqlite3.Connection) -> dict:
41
+ have = {r["key"]: r["value"] for r in conn.execute(
42
+ """SELECT key,value FROM meta WHERE key IN
43
+ ('auto_hash','hash_workers','hash_max_mbps','online_enrich','nsfw_blur_threshold')""")}
44
+ return {
45
+ "auto_hash": have.get("auto_hash", "1") == "1",
46
+ "hash_workers": int(have.get("hash_workers", "1")),
47
+ "hash_max_mbps": int(have.get("hash_max_mbps", "0")),
48
+ "online_enrich": have.get("online_enrich", "1") == "1",
49
+ "nsfw_blur_threshold": int(have.get("nsfw_blur_threshold", "1")),
50
+ }
51
+
52
+ def set_settings(conn: sqlite3.Connection, values: dict) -> None:
53
+ if "auto_hash" in values:
54
+ conn.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('auto_hash',?)",
55
+ ("1" if values["auto_hash"] else "0",))
56
+ if "hash_workers" in values:
57
+ w = max(1, min(int(values["hash_workers"]), 8))
58
+ conn.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('hash_workers',?)", (str(w),))
59
+ if "hash_max_mbps" in values:
60
+ m = max(0, int(values["hash_max_mbps"]))
61
+ conn.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('hash_max_mbps',?)", (str(m),))
62
+ if "online_enrich" in values:
63
+ conn.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('online_enrich',?)",
64
+ ("1" if values["online_enrich"] else "0",))
65
+ if "nsfw_blur_threshold" in values:
66
+ t = max(0, min(int(values["nsfw_blur_threshold"]), 32))
67
+ conn.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('nsfw_blur_threshold',?)", (str(t),))
68
+ conn.commit()
combuddy/db.py ADDED
@@ -0,0 +1,111 @@
1
+ import sqlite3
2
+
3
+ SCHEMA_VERSION = 3
4
+
5
+ def connect(db_path: str) -> sqlite3.Connection:
6
+ conn = sqlite3.connect(db_path, check_same_thread=False)
7
+ conn.row_factory = sqlite3.Row
8
+ conn.execute("PRAGMA journal_mode=WAL")
9
+ conn.execute("PRAGMA foreign_keys=ON")
10
+ return conn
11
+
12
+ def init_schema(conn: sqlite3.Connection) -> None:
13
+ conn.executescript(
14
+ """
15
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
16
+
17
+ CREATE TABLE IF NOT EXISTS roots (
18
+ id INTEGER PRIMARY KEY,
19
+ kind TEXT NOT NULL, -- 'model' | 'workflow'
20
+ path TEXT NOT NULL UNIQUE,
21
+ label TEXT,
22
+ source TEXT,
23
+ enabled INTEGER NOT NULL DEFAULT 1
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS models (
27
+ id INTEGER PRIMARY KEY,
28
+ root_id INTEGER NOT NULL REFERENCES roots(id),
29
+ path TEXT NOT NULL UNIQUE,
30
+ rel_path TEXT NOT NULL,
31
+ dir_type TEXT NOT NULL,
32
+ rel_in_type TEXT NOT NULL,
33
+ filename TEXT NOT NULL,
34
+ ext TEXT NOT NULL,
35
+ size INTEGER NOT NULL,
36
+ mtime REAL NOT NULL,
37
+ base_arch TEXT,
38
+ base_source TEXT,
39
+ header_meta TEXT, -- reserved for future enrichment, unpopulated in v1 (same as sha256)
40
+ sha256 TEXT,
41
+ precision TEXT,
42
+ param_count INTEGER,
43
+ display_name TEXT,
44
+ match_key TEXT NOT NULL,
45
+ name_key TEXT NOT NULL,
46
+ first_seen REAL NOT NULL,
47
+ last_scanned REAL NOT NULL
48
+ );
49
+ CREATE INDEX IF NOT EXISTS ix_models_match ON models(dir_type, match_key);
50
+ CREATE INDEX IF NOT EXISTS ix_models_name ON models(name_key);
51
+
52
+ CREATE TABLE IF NOT EXISTS workflows (
53
+ id INTEGER PRIMARY KEY,
54
+ root_id INTEGER NOT NULL REFERENCES roots(id),
55
+ path TEXT NOT NULL UNIQUE,
56
+ filename TEXT NOT NULL,
57
+ mtime REAL NOT NULL,
58
+ ref_count INTEGER NOT NULL DEFAULT 0,
59
+ parse_error TEXT,
60
+ last_scanned REAL NOT NULL
61
+ );
62
+
63
+ CREATE TABLE IF NOT EXISTS edges (
64
+ id INTEGER PRIMARY KEY,
65
+ workflow_id INTEGER NOT NULL REFERENCES workflows(id),
66
+ ref_string TEXT NOT NULL,
67
+ ref_key TEXT NOT NULL,
68
+ ref_dir_type TEXT,
69
+ node_type TEXT,
70
+ model_id INTEGER REFERENCES models(id),
71
+ match_kind TEXT,
72
+ UNIQUE(workflow_id, ref_string, node_type)
73
+ );
74
+ CREATE INDEX IF NOT EXISTS ix_edges_model ON edges(model_id);
75
+
76
+ CREATE TABLE IF NOT EXISTS trash (
77
+ id INTEGER PRIMARY KEY,
78
+ model_path TEXT NOT NULL,
79
+ rel_path TEXT,
80
+ dir_type TEXT,
81
+ size INTEGER,
82
+ trashed_at REAL NOT NULL,
83
+ trash_path TEXT NOT NULL
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS civitai (
87
+ model_id INTEGER PRIMARY KEY REFERENCES models(id) ON DELETE CASCADE,
88
+ sha256 TEXT NOT NULL,
89
+ found INTEGER NOT NULL,
90
+ name TEXT,
91
+ version_name TEXT,
92
+ base_model TEXT,
93
+ model_type TEXT,
94
+ trigger_words TEXT,
95
+ nsfw_level INTEGER,
96
+ civitai_url TEXT,
97
+ image_path TEXT,
98
+ checked_at REAL NOT NULL
99
+ );
100
+ """
101
+ )
102
+ # migrate existing (pre-v2) dbs: add columns the CREATE-IF-NOT-EXISTS above can't add
103
+ have = {r["name"] for r in conn.execute("PRAGMA table_info(models)")}
104
+ for col, decl in (("precision", "TEXT"), ("param_count", "INTEGER"), ("display_name", "TEXT")):
105
+ if col not in have:
106
+ conn.execute(f"ALTER TABLE models ADD COLUMN {col} {decl}")
107
+ conn.execute(
108
+ "INSERT OR REPLACE INTO meta(key,value) VALUES('schema_version',?)",
109
+ (str(SCHEMA_VERSION),),
110
+ )
111
+ conn.commit()
combuddy/hashes.py ADDED
@@ -0,0 +1,102 @@
1
+ import hashlib, threading, time
2
+
3
+ _CHUNK = 8 << 20 # 8 MiB,与实测吞吐一致
4
+ _MAX_WORKERS = 8
5
+ _CANCELLED = object()
6
+
7
+ class _Throttle:
8
+ """全局令牌桶:把累计已读字节配速到 max_mbps。max_mbps<=0 表示不限速。"""
9
+ def __init__(self, max_mbps: int):
10
+ self.bps = int(max_mbps) * 1_000_000 if max_mbps and max_mbps > 0 else 0
11
+ self.lock = threading.Lock()
12
+ self.start = time.monotonic()
13
+ self.bytes = 0
14
+
15
+ def account(self, n: int) -> None:
16
+ if not self.bps:
17
+ return
18
+ with self.lock:
19
+ self.bytes += n
20
+ target = self.start + self.bytes / self.bps
21
+ wait = target - time.monotonic()
22
+ if wait > 0:
23
+ time.sleep(wait)
24
+
25
+ def sha256_file(path, throttle=None, should_cancel=None):
26
+ """流式 sha256。返回摘要 / None(读失败)/ _CANCELLED(仅当传入 should_cancel 且中途取消)。"""
27
+ h = hashlib.sha256()
28
+ try:
29
+ with open(path, "rb", buffering=0) as f:
30
+ while True:
31
+ if should_cancel and should_cancel():
32
+ return _CANCELLED
33
+ b = f.read(_CHUNK)
34
+ if not b:
35
+ break
36
+ h.update(b)
37
+ if throttle:
38
+ throttle.account(len(b))
39
+ except OSError:
40
+ return None
41
+ return h.hexdigest()
42
+
43
+ def compute_hashes(conn, workers=1, max_mbps=0, progress=None, should_cancel=None) -> dict:
44
+ rows = conn.execute(
45
+ "SELECT id, path FROM models WHERE sha256 IS NULL ORDER BY size").fetchall()
46
+ total = len(rows)
47
+ if progress:
48
+ progress(0, total)
49
+ workers = max(1, min(int(workers or 1), _MAX_WORKERS))
50
+ throttle = _Throttle(max_mbps)
51
+ write_lock = threading.Lock()
52
+ state_lock = threading.Lock()
53
+ idx_lock = threading.Lock()
54
+ state = {"done": 0, "hashed": 0, "errors": 0, "cancelled": False}
55
+ idx = {"i": 0}
56
+
57
+ def next_row():
58
+ with idx_lock:
59
+ i = idx["i"]
60
+ if i >= total:
61
+ return None
62
+ idx["i"] = i + 1
63
+ return rows[i]
64
+
65
+ def worker():
66
+ while True:
67
+ if should_cancel and should_cancel():
68
+ with state_lock:
69
+ state["cancelled"] = True
70
+ return
71
+ r = next_row()
72
+ if r is None:
73
+ return
74
+ res = sha256_file(r["path"], throttle, should_cancel)
75
+ if res is _CANCELLED:
76
+ with state_lock:
77
+ state["cancelled"] = True
78
+ return
79
+ with write_lock:
80
+ if res is not None:
81
+ conn.execute("UPDATE models SET sha256=? WHERE id=?", (res, r["id"]))
82
+ conn.commit() # 逐文件提交 → 断点续跑
83
+ with state_lock:
84
+ state["done"] += 1
85
+ if res is None:
86
+ state["errors"] += 1
87
+ else:
88
+ state["hashed"] += 1
89
+ d = state["done"]
90
+ if progress:
91
+ progress(d, total)
92
+
93
+ if workers == 1:
94
+ worker()
95
+ else:
96
+ threads = [threading.Thread(target=worker) for _ in range(workers)]
97
+ for t in threads:
98
+ t.start()
99
+ for t in threads:
100
+ t.join()
101
+ return {"hashed": state["hashed"], "errors": state["errors"],
102
+ "total": total, "cancelled": state["cancelled"]}