diff --git a/js/modelsync-panel.js b/js/modelsync-panel.js index 56fca7d..6fafe6f 100644 --- a/js/modelsync-panel.js +++ b/js/modelsync-panel.js @@ -177,6 +177,8 @@ class ModelSyncPanel { + +
@@ -278,6 +280,12 @@ class ModelSyncPanel { case "scan": await this.scanModels(); break; + case "lookup": + await this.lookupSources(); + break; + case "stop-lookup": + this.stopLookup(); + break; case "check": await this.checkUpdates(); break; @@ -305,6 +313,83 @@ class ModelSyncPanel { } } + async lookupSources() { + this.lookupRunning = true; + let foundCount = 0; + let notFoundCount = 0; + let processedCount = 0; + + // Show stop button, hide lookup button + const lookupBtn = this.element?.querySelector('[data-action="lookup"]'); + const stopBtn = this.element?.querySelector('[data-action="stop-lookup"]'); + if (lookupBtn) lookupBtn.style.display = 'none'; + if (stopBtn) stopBtn.style.display = 'inline-block'; + + const processNext = async () => { + if (!this.lookupRunning) { + if (lookupBtn) lookupBtn.style.display = 'inline-block'; + if (stopBtn) stopBtn.style.display = 'none'; + this.setStatus(`Lookup stopped. Found ${foundCount}, not found ${notFoundCount}.`); + await this.refreshData(); + return; + } + + try { + const res = await fetch("/modelsync/lookup-sources", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await res.json(); + + if (data.status === "complete") { + this.lookupRunning = false; + if (lookupBtn) lookupBtn.style.display = 'inline-block'; + if (stopBtn) stopBtn.style.display = 'none'; + this.setStatus(`Lookup complete. Found ${foundCount} on CivitAI, ${notFoundCount} not found.`); + await this.refreshData(); + return; + } + + if (data.status === "found") { + foundCount++; + processedCount++; + } else if (data.status === "not_found") { + notFoundCount++; + processedCount++; + } else if (data.status === "hashing") { + // Just hashed, will lookup next call + } + + const progress = data.total > 0 ? Math.round(((data.total - data.remaining) / data.total) * 100) : 0; + this.setStatus(`[${progress}%] ${data.message} (${foundCount} found, ${data.remaining} remaining)`); + + // Update UI periodically + if (processedCount % 5 === 0) { + await this.refreshData(); + } + + // Continue to next model + setTimeout(processNext, 100); + + } catch (error) { + this.lookupRunning = false; + if (lookupBtn) lookupBtn.style.display = 'inline-block'; + if (stopBtn) stopBtn.style.display = 'none'; + this.setStatus("Lookup error: " + error.message); + await this.refreshData(); + } + }; + + this.setStatus("Starting source lookup..."); + processNext(); + } + + stopLookup() { + this.lookupRunning = false; + this.setStatus("Stopping lookup..."); + } + async checkUpdates() { this.setStatus("Checking for updates..."); try { diff --git a/lib/version_tracker.py b/lib/version_tracker.py index f6d80d4..ab6dddc 100644 --- a/lib/version_tracker.py +++ b/lib/version_tracker.py @@ -342,3 +342,98 @@ class VersionTracker: await asyncio.gather(*[check_one(m) for m in models]) return results + + async def lookup_civitai_by_hash(self, file_hash: str) -> Optional[Dict[str, Any]]: + """ + Look up a model on CivitAI by its file hash. + + Args: + file_hash: SHA256 hash of the model file + + Returns: + Dict with model info including source_url, or None if not found + """ + if not file_hash: + return None + + try: + session = await self._get_session() + url = f"{self.CIVITAI_API_BASE}/model-versions/by-hash/{file_hash}" + + async with session.get(url) as resp: + if resp.status == 404: + return None + if resp.status != 200: + print(f"CivitAI hash lookup failed: {resp.status}") + return None + + data = await resp.json() + + # Build the source URL + model_id = data.get("modelId") + version_id = data.get("id") + + if not model_id or not version_id: + return None + + source_url = f"https://civitai.com/models/{model_id}?modelVersionId={version_id}" + + # Get file info + files = data.get("files", []) + primary_file = next( + (f for f in files if f.get("primary", False)), + files[0] if files else {} + ) + + result = { + "source_type": "civitai", + "source_url": source_url, + "model_id": model_id, + "version_id": version_id, + "model_name": data.get("model", {}).get("name", "Unknown"), + "version_name": data.get("name"), + "size": primary_file.get("sizeKB", 0) * 1024, + "modified": data.get("publishedAt"), + } + + # Get hash info + hashes = primary_file.get("hashes", {}) + if "SHA256" in hashes: + result["remote_hash"] = hashes["SHA256"] + + return result + + except Exception as e: + print(f"Error looking up hash on CivitAI: {e}") + return None + + async def batch_lookup_by_hash( + self, + models: List[Dict[str, Any]], + max_concurrent: int = 3 + ) -> Dict[int, Dict[str, Any]]: + """ + Look up multiple models by hash on CivitAI concurrently. + + Args: + models: List of model dicts with 'id' and 'file_hash' keys + max_concurrent: Maximum concurrent requests (keep low to avoid rate limits) + + Returns: + Dict mapping model id to lookup result + """ + semaphore = asyncio.Semaphore(max_concurrent) + results = {} + + async def lookup_one(model: Dict[str, Any]): + async with semaphore: + file_hash = model.get("file_hash") + if file_hash and not model.get("source_url"): + # Small delay to avoid rate limiting + await asyncio.sleep(0.5) + info = await self.lookup_civitai_by_hash(file_hash) + if info: + results[model["id"]] = info + + await asyncio.gather(*[lookup_one(m) for m in models]) + return results diff --git a/server.py b/server.py index fad23ba..8f08130 100644 --- a/server.py +++ b/server.py @@ -71,6 +71,7 @@ class ModelSyncAPI: app.router.add_post("/modelsync/check", self.check_updates) app.router.add_post("/modelsync/download/{id}", self.download_update) + app.router.add_post("/modelsync/lookup-sources", self.lookup_sources) app.router.add_get("/modelsync/config", self.get_config) app.router.add_post("/modelsync/config", self.set_config) @@ -270,6 +271,101 @@ class ModelSyncAPI: "model_id": model_id, }) + async def lookup_sources(self, request: web.Request) -> web.Response: + """ + Look up source URL for ONE model at a time by querying CivitAI with file hash. + Call repeatedly to process all models with progress feedback. + """ + data = await request.json() if request.body_exists else {} + + # Get all models without source URLs + all_models = self.store.get_all_models() + models_to_lookup = [m for m in all_models if not m.get("source_url")] + + if not models_to_lookup: + return web.json_response({ + "status": "complete", + "message": "All models already have sources", + "remaining": 0, + "total": len(all_models), + }) + + # Find next model to process (prioritize ones with hashes) + model = None + for m in models_to_lookup: + if m.get("file_hash"): + model = m + break + + # If no model has hash, pick first one to hash + if not model: + model = models_to_lookup[0] + + # Step 1: Compute hash if needed + if not model.get("file_hash"): + try: + print(f"[ModelSync] Hashing {model['filename']}...") + file_hash = compute_sha256_chunked(model["path"]) + self.store.update_model(model["id"], {"file_hash": file_hash}) + model["file_hash"] = file_hash + return web.json_response({ + "status": "hashing", + "message": f"Computed hash for {model['filename']}", + "model": model["filename"], + "remaining": len(models_to_lookup), + "total": len(all_models), + }) + except Exception as e: + print(f"[ModelSync] Error hashing {model['filename']}: {e}") + return web.json_response({ + "status": "error", + "message": f"Failed to hash {model['filename']}: {str(e)}", + "remaining": len(models_to_lookup), + "total": len(all_models), + }) + + # Step 2: Look up on CivitAI + try: + print(f"[ModelSync] Looking up {model['filename']} on CivitAI...") + info = await self.tracker.lookup_civitai_by_hash(model["file_hash"]) + + if info: + self.store.update_model(model["id"], { + "source_url": info.get("source_url"), + "source_type": info.get("source_type", "civitai"), + "remote_hash": info.get("remote_hash"), + }) + return web.json_response({ + "status": "found", + "message": f"Found {model['filename']} on CivitAI: {info.get('model_name', 'Unknown')}", + "model": model["filename"], + "civitai_name": info.get("model_name"), + "source_url": info.get("source_url"), + "remaining": len(models_to_lookup) - 1, + "total": len(all_models), + }) + else: + # Mark as checked but not found (use source_type to track) + self.store.update_model(model["id"], { + "source_type": "not_found", + }) + return web.json_response({ + "status": "not_found", + "message": f"{model['filename']} not found on CivitAI", + "model": model["filename"], + "remaining": len(models_to_lookup) - 1, + "total": len(all_models), + }) + + except Exception as e: + print(f"[ModelSync] Error looking up {model['filename']}: {e}") + return web.json_response({ + "status": "error", + "message": f"Lookup failed for {model['filename']}: {str(e)}", + "remaining": len(models_to_lookup), + "total": len(all_models), + }) + # Configuration endpoints async def get_config(self, request: web.Request) -> web.Response: