fix(rotation): retry after batch-done reuses learned fallback state

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.
This commit is contained in:
Administrator 2026-04-22 17:56:59 +02:00
parent 1616ee8f14
commit 05e6d654c4
3 changed files with 94 additions and 8 deletions

View File

@ -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()}`;

18
main.js
View File

@ -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()) {

View File

@ -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 = [