diff --git a/lib/upload-manager.js b/lib/upload-manager.js index a362910..a7178f9 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -64,6 +64,19 @@ class UploadManager extends EventEmitter { return this._accountOverrides.get(hoster) || null; } + // True if the hoster has a usable override stored that differs from the + // account currently in the task and isn't itself already marked failed. + // Used by the retry loop to decide "retry on same account vs break to + // rotation" — skipping wasted attempts on a likely-bad primary when a + // pre-resolved fallback is ready to try. + _hasPendingOverride(hoster, currentAccountId) { + const override = this._accountOverrides.get(hoster); + if (!override) return false; + if (override.id === currentAccountId) return false; + if (this._failedAccounts.has(hoster + ':' + override.id)) return false; + return true; + } + _rotLog(event, data) { this.emit('rot-log', { ts: Date.now(), event, ...data }); } @@ -595,6 +608,19 @@ class UploadManager extends EventEmitter { }); break; } + // Generic non-transient error AND a fallback is already resolved for + // this hoster: bail to rotation instead of burning more retries on a + // possibly-dead primary. The fallback (pre-resolved at batch-start) + // deserves a real shot. Transient network errors stay on the same + // account — the network is the issue, not the account. + if (!this._isTransientNetworkError(err) && + this._hasPendingOverride(task.hoster, task.accountId)) { + this._rotLog('try-alternate-after-fail', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + attempt, error: err && err.message ? err.message : String(err) + }); + break; + } if (attempt >= maxAttempts) break; // Wait 3 seconds before retry await this._sleep(3000, signal); diff --git a/main.js b/main.js index d6cd260..d357ded 100644 --- a/main.js +++ b/main.js @@ -1133,6 +1133,26 @@ ipcMain.handle('start-upload', (_event, payload) => { if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.', skippedJobs }; + // Pre-resolve a fallback for every hoster that has one. Lets the upload + // manager break out of the retry loop after a single generic failure and + // try the alternate account immediately, instead of hammering a probably- + // dead primary 5× before the account-failed event even fires. Doesn't + // trigger pre-job-swap (which only fires when the current account is in + // _failedAccounts), so jobs still start on the primary as expected. + const hostersInBatch = new Set(tasks.map(t => t.hoster).filter(Boolean)); + for (const hoster of hostersInBatch) { + if (_sessionAccountOverrides.has(hoster)) continue; // already learned from past batch + const accounts = config.hosters && config.hosters[hoster]; + if (!Array.isArray(accounts) || accounts.length < 2) continue; + const primary = accounts.find(a => a && a.enabled !== false && hosterAccountHasCreds(hoster, a)); + if (!primary) continue; + const next = getNextFallbackAccount(config, hoster, primary.id); + if (next) { + _sessionAccountOverrides.set(hoster, next); + rotLog(`main: pre-resolved fallback for ${hoster} → ${next.id} (primary ${primary.id} will try acc2 on first failure)`); + } + } + // Fresh collector for this new batch — old entries from the previous // batch's jobs are dropped (user's signal for "fresh log" is starting a // new upload; addJobs during a running batch keeps them). diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index 08f4813..1468d17 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -692,6 +692,94 @@ describe('UploadManager', () => { assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts'); }); + it('generic error + pre-resolved override: rotates after 1 attempt (no more 5x on primary)', async () => { + // acc1 throws a generic non-transient, non-account-specific error. + // acc2 succeeds. With a pre-resolved override (from main.js at batch + // start), the retry loop must break after 1 attempt on acc1 and rotate. + let acc1Calls = 0; + let acc2Calls = 0; + mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { + if (apiKey === 'acc1-key') { + acc1Calls++; + throw new Error('VOE Upload: irgendein generischer Fehler'); + } + acc2Calls++; + if (onProgress) onProgress(fakeFileSize, fakeFileSize); + return { download_url: 'ok', embed_url: null, file_code: 'ok' }; + }); + + const mgr = new UploadManager( + { 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } + ); + const events = []; + mgr.on('rot-log', (e) => events.push(e.event)); + + await mgr.startBatch([ + { file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' } + ], { + // Main.js pre-resolves the fallback at batch start. + primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]] + }); + + assert.equal(acc1Calls, 1, 'acc1 must get exactly 1 attempt before rotation kicks in'); + assert.ok(acc2Calls >= 1, 'acc2 must take over'); + assert.ok(events.includes('try-alternate-after-fail'), + `expected try-alternate-after-fail; got: ${events.join(',')}`); + }); + + it('transient error + pre-resolved override: retries SAME acc (network, not acc, is the issue)', async () => { + let acc1Calls = 0; + let acc2Calls = 0; + mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { + if (apiKey === 'acc1-key') { + acc1Calls++; + if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443'); + if (onProgress) onProgress(fakeFileSize, fakeFileSize); + return { download_url: 'ok-on-acc1-retry', embed_url: null, file_code: 'ok' }; + } + acc2Calls++; + if (onProgress) onProgress(fakeFileSize, fakeFileSize); + return { download_url: 'ok', embed_url: null, file_code: 'ok' }; + }); + + const mgr = new UploadManager( + { 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } + ); + const events = []; + mgr.on('rot-log', (e) => events.push(e.event)); + + await mgr.startBatch([ + { file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' } + ], { + primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]] + }); + + assert.equal(acc1Calls, 3, 'transient must retry same acc until success'); + assert.equal(acc2Calls, 0, 'must NOT rotate away on transient network errors'); + assert.ok(!events.includes('try-alternate-after-fail'), + `transient should NOT trigger try-alternate-after-fail; got: ${events.join(',')}`); + }); + + it('generic error + NO override: falls back to classic retry on same account', async () => { + let acc1Calls = 0; + mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { + acc1Calls++; + throw new Error('something generic'); + }); + + const mgr = new UploadManager( + { 'voe.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } + ); + + await mgr.startBatch([ + { file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' } + ]); + + // retries=3 → maxAttempts=4 (retries + 1). Without an override to rotate + // to, must exhaust all 4 attempts on acc1. + assert.equal(acc1Calls, 4, 'single-account hoster must retry N+1 times on same account'); + }); + it('startBatch without prime opts still clears state (back-compat)', async () => { const mgr = new UploadManager({}); mgr._failedAccounts.set('byse.sx:acc1', true);