fix: multi-level account rotation + clear failed-accounts per batch + size-sort staleness

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.
This commit is contained in:
Administrator 2026-04-19 22:01:20 +02:00
parent 5265bcd77a
commit 880537dcfb
2 changed files with 83 additions and 71 deletions

View File

@ -132,6 +132,12 @@ class UploadManager extends EventEmitter {
this.globalSemaphore = null; this.globalSemaphore = null;
this.globalThrottle = null; this.globalThrottle = null;
this.lastStartTime = {}; 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 { signal } = this.abortController;
const batchId = `batch-${Date.now()}`; const batchId = `batch-${Date.now()}`;
@ -461,79 +467,85 @@ class UploadManager extends EventEmitter {
return; return;
} }
// Account fallback: if this account hasn't failed before, try switching // Account rotation: mark the current account failed, wait for main to
if (task.accountId && !this._failedAccounts.has(task.hoster + ':' + task.accountId)) { // 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._failedAccounts.set(task.hoster + ':' + task.accountId, true);
this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId }); 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); await this._sleep(800, signal);
const override = this._accountOverrides.get(task.hoster); const override = this._accountOverrides.get(task.hoster);
if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) { if (!override || this._failedAccounts.has(task.hoster + ':' + override.id)) break;
// Switch to fallback account and retry this file // Switch to fallback account and retry this file
task.accountId = override.id; task.accountId = override.id;
task.username = override.username; task.username = override.username;
task.password = override.password; task.password = override.password;
task.apiKey = override.apiKey; task.apiKey = override.apiKey;
this._emitProgress(uploadId, fileName, task.hoster, { this._emitProgress(uploadId, fileName, task.hoster, {
jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize,
speedKbs: 0, elapsed: 0, remaining: 0, speedKbs: 0, elapsed: 0, remaining: 0,
error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts
}); });
// Re-run retry loop with new account // Retry loop with the new account. On exhausted failure, the while
for (let attempt = 1; attempt <= maxAttempts; attempt++) { // loop iterates: marks this account failed too, asks main for the next
if (signal.aborted || this.stopAfterActive) break; // fallback, and so on.
if (attempt > 1) { 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, { this._emitProgress(uploadId, fileName, task.hoster, {
jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, jobId, status: 'uploading',
speedKbs: 0, elapsed: 0, remaining: 0, progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
error: lastError ? lastError.message : '', result: null, attempt, maxAttempts 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 hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null;
const now = Date.now(); const globalThrottle = this._getGlobalThrottle();
const timeDelta = (now - lastSpeedTime) / 1000; const throttle = hosterThrottle && globalThrottle
if (timeDelta >= 1) { ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024); : hosterThrottle || globalThrottle;
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 result = await this._executeUpload(task, progressCb, signal, throttle);
const globalThrottle = this._getGlobalThrottle(); this.activeJobs.delete(uploadId);
const throttle = hosterThrottle && globalThrottle this.sessionBytes += fileSize;
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
: hosterThrottle || globalThrottle; recordFinalResult('done', { result });
return;
const result = await this._executeUpload(task, progressCb, signal, throttle); } catch (err) {
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
this.sessionBytes += fileSize; lastError = err;
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); if (signal.aborted || this.stopAfterActive) break;
recordFinalResult('done', { result }); if (attempt >= maxAttempts) break;
return;
} catch (err) {
this.activeJobs.delete(uploadId);
lastError = err;
if (signal.aborted || this.stopAfterActive) break;
if (attempt >= maxAttempts) break;
}
} }
} }
} }

View File

@ -1062,13 +1062,13 @@ function _onQueueScroll() {
const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true }); const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
const _collatorSimple = new Intl.Collator('de'); const _collatorSimple = new Intl.Collator('de');
// Queue sort memoization. Keys that don't change during upload (filename, host, // Queue sort memoization. Keys that don't change after a job enters the queue
// size) reuse the cached result across progress-driven re-renders. Dynamic keys // (filename, host) reuse the cached result across progress-driven re-renders.
// (status/speed/progress) are recomputed each call since the sort order itself // Dynamic keys (status/speed/progress) AND size (which goes 0 → actual when
// moves every tick. For a queue of 1000+ jobs sorted by filename, this skips // previews resolve / upload starts) are recomputed each call — otherwise a
// the Collator-based O(n log n) sort on every 200ms progress render. // queue sorted by size during previews would be stuck in all-zeros order.
let _queueSortCache = { sig: '', result: [] }; let _queueSortCache = { sig: '', result: [] };
const _STATIC_SORT_KEYS = new Set(['filename', 'host', 'size']); const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
function sortQueueJobs(jobs) { function sortQueueJobs(jobs) {
const { key, direction } = queueSortState; const { key, direction } = queueSortState;