feat(example-images): add force parameter to retry failed downloads

When force=true is passed via API, models in failed_models set are
re-downloaded instead of being skipped. On successful download, model is
removed from failed_models set.

This provides a manual batch repair mechanism for users when CivitAI
media server is temporarily down and causes empty folders.

Changes:
- Backend: Add force parameter to start_download(), _download_all_example_images(), _process_model()
- Backend: Skip failed_models check when force=true
- Backend: Remove model from failed_models on successful force retry
- Frontend: GlobalContextMenu now calls API with force=true directly
- Tests: Update mock to accept force parameter
This commit is contained in:
Will Miao
2026-01-18 21:58:12 +08:00
parent 7f2e8a0afb
commit b0f0158f98
3 changed files with 459 additions and 340 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -75,13 +75,6 @@ export class GlobalContextMenu extends BaseContextMenu {
} }
async downloadExampleImages(menuItem) { async downloadExampleImages(menuItem) {
const exampleImagesManager = window.exampleImagesManager;
if (!exampleImagesManager) {
showToast('globalContextMenu.downloadExampleImages.unavailable', {}, 'error');
return;
}
const downloadPath = state?.global?.settings?.example_images_path; const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) { if (!downloadPath) {
showToast('globalContextMenu.downloadExampleImages.missingPath', {}, 'warning'); showToast('globalContextMenu.downloadExampleImages.missingPath', {}, 'warning');
@@ -91,7 +84,48 @@ export class GlobalContextMenu extends BaseContextMenu {
menuItem?.classList.add('disabled'); menuItem?.classList.add('disabled');
try { try {
await exampleImagesManager.handleDownloadButton(); const optimize = state.global.settings.optimize_example_images;
const response = await fetch('/api/lm/download-example-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
force: true,
optimize,
model_types: ['lora', 'checkpoint', 'embedding']
})
});
const data = await response.json();
if (data.success) {
showToast('toast.exampleImages.downloadStarted', {}, 'success');
const exampleImagesManager = window.exampleImagesManager;
if (exampleImagesManager) {
exampleImagesManager.isDownloading = true;
exampleImagesManager.isPaused = false;
exampleImagesManager.isStopping = false;
exampleImagesManager.hasShownCompletionToast = false;
exampleImagesManager.startTime = new Date();
exampleImagesManager.updateUI(data.status);
exampleImagesManager.showProgressPanel();
exampleImagesManager.startProgressUpdates();
exampleImagesManager.updateDownloadButtonText();
const stopButton = document.getElementById('stopExampleDownloadBtn');
if (stopButton) {
stopButton.disabled = false;
}
}
} else {
showToast('toast.exampleImages.downloadStartFailed', { error: data.error }, 'error');
}
} catch (error) {
console.error('Failed to trigger example images download:', error);
showToast('toast.exampleImages.downloadStartFailed', {}, 'error');
} finally { } finally {
menuItem?.classList.remove('disabled'); menuItem?.classList.remove('disabled');
} }

View File

@@ -29,12 +29,14 @@ def restore_settings() -> None:
manager.settings.update(original) manager.settings.update(original)
async def test_start_download_requires_configured_path(monkeypatch: pytest.MonkeyPatch) -> None: async def test_start_download_requires_configured_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager()) manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
# Ensure example_images_path is not configured # Ensure example_images_path is not configured
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
settings_manager.settings.pop('example_images_path', None) settings_manager.settings.pop("example_images_path", None)
with pytest.raises(download_module.DownloadConfigurationError) as exc_info: with pytest.raises(download_module.DownloadConfigurationError) as exc_info:
await manager.start_download({}) await manager.start_download({})
@@ -46,7 +48,9 @@ async def test_start_download_requires_configured_path(monkeypatch: pytest.Monke
assert "skipping auto download" in result["message"] assert "skipping auto download" in result["message"]
async def test_start_download_bootstraps_progress_and_task(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: async def test_start_download_bootstraps_progress_and_task(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path) settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["libraries"] = {"default": {}} settings_manager.settings["libraries"] = {"default": {}}
@@ -58,7 +62,9 @@ async def test_start_download_bootstraps_progress_and_task(monkeypatch: pytest.M
started = asyncio.Event() started = asyncio.Event()
release = asyncio.Event() release = asyncio.Event()
async def fake_download(self, output_dir, optimize, model_types, delay, library_name): async def fake_download(
self, output_dir, optimize, model_types, delay, library_name, force=False
):
started.set() started.set()
await release.wait() await release.wait()
async with self._state_lock: async with self._state_lock:
@@ -129,7 +135,9 @@ async def test_pause_and_resume_flow(monkeypatch: pytest.MonkeyPatch, tmp_path)
await asyncio.wait_for(task, timeout=1) await asyncio.wait_for(task, timeout=1)
async def test_stop_download_transitions_to_stopped(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: async def test_stop_download_transitions_to_stopped(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path) settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["libraries"] = {"default": {}} settings_manager.settings["libraries"] = {"default": {}}
@@ -145,13 +153,13 @@ async def test_stop_download_transitions_to_stopped(monkeypatch: pytest.MonkeyPa
started.set() started.set()
await release.wait() await release.wait()
async with self._state_lock: async with self._state_lock:
if self._stop_requested and self._progress['status'] == 'stopping': if self._stop_requested and self._progress["status"] == "stopping":
self._progress['status'] = 'stopped' self._progress["status"] = "stopped"
else: else:
self._progress['status'] = 'completed' self._progress["status"] = "completed"
self._progress['end_time'] = time.time() self._progress["end_time"] = time.time()
self._stop_requested = False self._stop_requested = False
await self._broadcast_progress(status=self._progress['status']) await self._broadcast_progress(status=self._progress["status"])
async with self._state_lock: async with self._state_lock:
self._is_downloading = False self._is_downloading = False
self._download_task = None self._download_task = None
@@ -182,7 +190,9 @@ async def test_stop_download_transitions_to_stopped(monkeypatch: pytest.MonkeyPa
assert "stopped" in statuses assert "stopped" in statuses
async def test_pause_or_resume_without_running_download(monkeypatch: pytest.MonkeyPatch) -> None: async def test_pause_or_resume_without_running_download(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager()) manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
with pytest.raises(download_module.DownloadNotRunningError): with pytest.raises(download_module.DownloadNotRunningError):