diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index aeea0d11..b8c9a645 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -6,7 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { translate } from '../utils/i18nHelpers.js'; -import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js'; +import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js'; import { formatFileSize } from '../utils/formatters.js'; import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js'; @@ -879,6 +879,26 @@ export class DownloadManager { this.updateTargetPath(); } + /** + * Synthesize a clickable URL for a single-download failure entry. + * Single downloads have no pasted URL, so the modal link is derived from + * the model/version ids (CivitAI) or the HF repo/file (HuggingFace). + */ + _buildSingleItemUrl({ modelId, versionId, source, repo = null, filename = null }) { + if (source === 'huggingface' && repo) { + const base = `https://huggingface.co/${encodeURI(repo)}`; + return filename ? `${base}/blob/${encodeURI('main')}/${encodeURI(filename)}` : base; + } + if (modelId) { + return buildCivitaiUrl({ + modelId, + versionId, + host: normalizeCivitaiPageHost(state?.global?.settings?.civitai_host), + }); + } + return null; + } + async executeDownloadWithProgress({ modelId, versionId, @@ -897,6 +917,7 @@ export class DownloadManager { } const displayName = versionName || `#${versionId}`; + const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false }; let ws = null; let updateProgress = () => { }; let cancelled = false; @@ -985,6 +1006,26 @@ export class DownloadManager { return true; } + if (!response?.success) { + this.loadingManager.setStatus(translate('modals.download.status.finalizing')); + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ + item: { + modelId, + versionId, + source, + url: this._buildSingleItemUrl({ modelId, versionId, source }), + }, + error: response?.error || 'Unknown error', + name: displayName, + }], + onRetry: () => this.executeDownloadWithProgress(retryParams), + }); + return false; + } + showToast('toast.loras.downloadCompleted', {}, 'success'); if (closeModal) { @@ -1019,7 +1060,21 @@ export class DownloadManager { console.log('Download cancelled by user:', downloadId); } else { console.error('Failed to download model version:', error); - showToast('toast.downloads.downloadError', { message: error?.message }, 'error'); + showDownloadBatchSummary({ + total: 1, + completed: 0, + failedItems: [{ + item: { + modelId, + versionId, + source, + url: this._buildSingleItemUrl({ modelId, versionId, source }), + }, + error: error?.message || 'Unknown error', + name: displayName, + }], + onRetry: () => this.executeDownloadWithProgress(retryParams), + }); } return false; } finally { @@ -1034,14 +1089,16 @@ export class DownloadManager { } } - async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) { + async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) { modalManager.closeModal('downloadModal'); this.loadingManager.restoreProgressBar(); - const totalFiles = this.hfSelectedFiles.length; + const filesToDownload = files || this.hfSelectedFiles; + const totalFiles = filesToDownload.length; const updateProgress = this.loadingManager.showDownloadProgress(totalFiles); let cancelled = false; let currentDownloadId = null; + const failedFiles = []; this.loadingManager.showCancelButton(async () => { if (cancelled) return; @@ -1060,7 +1117,7 @@ export class DownloadManager { for (let i = 0; i < totalFiles; i++) { if (cancelled) break; - const filename = this.hfSelectedFiles[i]; + const filename = filesToDownload[i]; updateProgress(0, completedDownloads, filename); this.loadingManager.setStatus(`Downloading ${filename}...`); @@ -1106,6 +1163,31 @@ export class DownloadManager { if (response?.success) { completedDownloads++; updateProgress(100, completedDownloads, filename); + } else { + failedFiles.push({ + item: { + source: 'huggingface', + repo: this.hfRepoId, + filename, + url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }), + }, + error: response?.error || 'Unknown error', + name: filename, + }); + } + } catch (err) { + if (!cancelled) { + console.error(`Failed to download HF file ${filename}:`, err); + failedFiles.push({ + item: { + source: 'huggingface', + repo: this.hfRepoId, + filename, + url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }), + }, + error: err?.message || 'Unknown error', + name: filename, + }); } } finally { ws.close(); @@ -1115,11 +1197,27 @@ export class DownloadManager { if (cancelled) { showToast('toast.downloads.downloadStopped', {}, 'info', `Download cancelled. ${completedDownloads} item(s) completed.`); - } else { - showToast('toast.loras.downloadCompleted', {}, 'success'); + await resetAndReload(true); + return true; } + if (failedFiles.length === 0) { + showToast('toast.loras.downloadCompleted', {}, 'success'); + await resetAndReload(true); + return true; + } + showDownloadBatchSummary({ + total: totalFiles, + completed: completedDownloads, + failedItems: failedFiles, + onRetry: () => this._downloadHfSingle({ + modelRoot, + targetFolder, + useDefaultPaths, + files: failedFiles.map((f) => f.item.filename), + }), + }); await resetAndReload(true); - return true; + return false; } catch (error) { if (!cancelled) { console.error('Failed to download HF model:', error); diff --git a/tests/frontend/managers/downloadManager.batchSummary.test.js b/tests/frontend/managers/downloadManager.batchSummary.test.js index 30cb057e..7c09e55c 100644 --- a/tests/frontend/managers/downloadManager.batchSummary.test.js +++ b/tests/frontend/managers/downloadManager.batchSummary.test.js @@ -28,6 +28,7 @@ const { downloadModel: vi.fn(), downloadHfModel: vi.fn(), cancelDownload: vi.fn(), + getPageState: vi.fn(() => ({})), }; // Shared loading manager served both via state.loadingManager and the @@ -314,4 +315,120 @@ describe('DownloadManager batch download summary flow', () => { ); expect(resetAndReloadMock).toHaveBeenCalledWith(true); }); + + it('shows the batch summary for a single CivitAI download resolved as a failure', async () => { + mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'rate limited' }); + + await manager.executeDownloadWithProgress({ + modelId: '111', + versionId: 'v1', + versionName: 'V1', + modelRoot: '/m', + useDefaultPaths: true, + }); + + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + const summary = showDownloadBatchSummaryMock.mock.calls[0][0]; + expect(summary.total).toBe(1); + expect(summary.completed).toBe(0); + expect(summary.failedItems).toHaveLength(1); + expect(summary.failedItems[0].item.modelId).toBe('111'); + expect(summary.failedItems[0].item.versionId).toBe('v1'); + expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111')); + expect(summary.failedItems[0].error).toBe('rate limited'); + expect(summary.failedItems[0].name).toBe('V1'); + expect(summary.onRetry).toEqual(expect.any(Function)); + expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success'); + }); + + it('shows the batch summary when a single download throws', async () => { + mockApiClient.downloadModel.mockRejectedValue(new Error('network down')); + + await manager.executeDownloadWithProgress({ + modelId: '111', + versionId: 'v1', + versionName: 'V1', + modelRoot: '/m', + useDefaultPaths: true, + }); + + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + const summary = showDownloadBatchSummaryMock.mock.calls[0][0]; + expect(summary.total).toBe(1); + expect(summary.completed).toBe(0); + expect(summary.failedItems).toHaveLength(1); + expect(summary.failedItems[0].error).toBe('network down'); + expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111')); + expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success'); + }); + + it('keeps the success toast and skips the summary for a successful single download', async () => { + mockApiClient.downloadModel.mockResolvedValue({ success: true }); + + const result = await manager.executeDownloadWithProgress({ + modelId: '111', + versionId: 'v1', + versionName: 'V1', + modelRoot: '/m', + useDefaultPaths: true, + }); + + expect(result).toBe(true); + expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success'); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + }); + + it('retries a failed single download through onRetry with the same params', async () => { + mockApiClient.downloadModel + .mockResolvedValueOnce({ success: false, error: 'rate limited' }) + .mockResolvedValueOnce({ success: true }); + + await manager.executeDownloadWithProgress({ + modelId: '111', + versionId: 'v1', + versionName: 'V1', + modelRoot: '/m', + useDefaultPaths: true, + }); + + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + const summary = showDownloadBatchSummaryMock.mock.calls[0][0]; + expect(summary.failedItems).toHaveLength(1); + + await summary.onRetry(); + + expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2); + const retryCall = mockApiClient.downloadModel.mock.calls[1]; + expect(retryCall[0]).toBe('111'); + expect(retryCall[1]).toBe('v1'); + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success'); + }); + + it('shows a summary for HF partial failure and retries only the failed files', async () => { + manager.hfRepoId = 'user/repo'; + manager.hfSelectedFiles = ['a.safetensors', 'b.safetensors']; + mockApiClient.downloadHfModel + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: 'denied' }); + + const result = await manager._downloadHfSingle({ modelRoot: '/m', useDefaultPaths: true }); + + expect(result).toBe(false); + expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1); + const summary = showDownloadBatchSummaryMock.mock.calls[0][0]; + expect(summary.total).toBe(2); + expect(summary.completed).toBe(1); + expect(summary.failedItems).toHaveLength(1); + expect(summary.failedItems[0].name).toBe('b.safetensors'); + expect(summary.failedItems[0].item.url).toEqual( + expect.stringContaining('huggingface.co/user/repo/blob/main/b.safetensors') + ); + + await summary.onRetry(); + + expect(mockApiClient.downloadHfModel).toHaveBeenCalledTimes(3); + expect(mockApiClient.downloadHfModel.mock.calls[2][0].filename).toBe('b.safetensors'); + }); });