Add CivitAI hash lookup with progressive UI feedback

- Added lookup_civitai_by_hash() to query CivitAI API by file hash
- Modified lookup-sources endpoint to process one model at a time
- UI now shows real-time progress with found/not-found counts
- Added Stop button to cancel ongoing lookup
- Computes SHA256 hashes for models that don't have them

This allows identifying models that were downloaded outside of
tracked sources and finding their CivitAI pages automatically.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ben Finklea
2026-01-25 13:26:51 -06:00
co-authored by Claude Opus 4.5
parent 77e81efa2d
commit 261d3c598c
3 changed files with 276 additions and 0 deletions
+85
View File
@@ -177,6 +177,8 @@ class ModelSyncPanel {
<option value="auto">Auto-Update Enabled</option>
</select>
<button class="modelsync-btn" data-action="scan">Scan</button>
<button class="modelsync-btn primary" data-action="lookup">Lookup Sources</button>
<button class="modelsync-btn" data-action="stop-lookup" style="display:none;background:#c00;">Stop</button>
<button class="modelsync-btn" data-action="check">Check Updates</button>
</div>
<div class="modelsync-models-list"></div>
@@ -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 {
+95
View File
@@ -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
+96
View File
@@ -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: