Files
Ben FinkleaandClaude Opus 4.5 0b6b9066b3 Fix multiple bugs: menu button visibility, source_hint handling, tempfile security
Bug fixes:
- Fix menu button not appearing: Use app.registerExtension() for proper
  ComfyUI initialization timing. The button now appears in the top bar
  next to the Manager button.
- Fix source_hint incorrectly saved as source_url: Pattern-matched hints
  like "Lightricks/LTX-Video" are not valid URLs and should not be used
  for update checks.
- Fix deprecated tempfile.mktemp(): Use secure mkstemp() instead to avoid
  race conditions.
- Add os import for file descriptor handling in update_scheduler.py

Test improvements:
- Update Playwright tests to hover over UI to reveal ComfyUI menu
- All 26 tests now passing (17 unit + 9 UI)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:46:47 -06:00

402 lines
14 KiB
Python

"""Background scheduler for automatic update checking and downloads."""
from __future__ import annotations
import asyncio
import os
import threading
import time
import shutil
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Callable, Dict, Any, List
from dataclasses import dataclass, field
from .metadata_store import MetadataStore
from .version_tracker import VersionTracker
from .hash_utils import compute_sha256_chunked, verify_file_hash
@dataclass
class DownloadTask:
"""Represents a pending download task."""
model_id: int
source_url: str
destination: Path
expected_hash: Optional[str] = None
status: str = "pending" # pending, downloading, verifying, complete, failed
progress: float = 0.0
error: Optional[str] = None
@dataclass
class SchedulerState:
"""Current state of the scheduler."""
running: bool = False
last_check: Optional[datetime] = None
next_check: Optional[datetime] = None
current_downloads: List[DownloadTask] = field(default_factory=list)
models_checked: int = 0
updates_found: int = 0
class UpdateScheduler:
"""Background scheduler for periodic update checks and downloads."""
def __init__(
self,
store: MetadataStore,
tracker: VersionTracker,
check_interval_hours: float = 24.0,
on_update_found: Optional[Callable[[Dict[str, Any]], None]] = None,
on_download_complete: Optional[Callable[[int, bool], None]] = None,
):
"""
Initialize the update scheduler.
Args:
store: MetadataStore instance
tracker: VersionTracker instance
check_interval_hours: Hours between automatic checks
on_update_found: Callback when update is found (receives model dict)
on_download_complete: Callback when download finishes (model_id, success)
"""
self.store = store
self.tracker = tracker
self.check_interval_hours = check_interval_hours
self.on_update_found = on_update_found
self.on_download_complete = on_download_complete
self._state = SchedulerState()
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._loop: Optional[asyncio.AbstractEventLoop] = None
@property
def state(self) -> SchedulerState:
"""Get current scheduler state."""
return self._state
@property
def is_running(self) -> bool:
"""Check if scheduler is running."""
return self._state.running
def start(self):
"""Start the background scheduler thread."""
if self._state.running:
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="ModelSync-Scheduler",
daemon=True
)
self._state.running = True
self._thread.start()
def stop(self):
"""Stop the background scheduler."""
if not self._state.running:
return
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5.0)
self._state.running = False
def _run_loop(self):
"""Main scheduler loop (runs in background thread)."""
# Create event loop for this thread
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
while not self._stop_event.is_set():
# Read current config values (so changes take effect)
auto_enabled = self.store.get_config("auto_update_enabled", "false")
interval_hours = float(self.store.get_config("check_interval_hours", "24"))
if auto_enabled.lower() == "true":
# Run the check
self._loop.run_until_complete(self._check_all_models())
# Process any pending auto-updates
self._loop.run_until_complete(self._download_pending_updates())
# Calculate next check time using config value
self._state.next_check = datetime.now() + timedelta(hours=interval_hours)
# Wait for interval or stop signal
interval_seconds = interval_hours * 3600
self._stop_event.wait(timeout=interval_seconds)
finally:
self._loop.close()
self._loop = None
async def _check_all_models(self):
"""Check all models for updates."""
self._state.last_check = datetime.now()
self._state.models_checked = 0
self._state.updates_found = 0
# Get all models with source URLs
models = self.store.get_all_models()
models_with_urls = [m for m in models if m.get("source_url")]
# Batch check for updates
results = await self.tracker.batch_check(models_with_urls)
for model in models_with_urls:
self._state.models_checked += 1
model_id = model["id"]
if model_id in results:
remote_info = results[model_id]
# Update remote info in database
updates = {
"remote_hash": remote_info.get("hash"),
"remote_size": remote_info.get("size"),
"remote_modified": remote_info.get("modified"),
"last_checked": datetime.now().isoformat(),
}
# Check if update is available
is_update = self.tracker.is_update_available(
local_hash=model.get("file_hash"),
local_size=model.get("file_size"),
remote_hash=remote_info.get("hash"),
remote_size=remote_info.get("size"),
)
updates["update_available"] = 1 if is_update else 0
self.store.update_model(model_id, updates)
if is_update:
self._state.updates_found += 1
if self.on_update_found:
# Refresh model data
updated_model = self.store.get_model(model_id)
self.on_update_found(updated_model)
async def _download_pending_updates(self):
"""Download updates for models with auto_update enabled."""
models = self.store.get_auto_update_models()
models_to_update = [m for m in models if m.get("update_available")]
max_concurrent = int(self.store.get_config("max_concurrent_downloads", "2"))
for model in models_to_update:
# Create download task
task = DownloadTask(
model_id=model["id"],
source_url=model["source_url"],
destination=Path(model["path"]),
expected_hash=model.get("remote_hash"),
)
self._state.current_downloads.append(task)
try:
success = await self._download_model(task)
if self.on_download_complete:
self.on_download_complete(model["id"], success)
finally:
self._state.current_downloads.remove(task)
async def _download_model(self, task: DownloadTask) -> bool:
"""
Download a model update.
Args:
task: DownloadTask to process
Returns:
True if successful
"""
task.status = "downloading"
backup_before = self.store.get_config("backup_before_update", "true")
verify_downloads = self.store.get_config("verify_downloads", "true")
backup_path = None
temp_path = None
try:
# Create backup if enabled
if backup_before.lower() == "true" and task.destination.exists():
backup_path = task.destination.with_suffix(
task.destination.suffix + ".bak"
)
shutil.copy2(task.destination, backup_path)
# Download to temp file (use mkstemp for security, not deprecated mktemp)
fd, temp_str = tempfile.mkstemp(suffix=task.destination.suffix)
os.close(fd) # Close the file descriptor, we'll write to it later
temp_path = Path(temp_str)
# Perform download based on source type
source_type = self.tracker.detect_source_type(task.source_url)
if source_type == "huggingface":
await self._download_from_huggingface(task, temp_path)
elif source_type == "civitai":
await self._download_from_civitai(task, temp_path)
else:
task.status = "failed"
task.error = f"Unsupported source type: {source_type}"
return False
# Verify hash if enabled
if verify_downloads.lower() == "true" and task.expected_hash:
task.status = "verifying"
if not verify_file_hash(temp_path, task.expected_hash):
task.status = "failed"
task.error = "Hash verification failed"
return False
# Move to final destination
shutil.move(str(temp_path), str(task.destination))
temp_path = None
# Update database
new_hash = compute_sha256_chunked(task.destination)
new_size = task.destination.stat().st_size
self.store.update_model(task.model_id, {
"file_hash": new_hash,
"file_size": new_size,
"update_available": 0,
"install_date": datetime.now().isoformat(),
})
# Remove backup
if backup_path and backup_path.exists():
backup_path.unlink()
task.status = "complete"
task.progress = 1.0
return True
except Exception as e:
task.status = "failed"
task.error = str(e)
# Restore from backup if available
if backup_path and backup_path.exists():
if task.destination.exists():
task.destination.unlink()
shutil.move(str(backup_path), str(task.destination))
return False
finally:
# Clean up temp file
if temp_path and temp_path.exists():
temp_path.unlink()
async def _download_from_huggingface(self, task: DownloadTask, dest: Path):
"""Download file from HuggingFace."""
try:
from huggingface_hub import hf_hub_download
parsed = self.tracker.parse_huggingface_url(task.source_url)
if not parsed:
raise ValueError(f"Could not parse HuggingFace URL: {task.source_url}")
repo_id, file_path = parsed
# Download to temp location
downloaded = hf_hub_download(
repo_id=repo_id,
filename=file_path,
local_dir=dest.parent,
local_dir_use_symlinks=False,
)
# Move to expected destination
shutil.move(downloaded, dest)
except ImportError:
raise ImportError("huggingface_hub not installed")
async def _download_from_civitai(self, task: DownloadTask, dest: Path):
"""Download file from CivitAI."""
import aiohttp
parsed = self.tracker.parse_civitai_url(task.source_url)
if not parsed:
raise ValueError(f"Could not parse CivitAI URL: {task.source_url}")
# Get download URL
if "version_id" in parsed:
download_url = f"https://civitai.com/api/download/models/{parsed['version_id']}"
else:
# Need to get version ID first
async with aiohttp.ClientSession() as session:
model_url = f"{self.tracker.CIVITAI_API_BASE}/models/{parsed['model_id']}"
async with session.get(model_url) as resp:
data = await resp.json()
version_id = data["modelVersions"][0]["id"]
download_url = f"https://civitai.com/api/download/models/{version_id}"
# Download file
async with aiohttp.ClientSession() as session:
async with session.get(download_url) as resp:
if resp.status != 200:
raise Exception(f"Download failed with status {resp.status}")
total_size = int(resp.headers.get("content-length", 0))
downloaded = 0
with open(dest, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
task.progress = downloaded / total_size
async def check_now(self) -> Dict[str, int]:
"""
Trigger an immediate check (can be called from main thread).
Returns:
Dict with 'checked' and 'updates_found' counts
"""
await self._check_all_models()
return {
"checked": self._state.models_checked,
"updates_found": self._state.updates_found,
}
async def download_update(self, model_id: int) -> bool:
"""
Download update for a specific model.
Args:
model_id: Model ID to update
Returns:
True if successful
"""
model = self.store.get_model(model_id)
if not model:
return False
task = DownloadTask(
model_id=model["id"],
source_url=model["source_url"],
destination=Path(model["path"]),
expected_hash=model.get("remote_hash"),
)
self._state.current_downloads.append(task)
try:
return await self._download_model(task)
finally:
self._state.current_downloads.remove(task)