From 05e6d654c4658d02522401fc43c90a8dfc565d28 Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 22 Apr 2026 17:56:59 +0200 Subject: [PATCH] fix(rotation): retry after batch-done reuses learned fallback state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously: clicking "Erneut versuchen" after a batch had already finished spawned a fresh UploadManager with empty _failedAccounts and _accountOverrides. The first retry then burned the full retry budget on the account we already knew was dead (e.g. disk-space-full byse account) before rotation kicked in again — same problem we fixed for within-batch flow but for across-batch flow. - main.js: two module-level maps (_sessionFailedAccounts, _sessionAccountOverrides) cache rotation state across batches in the same app session. Populated on account-failed and on both switchAccount paths (event-driven + save-config re-resolve). - lib/upload-manager.js: startBatch(tasks, opts) accepts primeFailedAccounts + primeOverrides. State is still cleared first (legacy behaviour for callers without opts), then re-primed from the passed session state. batch-start rot-log entry reports how many entries were primed for diagnostics. - Tests: prime priority is honored (pre-job-swap fires on first attempt, no fast-fail, no upload to acc1); back-compat for callers that don't pass opts. App restart remains the reset signal — matches the "neuer Tag, acc1 hat vielleicht wieder Platz" expectation. --- lib/upload-manager.js | 24 +++++++++++---- main.js | 18 +++++++++-- tests/upload-manager.test.js | 60 ++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 1d841e4..2771c9e 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -227,7 +227,7 @@ class UploadManager extends EventEmitter { return this.semaphores[hoster]; } - async startBatch(tasks) { + async startBatch(tasks, opts = {}) { this.running = true; this.stopAfterActive = false; this.abortController = new AbortController(); @@ -240,13 +240,25 @@ 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. + // Reset account-rotation state each batch — but optionally re-prime from + // app-session memory so a "Retry failed" right after batch-done doesn't + // burn 5 retries on the account we already know is dead. Caller (main.js) + // passes the session-scoped failed/override state. this._failedAccounts.clear(); this._accountOverrides.clear(); - this._rotLog('batch-start', { taskCount: tasks.length }); + if (Array.isArray(opts.primeFailedAccounts)) { + for (const key of opts.primeFailedAccounts) this._failedAccounts.set(key, true); + } + if (Array.isArray(opts.primeOverrides)) { + for (const entry of opts.primeOverrides) { + if (Array.isArray(entry) && entry.length === 2) this._accountOverrides.set(entry[0], entry[1]); + } + } + this._rotLog('batch-start', { + taskCount: tasks.length, + primedFailed: this._failedAccounts.size, + primedOverrides: this._accountOverrides.size + }); const { signal } = this.abortController; const batchId = `batch-${Date.now()}`; diff --git a/main.js b/main.js index 2734ad6..49a5ca7 100644 --- a/main.js +++ b/main.js @@ -20,6 +20,12 @@ let dropTargetWindow = null; let tray = null; const configStore = new ConfigStore(app); let uploadManager = null; +// Rotation memory that survives batch-done → new UploadManager within the +// same app session. Without this, clicking "Retry failed" after a batch +// ended would burn the full retry budget on accounts we already know are +// dead. Cleared on app restart (which is the user's signal for "try fresh"). +const _sessionFailedAccounts = new Map(); // "hoster:accountId" -> true +const _sessionAccountOverrides = new Map(); // hoster -> account object const folderMonitor = new FolderMonitor(); let remoteServer = null; let captureWindow = null; @@ -938,6 +944,7 @@ ipcMain.handle('save-config', async (_event, config) => { if (fallback) { rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`); uploadManager.switchAccount(hoster, fallback); + _sessionAccountOverrides.set(hoster, fallback); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('account-switched', { hoster, fromAccountId: failedAccountId, toAccountId: fallback.id @@ -1149,11 +1156,15 @@ ipcMain.handle('start-upload', (_event, payload) => { }); uploadManager.on('account-failed', ({ hoster, accountId }) => { + // Persist to session cache so a subsequent batch (after batch-done) + // gets primed and won't burn retries on this account again. + _sessionFailedAccounts.set(hoster + ':' + accountId, true); const cfg = configStore.load(); const fallback = getNextFallbackAccount(cfg, hoster, accountId); if (fallback) { rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`); uploadManager.switchAccount(hoster, fallback); + _sessionAccountOverrides.set(hoster, fallback); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id }); } @@ -1193,8 +1204,11 @@ ipcMain.handle('start-upload', (_event, payload) => { // are not interleaved with the handle() response. process.nextTick(() => { if (!uploadManager) { debugLog('nextTick: uploadManager was nulled before startBatch'); return; } - debugLog('nextTick: calling startBatch now'); - uploadManager.startBatch(tasks).catch((err) => { + debugLog(`nextTick: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`); + uploadManager.startBatch(tasks, { + primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()), + primeOverrides: Array.from(_sessionAccountOverrides.entries()) + }).catch((err) => { debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); // Forward error to renderer as batch-done with failure if (mainWindow && !mainWindow.isDestroyed()) { diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index dc95fb2..08f4813 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -650,6 +650,66 @@ describe('UploadManager', () => { assert.equal(mgr.getOverride('voe.sx'), null, 'unrelated hoster still has no override'); }); + it('startBatch primes failed-accounts + overrides — retry after batch-done skips dead account', async () => { + // acc1 fails with disk-space; acc2 succeeds. + mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { + if (apiKey === 'acc1-key') { + const err = new Error('Byse lehnte Datei ab: not enough disk space'); + err.accountError = true; + throw err; + } + if (onProgress) onProgress(fakeFileSize, fakeFileSize); + return { download_url: 'ok', embed_url: null, file_code: 'ok' }; + }); + + const mgr = new UploadManager( + { 'byse.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } + ); + const rotEvents = []; + mgr.on('rot-log', (e) => rotEvents.push(e)); + + // Simulate a retry-after-batch-done: main.js would pass the + // session-cached failed-accounts + overrides from the previous batch. + await mgr.startBatch([ + { file: '/test/a.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' } + ], { + primeFailedAccounts: ['byse.sx:acc1'], + primeOverrides: [['byse.sx', { id: 'acc2', username: 'u2', password: 'p2', apiKey: 'acc2-key' }]] + }); + + const events = rotEvents.map(e => e.event); + // pre-job-swap should fire on the very first attempt — no fast-fail + // because acc1 was never touched. + assert.ok(events.includes('pre-job-swap'), + `expected pre-job-swap from primed state; got: ${events.join(',')}`); + assert.ok(!events.includes('fast-fail'), + `must NOT burn a fast-fail on primed-dead acc1; got: ${events.join(',')}`); + assert.ok(!events.includes('mark-failed'), + `acc1 was already marked failed (primed); must not emit mark-failed again; got: ${events.join(',')}`); + + // acc1 must not be touched at all. + const acc1Calls = mockUploadFile.mock.calls.filter(c => c.arguments[2] === 'acc1-key').length; + assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts'); + }); + + it('startBatch without prime opts still clears state (back-compat)', async () => { + const mgr = new UploadManager({}); + mgr._failedAccounts.set('byse.sx:acc1', true); + mgr._accountOverrides.set('byse.sx', { id: 'leftover' }); + + mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { + if (onProgress) onProgress(fakeFileSize, fakeFileSize); + return { download_url: 'ok', embed_url: null, file_code: 'ok' }; + }); + + await mgr.startBatch([ + { file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' } + ]); + + assert.equal(mgr._failedAccounts.size, 0, 'legacy callers still get a clean slate'); + assert.equal(mgr._accountOverrides.size, 0, 'legacy callers still get a clean slate for overrides'); + }); + it('transient network errors skip rotation (account stays fine)', () => { const mgr = new UploadManager({}); const cases = [