diff --git a/lora-trigger-sheet b/lora-trigger-sheet index 45d1bc4..29a2839 100755 --- a/lora-trigger-sheet +++ b/lora-trigger-sheet @@ -15,6 +15,11 @@ import struct import sys from pathlib import Path +import hashlib +import time +from urllib.error import HTTPError, URLError +from urllib.request import urlopen, Request + def read_safetensors_meta(filepath): try: @@ -127,7 +132,106 @@ def html_id(): return hashlib.md5(str(time.monotonic_ns()).encode()).hexdigest()[:8] -def generate_html(loras): +# --- Source URL lookup cache --- + +CACHE_DIR = os.path.expanduser("~/.cache") +CACHE_FILE = os.path.join(CACHE_DIR, "lora-sources.json") + + +def load_source_cache(): + if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE) as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + return {} + + +def save_source_cache(cache): + os.makedirs(CACHE_DIR, exist_ok=True) + with open(CACHE_FILE, "w") as f: + json.dump(cache, f, indent=2) + + +def file_blake2b_hash(filepath): + h = hashlib.blake2b(digest_size=32) + with open(filepath, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +def _fetch_json(url, timeout=10): + req = Request(url, headers={"User-Agent": "lora-trigger-sheet/1.0"}) + with urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + + +def lookup_source(filepath, filename, cache): + now = int(time.time()) + cached = cache.get(filename) + if cached: + url = cached.get("url") + label = cached.get("label") + if url and (now - cached.get("last_checked", 0)) < 86400 * 30: + return url, label + + # CivitAI model-version lookup by BLAKE2b file hash + try: + h = file_blake2b_hash(filepath) + data = _fetch_json( + f"https://civitai.com/api/v1/model-versions/by-hash/{h}" + ) + if isinstance(data, dict): + model_id = data.get("modelId") + version_id = data.get("id") + if model_id: + url = f"https://civitai.com/models/{model_id}" + if version_id: + url += f"?modelVersionId={version_id}" + cache[filename] = { + "url": url, "label": "CivitAI", + "model_name": data.get("name", ""), + "last_checked": now, + } + return url, "CivitAI" + except (HTTPError, URLError, json.JSONDecodeError, OSError): + pass + + # Fallback: HuggingFace model search by filename + try: + q = ( + filename.replace(".safetensors", "") + .replace("_", " ") + .replace("-", " ") + ) + data = _fetch_json( + "https://huggingface.co/api/models" + f"?search={q}&sort=downloads&direction=-1&limit=3" + ) + if isinstance(data, list) and len(data) > 0: + model_id = data[0].get("modelId", "") + if model_id: + url = f"https://huggingface.co/{model_id}" + cache[filename] = { + "url": url, "label": "HuggingFace", + "model_name": model_id, "last_checked": now, + } + return url, "HuggingFace" + except (HTTPError, URLError, json.JSONDecodeError, OSError): + pass + + cache[filename] = {"url": None, "label": None, "last_checked": now} + return None, None + + +def generate_html(loras, sources=None): + if sources is None: + sources = {} rows = [] no_trigger_files = [] @@ -165,18 +269,24 @@ def generate_html(loras): no_trigger_files.append((fname_no_ext, tool, base)) triggers = "(none)" - rows.append((fname_no_ext, tool, base, triggers)) + src_url, src_label = sources.get(fname, (None, None)) + rows.append((fname_no_ext, tool, base, triggers, src_url or "", src_label or "")) num_files = len(rows) table_rows = "" - for fname, tool, base, triggers in rows: + for fname, tool, base, triggers, src_url, src_label in rows: + if src_url: + src_cell = f'{_e(src_label)}' + else: + src_cell = '' table_rows += ( f" \n" f" {_e(fname)}\n" f" {_e(tool)}\n" f" {_e(base)}\n" f" {triggers}\n" + f" {src_cell}\n" f" \n" ) @@ -209,8 +319,9 @@ tr:hover td{{background:#161b22}} .ml:hover{{border-bottom:1px solid #58a6ff}} .ht{{display:none}} mark.hl{{background:#264f78;color:#f0f6fc;border-radius:2px;padding:0 2px}} -.c0{{width:26%}}.c1{{width:9%}}.c2{{width:13%}}.c3{{width:52%}} -@media(max-width:768px){{.c1,.c2,td:nth-child(2),td:nth-child(3){{display:none}}.c0{{width:30%}}.c3{{width:70%}}}} +.sr a{{color:#58a6ff;text-decoration:none;font-size:0.78rem}} +.c0{{width:24%}}.c1{{width:8%}}.c2{{width:12%}}.c3{{width:48%}}.c4{{width:8%}} +@media(max-width:768px){{.c1,.c2,.c4,td:nth-child(2),td:nth-child(3),td:nth-child(5){{display:none}}.c0{{width:30%}}.c3{{width:70%}}}} .foot{{margin-top:20px;color:#8b949e}} .foot h2{{font-size:1rem;color:#f0f6fc;margin-bottom:6px}} .foot ul{{list-style:none;columns:3;font-size:0.82rem}} @@ -225,6 +336,7 @@ mark.hl{{background:#264f78;color:#f0f6fc;border-radius:2px;padding:0 2px}} Tool Base Trigger(s) +Source {table_rows} @@ -354,7 +466,20 @@ def main(): meta = read_safetensors_meta(fpath) loras.append((fname, meta)) - html = generate_html(loras) + # Look up source URLs with disk-backed cache + cache = load_source_cache() + sources = {} + total = len(loras) + for i, (fname, _) in enumerate(loras, 1): + fpath = os.path.join(target, fname) + url, label = lookup_source(fpath, fname, cache) + if url: + sources[fname] = (url, label) + if i % 10 == 0 or i == total: + print(f" source lookup: {i}/{total}", file=sys.stderr) + save_source_cache(cache) + + html = generate_html(loras, sources) if len(args) >= 2: out_path = os.path.abspath(os.path.expanduser(args[1])) @@ -364,7 +489,8 @@ def main(): with open(out_path, "w") as f: f.write(html) - print(f"Generated {out_path} with {len(loras)} LoRA entries") + out_count = sum(1 for v in sources.values() if v[0]) + print(f"Generated {out_path} with {len(loras)} LoRA entries ({out_count} with source links)") if __name__ == "__main__":