feat: support grouped download progress for multiple active downloads

- Add getActiveDownloadEntriesForMissing to find all active downloads
  targeting the same missing model entry.
- Refactor resolve_download_methods.js to extract status labels,
  progress markup builders, and snapshot helpers.
- Render multiple concurrent downloads in a grouped progress list
  when more than one download is active for the same missing model.
- Update attachDownloadActionHandlers to resolve downloadId from
  dataset attributes so grouped controls target the right download.
- Add CSS for grouped download progress items in the missing models UI.
- Refresh huggingface-author-index.json timestamp.
This commit is contained in:
Dariusz L
2026-06-27 23:51:43 +02:00
parent aa91d1410c
commit c79e217a42
3 changed files with 234 additions and 97 deletions
File diff suppressed because one or more lines are too long
+40
View File
@@ -404,6 +404,46 @@
display: none;
}
.mr-download-progress-list {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-8);
}
.mr-download-progress-item {
padding: 8px;
border: 1px solid var(--mr-border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.025);
}
.mr-download-progress-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mr-spacing-8);
margin-bottom: 5px;
color: var(--mr-text-muted);
font-size: 11px;
font-weight: 700;
}
.mr-download-progress-item-head > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-download-progress-item-head > span:last-child {
flex: 0 0 auto;
color: var(--mr-text);
}
.mr-download-progress-item .mr-progress-container {
margin-top: 0;
}
.mr-civitai-open-btn {
background: transparent;
color: #ff9800;
+193 -96
View File
@@ -776,6 +776,14 @@ export const resolveDownloadMethods = {
return null;
},
getActiveDownloadEntriesForMissing(missing) {
if (!missing) return [];
const missingKey = this.getDownloadStateKey(missing);
return Object.entries(this.activeDownloads || {})
.filter(([, info]) => info?.missing && this.getDownloadStateKey(info.missing) === missingKey)
.map(([downloadId, info]) => ({ downloadId, info }));
},
getDownloadSnapshotForMissing(missing) {
const active = this.getActiveDownloadEntryForMissing(missing);
if (active) {
@@ -813,7 +821,7 @@ export const resolveDownloadMethods = {
},
attachDownloadActionHandlers(progressDiv, downloadId) {
if (!progressDiv || !downloadId) return;
if (!progressDiv) return;
const bindInstantAction = (button, handler) => {
if (!button || button._hasListener) return;
button._hasListener = true;
@@ -834,108 +842,172 @@ export const resolveDownloadMethods = {
button.addEventListener('click', run);
};
progressDiv.querySelectorAll('.cancel-download-btn, .cancel-download-btn-pending').forEach(cancelBtn => {
bindInstantAction(cancelBtn, () => this.cancelDownload(downloadId));
bindInstantAction(cancelBtn, () => {
const targetDownloadId = cancelBtn.dataset.downloadId || downloadId;
if (targetDownloadId) this.cancelDownload(targetDownloadId);
});
});
progressDiv.querySelectorAll('.pause-download-btn').forEach(pauseBtn => {
bindInstantAction(pauseBtn, () => this.pauseDownload(downloadId));
bindInstantAction(pauseBtn, () => {
const targetDownloadId = pauseBtn.dataset.downloadId || downloadId;
if (targetDownloadId) this.pauseDownload(targetDownloadId);
});
});
progressDiv.querySelectorAll('.resume-download-btn').forEach(resumeBtn => {
bindInstantAction(resumeBtn, () => this.resumeDownload(downloadId));
bindInstantAction(resumeBtn, () => {
const targetDownloadId = resumeBtn.dataset.downloadId || downloadId;
if (targetDownloadId) this.resumeDownload(targetDownloadId);
});
});
},
renderDownloadSnapshot(downloadId, snapshot, elements = {}) {
if (!snapshot) return;
getDownloadProgressStatusLabel(status = '', percent = 0) {
if (status === 'downloading') return `${Math.round(percent)}%`;
if (status === 'starting') return 'Starting';
if (status === 'paused') return 'Paused';
if (status === 'completed_checking') return 'Checking';
return String(status || '').replace(/_/g, ' ') || 'Download';
},
const { progressDiv, downloadBtn } = elements.progressDiv || elements.downloadBtn
? elements
: this.resolveDownloadUiElements(snapshot);
const progress = snapshot.progress || {};
const status = snapshot.status || progress.status || '';
const isActive = snapshot.isActive || Boolean(downloadId && this.activeDownloads?.[downloadId]);
isDownloadProgressStatus(status = '', isActive = false) {
const terminalStatuses = new Set(['cancelling', 'cancelled', 'error', 'refresh_error', 'completed_checking', 'completed']);
const shouldRenderProgress = status === 'starting'
return status === 'starting'
|| status === 'downloading'
|| status === 'paused'
|| (isActive && !terminalStatuses.has(status));
},
if (progressDiv) {
progressDiv.classList.remove('mr-is-hidden');
progressDiv.classList.add('mr-is-visible');
renderDownloadSnapshotMarkup(downloadId, snapshot) {
const progress = snapshot.progress || {};
const status = snapshot.status || progress.status || '';
const isActive = snapshot.isActive || Boolean(downloadId && this.activeDownloads?.[downloadId]);
const shouldRenderProgress = this.isDownloadProgressStatus(status, isActive);
if (shouldRenderProgress) {
const canCancel = Boolean(downloadId);
const backend = String(progress.download_backend || snapshot.downloadBackend || snapshot.download_backend || '').toLowerCase();
const isAria2 = backend === 'aria2';
const isPaused = status === 'paused';
const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0));
const downloaded = this.formatBytes(progress.downloaded || 0);
const total = this.formatBytes(progress.total_size || 0);
const progressMeta = this.formatDownloadProgressMeta(progress);
const leftText = progress.total_size
? `${downloaded} / ${total} (${percent}%)${isPaused ? ' - Paused' : ''}`
: '<span class="mr-info-accent-text">Connecting...</span>';
let actionClass = canCancel ? 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm' : '';
let actionText = canCancel ? 'Cancel' : '';
let actionsHtml = '';
if (canCancel && isAria2 && isPaused) {
actionClass = 'resume-download-btn mr-btn mr-btn-primary mr-btn-sm';
actionText = 'Resume';
} else if (canCancel && isAria2 && status === 'downloading') {
actionClass = 'pause-download-btn mr-btn mr-btn-secondary mr-btn-sm';
actionText = 'Pause';
}
if (canCancel && isAria2 && (isPaused || status === 'downloading')) {
const safeDownloadId = this.escapeHtml(String(downloadId));
const toggleButton = isPaused
? `<button class="resume-download-btn mr-btn mr-btn-primary mr-btn-sm" data-download-id="${safeDownloadId}">Resume</button>`
: `<button class="pause-download-btn mr-btn mr-btn-secondary mr-btn-sm" data-download-id="${safeDownloadId}">Pause</button>`;
actionsHtml = `${toggleButton}<button class="cancel-download-btn mr-btn mr-btn-danger mr-btn-sm" data-download-id="${safeDownloadId}">Cancel</button>`;
}
progressDiv.innerHTML = this.renderProgressWithAction({
percent,
leftText,
rightText: progressMeta,
actionClass,
actionText,
actionDataAttr: canCancel ? `data-download-id="${downloadId}"` : '',
actionsHtml,
contextMenuModel: this.getDownloadFolderContext(progress, snapshot)
});
this.attachDownloadActionHandlers(progressDiv, downloadId);
} else if (status === 'cancelled') {
progressDiv.innerHTML = this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning');
} else if (status === 'cancelling') {
progressDiv.innerHTML = this.renderDownloadStatusMessage(
snapshot.message || 'Cancelling download...',
snapshot.type || 'info',
progress,
snapshot
);
} else if (status === 'error' || status === 'refresh_error') {
progressDiv.innerHTML = this.renderDownloadStatusMessage(
snapshot.message || progress.error || 'Download failed',
snapshot.type || (status === 'refresh_error' ? 'warning' : 'error'),
progress,
snapshot
);
} else if (status === 'completed_checking') {
progressDiv.innerHTML = this.renderDownloadStatusMessage(
snapshot.message || 'Download complete. Checking local matches...',
snapshot.type || 'success',
progress,
snapshot
);
} else if (status === 'completed') {
progressDiv.innerHTML = this.renderDownloadStatusMessage(
snapshot.message || 'Download complete. Local matches updated.',
snapshot.type || 'success',
progress,
snapshot
);
if (shouldRenderProgress) {
const canCancel = Boolean(downloadId);
const backend = String(progress.download_backend || snapshot.downloadBackend || snapshot.download_backend || '').toLowerCase();
const isAria2 = backend === 'aria2';
const isPaused = status === 'paused';
const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0));
const downloaded = this.formatBytes(progress.downloaded || 0);
const total = this.formatBytes(progress.total_size || 0);
const progressMeta = this.formatDownloadProgressMeta(progress);
const leftText = progress.total_size
? `${downloaded} / ${total} (${percent}%)${isPaused ? ' - Paused' : ''}`
: '<span class="mr-info-accent-text">Connecting...</span>';
let actionClass = canCancel ? 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm' : '';
let actionText = canCancel ? 'Cancel' : '';
let actionsHtml = '';
if (canCancel && isAria2 && isPaused) {
actionClass = 'resume-download-btn mr-btn mr-btn-primary mr-btn-sm';
actionText = 'Resume';
} else if (canCancel && isAria2 && status === 'downloading') {
actionClass = 'pause-download-btn mr-btn mr-btn-secondary mr-btn-sm';
actionText = 'Pause';
}
if (canCancel && isAria2 && (isPaused || status === 'downloading')) {
const safeDownloadId = this.escapeHtml(String(downloadId));
const toggleButton = isPaused
? `<button class="resume-download-btn mr-btn mr-btn-primary mr-btn-sm" data-download-id="${safeDownloadId}">Resume</button>`
: `<button class="pause-download-btn mr-btn mr-btn-secondary mr-btn-sm" data-download-id="${safeDownloadId}">Pause</button>`;
actionsHtml = `${toggleButton}<button class="cancel-download-btn mr-btn mr-btn-danger mr-btn-sm" data-download-id="${safeDownloadId}">Cancel</button>`;
}
return this.renderProgressWithAction({
percent,
leftText,
rightText: progressMeta,
actionClass,
actionText,
actionDataAttr: canCancel ? `data-download-id="${downloadId}"` : '',
actionsHtml,
contextMenuModel: this.getDownloadFolderContext(progress, snapshot)
});
}
if (status === 'cancelled') {
return this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning');
}
if (status === 'cancelling') {
return this.renderDownloadStatusMessage(snapshot.message || 'Cancelling download...', snapshot.type || 'info', progress, snapshot);
}
if (status === 'error' || status === 'refresh_error') {
return this.renderDownloadStatusMessage(
snapshot.message || progress.error || 'Download failed',
snapshot.type || (status === 'refresh_error' ? 'warning' : 'error'),
progress,
snapshot
);
}
if (status === 'completed_checking') {
return this.renderDownloadStatusMessage(snapshot.message || 'Download complete. Checking local matches...', snapshot.type || 'success', progress, snapshot);
}
if (status === 'completed') {
return this.renderDownloadStatusMessage(snapshot.message || 'Download complete. Local matches updated.', snapshot.type || 'success', progress, snapshot);
}
return '';
},
buildActiveDownloadSnapshot(downloadId, info = {}) {
const progress = this.applyPendingDownloadStatus?.(
info,
info.lastProgress || { status: info.lastStatus || 'starting', progress: 0 }
) || info.lastProgress || {};
return {
...(info.statusSnapshot || {}),
downloadId,
missing: info.missing,
category: info.category || '',
filename: progress.filename || info.filename || '',
downloadPath: progress.path || info.downloadPath || '',
downloadDirectory: progress.directory || info.downloadDirectory || '',
progress,
status: progress.status || info.lastStatus || 'starting',
isActive: true
};
},
renderDownloadProgressGroupItem(downloadId, snapshot) {
const progress = snapshot.progress || {};
const status = snapshot.status || progress.status || '';
const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0));
const filename = progress.filename || snapshot.filename || 'Download';
const statusLabel = this.getDownloadProgressStatusLabel(status, percent);
const itemIdAttr = downloadId ? ` data-download-id="${this.escapeHtml(String(downloadId))}"` : '';
return `
<div class="mr-download-progress-item"${itemIdAttr}>
<div class="mr-download-progress-item-head">
<span data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>
<span>${this.escapeHtml(statusLabel)}</span>
</div>
${this.renderDownloadSnapshotMarkup(downloadId, snapshot)}
</div>
`;
},
renderDownloadProgressGroupForMissing(missing, progressDiv, { includeDownloadId = '', includeSnapshot = null } = {}) {
if (!missing || !progressDiv) return false;
const activeEntries = this.getActiveDownloadEntriesForMissing(missing);
const renderedIds = new Set();
const items = activeEntries.map(({ downloadId, info }) => {
renderedIds.add(String(downloadId));
return this.renderDownloadProgressGroupItem(downloadId, this.buildActiveDownloadSnapshot(downloadId, info));
});
if (includeSnapshot && (!includeDownloadId || !renderedIds.has(String(includeDownloadId)))) {
items.push(this.renderDownloadProgressGroupItem(includeDownloadId, includeSnapshot));
}
if (items.length <= 1) return false;
progressDiv.classList.remove('mr-is-hidden');
progressDiv.classList.add('mr-is-visible');
progressDiv.innerHTML = `<div class="mr-download-progress-list">${items.join('')}</div>`;
this.attachDownloadActionHandlers(progressDiv, includeDownloadId);
return true;
},
updateDownloadButtonForSnapshot(downloadBtn, progress, status, shouldRenderProgress) {
if (!downloadBtn) return;
if (shouldRenderProgress) {
@@ -979,6 +1051,39 @@ export const resolveDownloadMethods = {
}
},
renderDownloadSnapshot(downloadId, snapshot, elements = {}) {
if (!snapshot) return;
const { progressDiv, downloadBtn } = elements.progressDiv || elements.downloadBtn
? elements
: this.resolveDownloadUiElements(snapshot);
const progress = snapshot.progress || {};
const status = snapshot.status || progress.status || '';
const isActive = snapshot.isActive || Boolean(downloadId && this.activeDownloads?.[downloadId]);
const shouldRenderProgress = this.isDownloadProgressStatus(status, isActive);
const missingForGroup = snapshot.missing || this.activeDownloads?.[downloadId]?.missing || null;
if (
progressDiv &&
this.renderDownloadProgressGroupForMissing(missingForGroup, progressDiv, {
includeDownloadId: downloadId,
includeSnapshot: snapshot
})
) {
this.updateDownloadButtonForSnapshot(downloadBtn, progress, status, shouldRenderProgress);
return;
}
if (progressDiv) {
progressDiv.classList.remove('mr-is-hidden');
progressDiv.classList.add('mr-is-visible');
progressDiv.innerHTML = this.renderDownloadSnapshotMarkup(downloadId, snapshot);
this.attachDownloadActionHandlers(progressDiv, downloadId);
}
this.updateDownloadButtonForSnapshot(downloadBtn, progress, status, shouldRenderProgress);
},
restoreDownloadProgressForMissing(missing) {
const snapshot = this.getDownloadSnapshotForMissing(missing);
if (!snapshot) return;
@@ -1218,7 +1323,7 @@ export const resolveDownloadMethods = {
const workflowTabText = workflowContext.workflowTabText || '';
try {
this.rememberDownloadSnapshotForMissing(missing, {
const startingSnapshot = this.rememberDownloadSnapshotForMissing(missing, {
downloadId: null,
category,
subfolder,
@@ -1254,15 +1359,7 @@ export const resolveDownloadMethods = {
}
if (progressDiv) {
progressDiv.classList.remove('mr-is-hidden');
progressDiv.classList.add('mr-is-visible');
progressDiv.innerHTML = this.renderProgressWithAction({
percent: 0,
leftText: '<span class="mr-info-accent-text">Connecting...</span>',
rightText: '',
actionClass: 'cancel-download-btn-pending mr-btn mr-btn-danger mr-btn-sm',
actionText: 'Cancel'
});
this.renderDownloadSnapshot(null, startingSnapshot, { progressDiv, downloadBtn: btn });
}
// Start download