From 880537dcfbd798d72450bbbe446ba5de2ee44637 Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 19 Apr 2026 22:01:20 +0200 Subject: [PATCH] fix: multi-level account rotation + clear failed-accounts per batch + size-sort staleness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three state bugs found during audit: 1. _failedAccounts / _accountOverrides survived across batches. A rate-limited account from batch 1 stayed permanently blacklisted for the rest of the app session, so batch 2 skipped straight to the fallback even after the original recovered. Now cleared in startBatch so each run evaluates accounts fresh. 2. Account rotation was one level deep. With three accounts [A,B,C] on the same hoster and A + B both failing, the job errored out — C was never tried. The fallback-retry was a single if-block. Replaced with a while-loop that keeps asking main for the next override and rotating until every account is exhausted. 3. Queue sort cache included 'size' as a static key, but bytesTotal goes 0 → actual when previews resolve. A queue sorted by size during preview would cache the all-zeros order and never update. Removed size from _STATIC_SORT_KEYS — it now re-sorts per render like status/speed/progress. --- lib/upload-manager.js | 142 +++++++++++++++++++++++------------------- renderer/app.js | 12 ++-- 2 files changed, 83 insertions(+), 71 deletions(-) diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 5b4b6f3..ca87c57 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -132,6 +132,12 @@ class UploadManager extends EventEmitter { this.globalSemaphore = null; this.globalThrottle = null; this.lastStartTime = {}; + // Reset account-rotation state each batch. Otherwise a previously failed + // account (e.g. rate-limited during the last batch) stays permanently + // blacklisted until the app restarts — every upload would silently skip + // straight to the fallback even after the original recovered. + this._failedAccounts.clear(); + this._accountOverrides.clear(); const { signal } = this.abortController; const batchId = `batch-${Date.now()}`; @@ -461,79 +467,85 @@ class UploadManager extends EventEmitter { return; } - // Account fallback: if this account hasn't failed before, try switching - if (task.accountId && !this._failedAccounts.has(task.hoster + ':' + task.accountId)) { + // Account rotation: mark the current account failed, wait for main to + // resolve the next fallback, then retry. Loop so A → B → C → ... works + // for hosters with 3+ accounts (the old code only did one level: A → B + // and stopped, even if C would have worked). + while (task.accountId && !this._failedAccounts.has(task.hoster + ':' + task.accountId)) { + if (signal.aborted || this.stopAfterActive) break; this._failedAccounts.set(task.hoster + ':' + task.accountId, true); this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId }); - // Wait briefly for switchAccount() to be called from main process await this._sleep(800, signal); const override = this._accountOverrides.get(task.hoster); - if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) { - // Switch to fallback account and retry this file - task.accountId = override.id; - task.username = override.username; - task.password = override.password; - task.apiKey = override.apiKey; - this._emitProgress(uploadId, fileName, task.hoster, { - jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, - speedKbs: 0, elapsed: 0, remaining: 0, - error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts - }); - // Re-run retry loop with new account - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (signal.aborted || this.stopAfterActive) break; - if (attempt > 1) { + if (!override || this._failedAccounts.has(task.hoster + ':' + override.id)) break; + // Switch to fallback account and retry this file + task.accountId = override.id; + task.username = override.username; + task.password = override.password; + task.apiKey = override.apiKey; + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts + }); + // Retry loop with the new account. On exhausted failure, the while + // loop iterates: marks this account failed too, asks main for the next + // fallback, and so on. + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (signal.aborted || this.stopAfterActive) break; + if (attempt > 1) { + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: lastError ? lastError.message : '', result: null, attempt, maxAttempts + }); + await this._sleep(3000, signal); + } + try { + const jobStart = Date.now(); + let lastBytes = 0; + let lastSpeedTime = jobStart; + let currentSpeedKbs = 0; + const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 }; + this.activeJobs.set(uploadId, activeEntry); + + const progressCb = (bytesUploaded, bytesTotal) => { + const now = Date.now(); + const timeDelta = (now - lastSpeedTime) / 1000; + if (timeDelta >= 1) { + currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024); + lastBytes = bytesUploaded; + lastSpeedTime = now; + } + activeEntry.speedKbs = currentSpeedKbs; + activeEntry.bytesUploaded = bytesUploaded; + const elapsed = Math.round((now - jobStart) / 1000); + const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; this._emitProgress(uploadId, fileName, task.hoster, { - jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, - speedKbs: 0, elapsed: 0, remaining: 0, - error: lastError ? lastError.message : '', result: null, attempt, maxAttempts + jobId, status: 'uploading', + progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, + bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs, + elapsed, remaining, error: null, result: null, attempt, maxAttempts }); - await this._sleep(3000, signal); - } - try { - const jobStart = Date.now(); - let lastBytes = 0; - let lastSpeedTime = jobStart; - let currentSpeedKbs = 0; - this.activeJobs.set(uploadId, { jobId, speedKbs: 0, bytesUploaded: 0 }); + }; - const progressCb = (bytesUploaded, bytesTotal) => { - const now = Date.now(); - const timeDelta = (now - lastSpeedTime) / 1000; - if (timeDelta >= 1) { - currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024); - lastBytes = bytesUploaded; - lastSpeedTime = now; - } - this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded }); - const elapsed = Math.round((now - jobStart) / 1000); - const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; - this._emitProgress(uploadId, fileName, task.hoster, { - jobId, status: 'uploading', - progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, - bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs, - elapsed, remaining, error: null, result: null, attempt, maxAttempts - }); - }; + const hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null; + const globalThrottle = this._getGlobalThrottle(); + const throttle = hosterThrottle && globalThrottle + ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } + : hosterThrottle || globalThrottle; - const hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null; - const globalThrottle = this._getGlobalThrottle(); - const throttle = hosterThrottle && globalThrottle - ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } - : hosterThrottle || globalThrottle; - - const result = await this._executeUpload(task, progressCb, signal, throttle); - this.activeJobs.delete(uploadId); - this.sessionBytes += fileSize; - emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); - recordFinalResult('done', { result }); - return; - } catch (err) { - this.activeJobs.delete(uploadId); - lastError = err; - if (signal.aborted || this.stopAfterActive) break; - if (attempt >= maxAttempts) break; - } + const result = await this._executeUpload(task, progressCb, signal, throttle); + this.activeJobs.delete(uploadId); + this.sessionBytes += fileSize; + emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); + recordFinalResult('done', { result }); + return; + } catch (err) { + this.activeJobs.delete(uploadId); + lastError = err; + if (signal.aborted || this.stopAfterActive) break; + if (attempt >= maxAttempts) break; } } } diff --git a/renderer/app.js b/renderer/app.js index 272cd2e..795110e 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1062,13 +1062,13 @@ function _onQueueScroll() { const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true }); const _collatorSimple = new Intl.Collator('de'); -// Queue sort memoization. Keys that don't change during upload (filename, host, -// size) reuse the cached result across progress-driven re-renders. Dynamic keys -// (status/speed/progress) are recomputed each call since the sort order itself -// moves every tick. For a queue of 1000+ jobs sorted by filename, this skips -// the Collator-based O(n log n) sort on every 200ms progress render. +// Queue sort memoization. Keys that don't change after a job enters the queue +// (filename, host) reuse the cached result across progress-driven re-renders. +// Dynamic keys (status/speed/progress) AND size (which goes 0 → actual when +// previews resolve / upload starts) are recomputed each call — otherwise a +// queue sorted by size during previews would be stuck in all-zeros order. let _queueSortCache = { sig: '', result: [] }; -const _STATIC_SORT_KEYS = new Set(['filename', 'host', 'size']); +const _STATIC_SORT_KEYS = new Set(['filename', 'host']); function sortQueueJobs(jobs) { const { key, direction } = queueSortState;