diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 2c51212..5a957ff 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -7058,7 +7058,7 @@ test('search and URN completion ignore stale UI runs', () => { assert.match(searchOnline, /isCurrentSearchRun/); assert.match(searchOnline, /currentSearchRun/); assert.match(searchOnline, /if \(searchRunId && !isCurrentSearchRun\(\)\) return/); - assert.match(searchOnline, /currentSearchRun && searchBtn\?\.isConnected !== false/); + assert.match(searchOnline, /currentSearchRun && searchBtn && searchBtn\.isConnected !== false/); assert.match(resolveUrnAsync, /urnResolveUiTokens/); assert.match(resolveUrnAsync, /isCurrentUrnUi/); assert.match(resolveUrnAsync, /else if \(data\) \{\s*return;/); @@ -8195,3 +8195,70 @@ test('applying source model details selection updates current download source ha assert.equal(state.results.civitai.file_info.hashes.SHA256, sha256); assert.equal(refreshCalls.length, 1); }); + +test('batch sources advance independently while each source stays sequential', async () => { + const runBatch = eval(`(${extractMethod(resolveDownloadMethodsSource, 'searchMissingBatch')})`); + const started = []; + const releases = new Map(); + const dialog = { + getBatchSearchTargets: () => [{ id: 1 }, { id: 2 }], + getWorkflowScopedQueueKey: () => 'workflow-a', + getSearchStateForWorkflow: () => ({}), + showNotification() {}, + updateBatchFooterButtons() {}, + closeFooterMenus() {}, + persistSearchStateForActiveWorkflow() {}, + async searchOnline(model, { scheduleSource }) { + await Promise.all(['fast', 'slow'].map(source => scheduleSource(source, async () => { + const key = source + model.id; + started.push(key); + await new Promise(resolve => releases.set(key, resolve)); + }))); + } + }; + const flush = async () => { for (let i = 0; i < 15; i++) await Promise.resolve(); }; + const pending = runBatch.call(dialog, 'all'); + await flush(); + assert.deepEqual(started, ['fast1', 'slow1']); + releases.get('fast1')(); + await flush(); + assert.deepEqual(started, ['fast1', 'slow1', 'fast2']); + assert.equal(dialog.batchSearchRunning, true); + releases.get('fast2')(); + releases.get('slow1')(); + await flush(); + assert.deepEqual(started, ['fast1', 'slow1', 'fast2', 'slow2']); + releases.get('slow2')(); + await pending; + assert.equal(dialog.batchSearchRunning, false); +}); + +test('batch source queue survives errors and skips waiting requests on stop', async () => { + const runBatch = eval(`(${extractMethod(resolveDownloadMethodsSource, 'searchMissingBatch')})`); + const calls = []; + let release; + const dialog = { + getBatchSearchTargets: () => [{ id: 1 }, { id: 2 }, { id: 3 }], + getWorkflowScopedQueueKey: () => 'workflow-a', + getSearchStateForWorkflow: () => ({}), + showNotification() {}, + updateBatchFooterButtons() {}, + closeFooterMenus() {}, + persistSearchStateForActiveWorkflow() {}, + async searchOnline(model, { scheduleSource }) { + await scheduleSource('civitai', async () => { + calls.push(model.id); + if (model.id === 1) throw new Error('provider failed'); + await new Promise(resolve => { release = resolve; }); + }); + } + }; + const pending = runBatch.call(dialog, 'all'); + for (let i = 0; i < 20; i++) await Promise.resolve(); + assert.deepEqual(calls, [1, 2]); + dialog.batchSearchCancelRequested = true; + release(); + await pending; + assert.deepEqual(calls, [1, 2]); + assert.equal(dialog.batchSearchRunning, false); +}); diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 3ca3e60..440d3dc 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -1103,14 +1103,20 @@ --mr-source-border-alpha: 0.22; } -.mr-missing-source-running, -.mr-missing-source-pending { +.mr-missing-source-running { --mr-source-rgb: 96, 190, 255; --mr-source-fill-alpha: 0.16; --mr-source-border-hi-alpha: 0.78; --mr-source-border-alpha: 0.6; } +.mr-missing-source-pending { + --mr-source-rgb: 175, 180, 188; + --mr-source-fill-alpha: 0.10; + --mr-source-border-hi-alpha: 0.52; + --mr-source-border-alpha: 0.38; +} + .mr-missing-source-error { --mr-source-rgb: 255, 138, 128; --mr-source-fill-alpha: 0.16; @@ -3675,6 +3681,10 @@ display: none; } +.mr-search-progress-pending .mr-search-progress-fill { + background: var(--mr-text-muted); +} + .mr-footer:not(.mr-footer-is-missing) .mr-footer-star-link { margin-right: auto; } diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 0837fc5..6427a92 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -210,22 +210,36 @@ export const resolveDownloadMethods = { let cancelled; let batchError; let hasBatchError = false; + const sourceTails = new Map(); + const scheduleSource = (sourceId, task) => { + const previous = sourceTails.get(sourceId) || Promise.resolve(); + const next = previous.then(() => this.batchSearchCancelRequested + ? { source: sourceId, cancelled: true } + : task()); + // One failed request must not block the remaining models. + sourceTails.set(sourceId, next.catch(() => {})); + return next; + }; try { - for (const missing of targets) { - if (this.batchSearchCancelRequested) { - break; - } + const outcomes = await Promise.allSettled(targets.map(async missing => { + if (this.batchSearchCancelRequested) return; const state = this.getSearchStateForWorkflow(batchWorkflowKey, missing); state.selectedSource = source || 'all'; try { - await this.searchOnline(missing, { workflowKey: batchWorkflowKey, forceSearch }); + await this.searchOnline(missing, { + workflowKey: batchWorkflowKey, + forceSearch, + scheduleSource + }); } catch (error) { failed += 1; console.error('Model Resolver: batch search item failed:', error); } - completed += 1; + if (!this.batchSearchCancelRequested) completed += 1; this.updateBatchFooterButtons(); - } + })); + const rejected = outcomes.find(outcome => outcome.status === 'rejected'); + if (rejected) throw rejected.reason; } catch (error) { batchError = error; hasBatchError = true; @@ -2581,7 +2595,7 @@ export const resolveDownloadMethods = { /** * Search online for a model */ - async searchOnline(missing, { workflowKey = this.getWorkflowScopedQueueKey(), forceSearch = false, source = null, searchQuery = '' } = {}) { + async searchOnline(missing, { workflowKey = this.getWorkflowScopedQueueKey(), forceSearch = false, source = null, searchQuery = '', scheduleSource = null } = {}) { const providedSearchQuery = String(searchQuery || '').trim(); const searchSha256 = normalizeSha256(providedSearchQuery); const state = this.getSearchStateForWorkflow(workflowKey, missing); @@ -2646,7 +2660,8 @@ export const resolveDownloadMethods = { const isUrn = Boolean(missing.is_urn && !requestedSearchQuery && !searchSha256); const resultsId = `search-results-${this.getMissingModelDomKey(missing)}`; - const canUpdateCurrentWorkflow = workflowKey === this.getWorkflowScopedQueueKey(); + const canUpdateCurrentWorkflow = workflowKey === this.getWorkflowScopedQueueKey() + && (!this.selectedMissingModelKey || this.getMissingModelKey(missing) === this.selectedMissingModelKey); const resultsDiv = canUpdateCurrentWorkflow ? this.contentElement?.querySelector(`#${resultsId}`) : null; @@ -2695,9 +2710,10 @@ export const resolveDownloadMethods = { state.sourceProgress = {}; for (const source of sourceIds) { this.setSourceProgress(state, source, { - status: 'running', - percent: 6, - startedAt: Date.now(), + status: scheduleSource ? 'pending' : 'running', + percent: scheduleSource ? 0 : 6, + message: scheduleSource ? 'Queued' : 'Searching...', + startedAt: scheduleSource ? null : Date.now(), estimateMs: this.getSearchSourceEstimateMs(source, isUrn) }, missing, { workflowKey }); } @@ -2707,22 +2723,13 @@ export const resolveDownloadMethods = { searchBtn.disabled = true; searchBtn.innerHTML = this.renderSearchButtonContent(`Searching ${selectedSourceLabel}...`); } - this.syncLinkNameActionUi?.(missing, this.contentElement, state); + if (canUpdateCurrentWorkflow) this.syncLinkNameActionUi?.(missing, this.contentElement, state); if (resultsDiv) { resultsDiv.classList.remove('mr-is-hidden'); resultsDiv.classList.add('mr-is-visible'); this.displaySearchResults(missing, state, resultsDiv); } - for (const source of sourceIds) { - this.startEstimatedSearchProgress( - state, - missing, - resultsDiv, - source, - searchRunId, - { workflowKey } - ); - } + // For URNs, include model_id and version_id for direct download const tokens = this.getStoredTokens(); @@ -2757,7 +2764,19 @@ export const resolveDownloadMethods = { let anyFound = false; let hadError = false; - const searchPromises = sourceIds.map(async (source) => { + const runSource = async (source) => { + if (!this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId) + || this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source)) { + return { source, cancelled: true }; + } + this.setSourceProgress(state, source, { + status: 'running', + percent: 6, + message: 'Searching...', + startedAt: Date.now() + }, missing, { workflowKey }); + this.refreshSearchUiForMissing(missing, state, { workflowKey }); + this.startEstimatedSearchProgress(state, missing, resultsDiv, source, searchRunId, { workflowKey }); const sourceIsUrn = isUrn && source !== 'lora_manager_archive'; const progressId = `${searchRunId}-${source}-${Math.random().toString(36).slice(2, 8)}`; const searchData = { @@ -2940,7 +2959,12 @@ export const resolveDownloadMethods = { cleanupJob.sourceProgressIds?.delete(source); } } - }); + }; + const searchPromises = sourceIds.map(source => ( + scheduleSource + ? scheduleSource(source, () => runSource(source)) + : runSource(source) + )); const currentJob = this.backgroundSearchJobs.get(backgroundJobKey); if (currentJob?.runId === searchRunId) { @@ -2982,7 +3006,7 @@ export const resolveDownloadMethods = { if (searchRunId && !isCurrentSearchRun()) return; state.lastAttemptError = error.message; this.persistSearchStateForWorkflow(workflowKey, missing, state); - if (resultsDiv?.isConnected !== false) { + if (resultsDiv && resultsDiv.isConnected !== false) { this.patchSearchResultsContainer?.( resultsDiv, this.renderStatusMessage(`Search failed: ${error.message}`, 'error') @@ -3012,7 +3036,7 @@ export const resolveDownloadMethods = { refresh: false }); } - if (currentSearchRun && searchBtn?.isConnected !== false) { + if (currentSearchRun && searchBtn && searchBtn.isConnected !== false) { searchBtn.disabled = false; searchBtn.innerHTML = this.renderSearchButtonContent('Search Again'); }