feat: add automatic account cooldown recovery

Replace session-long account failure pauses with classified 15, 30, 60, and 120 minute cooldowns for temporary account problems. Keep credential, OTP, banned, and disabled states manual while ignoring unknown, file, network, hoster, and bare WAF errors. Reset escalation after confirmed uploads, deduplicate parallel failures, publish revisioned pause snapshots, and show a stable localized countdown with automatic reactivation.
This commit is contained in:
Sucukdeluxe
2026-08-22 18:18:02 +02:00
parent e986c552dc
commit b769467d08
13 changed files with 653 additions and 35 deletions
+137 -1
View File
@@ -1,6 +1,11 @@
const test = require('node:test');
const assert = require('node:assert');
const { createAccountPicker, enabledAccountsFor } = require('../lib/account-rotation');
const {
classifyAccountFailure,
createAccountCooldownController,
createAccountPicker,
enabledAccountsFor
} = require('../lib/account-rotation');
const hasCreds = (hoster, a) => !!(a && a.creds !== false);
function acc(id, opts = {}) { return { id, enabled: opts.enabled, creds: opts.creds }; }
@@ -124,3 +129,134 @@ test('persisted cursor wraps correctly after the enabled-account count shrinks',
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 7 } });
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a2', 'a1', 'a2']);
});
test('temporary account failures escalate through 15, 30, 60, and 120 minute cooldowns', () => {
let now = 1_000_000;
const scheduled = [];
const controller = createAccountCooldownController({
now: () => now,
setTimer: (callback, delay) => { scheduled.push({ callback, delay }); return scheduled.length; },
clearTimer: () => {}
});
const expectedMinutes = [15, 30, 60, 120, 120];
for (let index = 0; index < expectedMinutes.length; index++) {
const record = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
assert.equal(record.failures, index + 1);
assert.equal(record.pausedUntil, now + expectedMinutes[index] * 60_000);
assert.equal(scheduled.at(-1).delay, expectedMinutes[index] * 60_000);
now = record.pausedUntil;
assert.deepEqual(controller.releaseExpired(), [`byse.sx:acc-1`]);
}
});
test('parallel duplicate failures count as one strike until the active cooldown expires', () => {
let now = 50_000;
const controller = createAccountCooldownController({
now: () => now,
setTimer: () => 1,
clearTimer: () => {}
});
const first = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
const duplicate = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
now = first.pausedUntil;
controller.releaseExpired();
const next = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
assert.equal(duplicate.failures, 1);
assert.equal(duplicate.pausedUntil, first.pausedUntil);
assert.equal(next.failures, 2);
assert.equal(next.pausedUntil, now + 30 * 60_000);
});
test('a successful upload resets cooldown escalation to the first level', () => {
let now = 5_000;
const controller = createAccountCooldownController({
now: () => now,
setTimer: () => 1,
clearTimer: () => {}
});
const first = controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
now = first.pausedUntil;
controller.releaseExpired();
controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
assert.equal(controller.markSuccess('voe.sx', 'acc-1'), true);
const afterSuccess = controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
assert.equal(afterSuccess.failures, 1);
assert.equal(afterSuccess.pausedUntil, now + 15 * 60_000);
});
test('manual account pauses never expire and can be reset explicitly', () => {
let now = 10_000;
const controller = createAccountCooldownController({
now: () => now,
setTimer: () => { throw new Error('manual pauses must not schedule timers'); },
clearTimer: () => {}
});
const record = controller.markFailure({ hoster: 'doodstream.com', accountId: 'acc-1', mode: 'manual' });
now += 24 * 60 * 60_000;
assert.equal(record.pausedUntil, null);
assert.deepEqual(controller.releaseExpired(), []);
assert.deepEqual(controller.activeKeys(), ['doodstream.com:acc-1']);
assert.equal(controller.reset('doodstream.com', 'acc-1'), true);
assert.deepEqual(controller.activeKeys(), []);
});
test('automatic expiry clears the runtime account and publishes the remaining state', () => {
let now = 2_000;
let scheduled;
const cleared = [];
const published = [];
const controller = createAccountCooldownController({
now: () => now,
setTimer: (callback, delay) => { scheduled = { callback, delay }; return 1; },
clearTimer: () => {},
onClearAccount: (hoster, accountId) => cleared.push(`${hoster}:${accountId}`),
onChange: (records, cause) => published.push({ records, cause })
});
const record = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
now = record.pausedUntil;
scheduled.callback();
assert.deepEqual(cleared, ['byse.sx:acc-1']);
assert.deepEqual(published.at(-1), { records: [], cause: 'expired' });
assert.deepEqual(controller.activeKeys(), []);
});
test('account failure classification keeps credential and OTP errors manual', () => {
const otp = new Error('Doodstream Login: OTP required');
otp.otpRequired = true;
assert.equal(classifyAccountFailure(otp), 'manual');
assert.equal(classifyAccountFailure(new Error('VOE Login fehlgeschlagen: Falscher Username oder Passwort')), 'manual');
assert.equal(classifyAccountFailure(new Error('HTTP 401 Unauthorized')), 'manual');
assert.equal(classifyAccountFailure(new Error('Account banned')), 'manual');
});
test('account failure classification cools down quota and rate-limit errors without pausing transient failures', () => {
const quota = new Error('not enough disk space on your account');
quota.accountError = true;
assert.equal(classifyAccountFailure(quota), 'cooldown');
assert.equal(classifyAccountFailure(new Error('HTTP 429 Too Many Requests')), 'cooldown');
const transient = new Error('HTTP 503 Service Unavailable');
transient.transientNetwork = true;
assert.equal(classifyAccountFailure(transient), 'none');
});
test('account failure classification does not punish unknown, confirmation, or bare WAF errors', () => {
assert.equal(classifyAccountFailure(new Error('Upload wurde nicht bestätigt')), 'none');
assert.equal(classifyAccountFailure(new Error('Unbekannter Parserfehler')), 'none');
const waf = new Error('HTTP 403');
waf.status = 403;
assert.equal(classifyAccountFailure(waf), 'none');
});
test('account failure classification cools down stale sessions and explicit account errors', () => {
assert.equal(classifyAccountFailure(new Error('CSRF-Token nicht gefunden')), 'cooldown');
assert.equal(classifyAccountFailure(new Error('Session expired')), 'cooldown');
const accountError = new Error('Provider account unavailable');
accountError.accountError = true;
assert.equal(classifyAccountFailure(accountError), 'cooldown');
assert.equal(classifyAccountFailure(new Error('Doodstream: sess_id nicht gefunden nach Login')), 'cooldown');
});
+53 -1
View File
@@ -2,7 +2,10 @@ const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
getAccountGroupStatus,
getAccountStatusPresentation
getAccountPausePresentation,
getAccountStatusPresentation,
formatAccountPauseRemaining,
subscribeAccountPauseSnapshots
} = require('../renderer/account-status');
test('mixed account results use warning group status', () => {
@@ -28,3 +31,52 @@ test('OTP-required account exposes warning presentation', () => {
requiresOtp: true
});
});
test('timed account pause presentation exposes a stable countdown', () => {
assert.deepEqual(getAccountPausePresentation({ mode: 'cooldown', pausedUntil: 905_000 }, 5_000), {
mode: 'cooldown',
remainingSeconds: 900,
expired: false
});
});
test('manual account pause presentation requires action and timed pauses expire at zero', () => {
assert.deepEqual(getAccountPausePresentation({ mode: 'manual', pausedUntil: null }, 5_000), {
mode: 'manual',
remainingSeconds: null,
expired: false
});
assert.deepEqual(getAccountPausePresentation({ mode: 'cooldown', pausedUntil: 5_000 }, 5_000), {
mode: 'cooldown',
remainingSeconds: 0,
expired: true
});
});
test('account pause countdown uses fixed two-digit seconds and supports two-hour cooldowns', () => {
assert.equal(formatAccountPauseRemaining(900), '15:00');
assert.equal(formatAccountPauseRemaining(3599), '59:59');
assert.equal(formatAccountPauseRemaining(7200), '120:00');
});
test('account pause subscription is installed before the initial snapshot is requested', async () => {
const order = [];
let push;
let resolveInitial;
const initial = new Promise(resolve => { resolveInitial = resolve; });
const applied = [];
const api = {
onSessionFailedAccountsChanged: callback => { order.push('subscribe'); push = callback; },
getSessionFailedAccountStates: () => { order.push('load'); return initial; }
};
const pending = subscribeAccountPauseSnapshots(api, snapshot => applied.push(snapshot));
push({ revision: 2, accounts: [{ accountId: 'live' }] });
resolveInitial({ revision: 1, accounts: [] });
await pending;
assert.deepEqual(order, ['subscribe', 'load']);
assert.deepEqual(applied, [
{ revision: 2, accounts: [{ accountId: 'live' }] },
{ revision: 1, accounts: [] }
]);
});
+13
View File
@@ -53,6 +53,19 @@ test('translates the account check timestamp label', () => {
assert.equal(translateText('checked', 'de'), 'geprüft');
});
test('translates account cooldown and manual pause labels', () => {
const pairs = [
['Pausiert noch', 'Paused '],
['Pausiert Aktion nötig', 'Paused action required'],
['Account automatisch wieder aktiv', 'Account automatically active again'],
['Account wieder aktiv nächste Batch verwendet ihn', 'Account active again the next batch will use it']
];
for (const [german, english] of pairs) {
assert.equal(translateText(german, 'en'), english);
assert.equal(translateText(english, 'de'), german);
}
});
test('translates the failure detail clipboard action', () => {
assert.equal(translateText('Fehlerdetails kopieren', 'en'), 'Copy failure details');
assert.equal(translateText('Failure details copied', 'de'), 'Fehlerdetails kopiert');
+44
View File
@@ -209,3 +209,47 @@ test('close readiness is signaled only after the renderer explicitly finishes in
['app:close-handshake-ready']
]);
});
test('preload exposes account cooldown snapshots and removes their listener during cleanup', async () => {
const listeners = new Map();
const invocations = [];
const removed = [];
let exposedApi = null;
const electronMock = {
contextBridge: {
exposeInMainWorld: (_name, api) => { exposedApi = api; }
},
ipcRenderer: {
invoke: (...args) => { invocations.push(args); return Promise.resolve({ version: 2, accounts: [] }); },
on: (channel, listener) => { listeners.set(channel, listener); },
send: () => {},
removeAllListeners: channel => { removed.push(channel); }
},
webUtils: {
getPathForFile: () => ''
}
};
const originalLoad = Module._load;
const preloadPath = require.resolve('../preload');
delete require.cache[preloadPath];
Module._load = function (request, parent, isMain) {
if (request === 'electron') return electronMock;
return originalLoad.call(this, request, parent, isMain);
};
try {
require(preloadPath);
} finally {
Module._load = originalLoad;
}
const snapshot = await exposedApi.getSessionFailedAccountStates();
let pushed = null;
exposedApi.onSessionFailedAccountsChanged(value => { pushed = value; });
listeners.get('session-failed-accounts-changed')({}, { version: 2, accounts: [{ accountId: 'a1' }] });
exposedApi.removeAllListeners();
assert.deepEqual(snapshot, { version: 2, accounts: [] });
assert.deepEqual(invocations, [['get-session-failed-account-states']]);
assert.deepEqual(pushed, { version: 2, accounts: [{ accountId: 'a1' }] });
assert.equal(removed.includes('session-failed-accounts-changed'), true);
});
+59
View File
@@ -873,6 +873,65 @@ describe('UploadManager', () => {
});
describe('session-level account memory', () => {
it('emits a timed account pause for quota failures and a success reset for the fallback', async () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (apiKey === 'full-key') {
const error = new Error('not enough disk space on your account');
error.accountError = true;
throw error;
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({ 'byse.sx': { retries: 0, parallelCount: 1 } });
const pauses = [];
const successes = [];
mgr.on('account-paused', event => pauses.push(event));
mgr.on('account-succeeded', event => successes.push(event));
mgr.on('account-failed', ({ hoster }) => {
mgr.switchAccount(hoster, { id: 'fallback', apiKey: 'fallback-key' });
});
await mgr.startBatch([{ file: '/test/cooldown.mp4', hoster: 'byse.sx', accountId: 'full', apiKey: 'full-key' }]);
assert.deepEqual(pauses, [{ hoster: 'byse.sx', accountId: 'full', mode: 'cooldown' }]);
assert.deepEqual(successes, [{ hoster: 'byse.sx', accountId: 'fallback' }]);
});
it('emits a manual account pause for credential failures', async () => {
mockUploadFile.mock.mockImplementation(async () => {
throw new Error('VOE Login fehlgeschlagen: Falscher Username oder Passwort');
});
const mgr = new UploadManager({ 'voe.sx': { retries: 0, parallelCount: 1 } });
const pauses = [];
mgr.on('account-paused', event => pauses.push(event));
await mgr.startBatch([{ file: '/test/manual.mp4', hoster: 'voe.sx', accountId: 'login', apiKey: 'bad' }]);
assert.deepEqual(pauses, [{ hoster: 'voe.sx', accountId: 'login', mode: 'manual' }]);
});
it('does not blacklist or emit fallback steering for unknown account failures', async () => {
mockUploadFile.mock.mockImplementation(async () => {
throw new Error('Unbekannter Parserfehler');
});
const mgr = new UploadManager({ 'voe.sx': { retries: 0, parallelCount: 1 } });
const paused = [];
const failed = [];
mgr.on('account-paused', event => paused.push(event));
mgr.on('account-failed', event => failed.push(event));
await mgr.startBatch([
{ file: '/test/unknown-a.mp4', hoster: 'voe.sx', accountId: 'primary', apiKey: 'key' },
{ file: '/test/unknown-b.mp4', hoster: 'voe.sx', accountId: 'primary', apiKey: 'key' }
]);
assert.deepEqual(paused, []);
assert.deepEqual(failed, []);
assert.deepEqual(mgr.getFailedAccountKeys(), []);
assert.equal(mockUploadFile.mock.calls.length, 2);
});
// Scenario: user has 2 byse accounts. Account 1 is full ("not enough
// disk space"). First job fails on acc1 → rotation to acc2. Second job
// must NOT re-probe acc1; pre-job-swap has to kick in.