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
+152 -1
View File
@@ -24,4 +24,155 @@ function createAccountPicker({ hosters, hosterSettings, hasCreds, indices }) {
return pick;
}
module.exports = { createAccountPicker, enabledAccountsFor };
function classifyAccountFailure(error) {
if (!error || error.transientNetwork === true || error.hosterTransient === true || error.fileRejected === true) return 'none';
if (error.otpRequired === true) return 'manual';
const message = String(error.message || error);
const manualPatterns = [
/otp|two[- ]?factor|verification code/i,
/Falscher (User|Username|Passwort)/i,
/Incorrect (Login|Password)/i,
/invalid (credentials|api[- ]?key|token)/i,
/unauthori[sz]ed|not authorized|\b401\b/i,
/(account|user) (banned|suspended|disabled|gesperrt)/i,
/API[- ]?Key (fehlt|prüfen)|missing API[- ]?key/i,
/Login fehlgeschlagen/i
];
if (manualPatterns.some(pattern => pattern.test(message))) return 'manual';
const cooldownPatterns = [
/\b429\b|rate[- ]?limit|too many requests/i,
/quota|not enough (disk )?(space|storage)|insufficient (disk )?space/i,
/disk (space )?full|storage (exhausted|full|voll|limit)|account (full|voll)/i,
/session (expired|abgelaufen)|CSRF[- ]?Token nicht gefunden|not logged in/i,
/Keine Session erhalten|Session konnte nicht verifiziert werden/i,
/sess_id nicht gefunden|session id not found/i
];
if (error.accountError === true || cooldownPatterns.some(pattern => pattern.test(message))) return 'cooldown';
return 'none';
}
function createAccountCooldownController(options = {}) {
const now = typeof options.now === 'function' ? options.now : Date.now;
const setTimer = typeof options.setTimer === 'function' ? options.setTimer : setTimeout;
const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : clearTimeout;
const onClearAccount = typeof options.onClearAccount === 'function' ? options.onClearAccount : () => {};
const onChange = typeof options.onChange === 'function' ? options.onChange : () => {};
const active = new Map();
const failures = new Map();
const cooldowns = [15, 30, 60, 120];
let timer = null;
function keyOf(hoster, accountId) {
return `${hoster}:${accountId}`;
}
function records() {
return [...active.values()]
.sort((left, right) => left.key.localeCompare(right.key))
.map(record => ({ ...record }));
}
function publish(cause) {
onChange(records(), cause);
}
function schedule() {
if (timer !== null) {
clearTimer(timer);
timer = null;
}
const deadlines = [...active.values()]
.filter(record => record.mode === 'cooldown' && Number.isFinite(record.pausedUntil))
.map(record => record.pausedUntil);
if (deadlines.length === 0) return;
const delay = Math.max(0, Math.min(...deadlines) - now());
timer = setTimer(() => {
timer = null;
releaseExpired();
}, delay);
}
function markFailure({ hoster, accountId, mode }) {
if (!hoster || !accountId || mode === 'none') return null;
const key = keyOf(hoster, accountId);
const current = active.get(key);
if (current?.mode === 'manual' && mode !== 'manual') return { ...current };
if (current?.mode === mode && (mode === 'manual' || current.pausedUntil > now())) return { ...current };
const count = (failures.get(key) || 0) + 1;
failures.set(key, count);
const minutes = cooldowns[Math.min(count - 1, cooldowns.length - 1)];
const record = {
key,
hoster,
accountId,
mode: mode === 'manual' ? 'manual' : 'cooldown',
failures: count,
pausedUntil: mode === 'manual' ? null : now() + minutes * 60_000
};
active.set(key, record);
publish('failed');
schedule();
return { ...record };
}
function releaseExpired() {
const currentTime = now();
const released = [];
for (const [key, record] of active) {
if (record.mode !== 'cooldown' || record.pausedUntil > currentTime) continue;
active.delete(key);
released.push(key);
onClearAccount(record.hoster, record.accountId);
}
if (released.length > 0) publish('expired');
schedule();
return released;
}
function reset(hoster, accountId, cause = 'reset') {
const key = keyOf(hoster, accountId);
const removed = active.delete(key);
const resetFailures = failures.delete(key);
if (removed || resetFailures) onClearAccount(hoster, accountId);
if (removed) publish(cause);
schedule();
return removed || resetFailures;
}
function markSuccess(hoster, accountId) {
return reset(hoster, accountId, 'success');
}
function clear() {
const current = records();
active.clear();
failures.clear();
for (const record of current) onClearAccount(record.hoster, record.accountId);
if (current.length > 0) publish('clear');
schedule();
return current.length;
}
function dispose() {
if (timer !== null) clearTimer(timer);
timer = null;
}
return Object.freeze({
activeKeys: () => [...active.keys()].sort(),
clear,
dispose,
list: records,
markFailure,
markSuccess,
releaseExpired,
reset
});
}
module.exports = {
classifyAccountFailure,
createAccountCooldownController,
createAccountPicker,
enabledAccountsFor
};
+30 -3
View File
@@ -12,6 +12,7 @@ const Semaphore = require('./semaphore');
const Throttle = require('./throttle');
const { probeFileHead } = require('./file-probe');
const { normalizeFailureDetails } = require('./upload-diagnostics');
const { classifyAccountFailure } = require('./account-rotation');
const DEFAULT_SETTINGS = {
retries: 3,
@@ -117,6 +118,10 @@ class UploadManager extends EventEmitter {
return n;
}
_emitAccountSucceeded(task) {
if (task?.hoster && task?.accountId) this.emit('account-succeeded', { hoster: task.hoster, accountId: task.accountId });
}
// 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
@@ -742,6 +747,7 @@ class UploadManager extends EventEmitter {
attempt
});
recordFinalResult('done', { result });
this._emitAccountSucceeded(task);
return;
} catch (err) {
this.activeJobs.delete(uploadId);
@@ -872,6 +878,7 @@ class UploadManager extends EventEmitter {
}
emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 });
recordFinalResult('done', { result: alt.result });
this._emitAccountSucceeded(task);
return;
}
const stoppedInAlternates = this.stopAfterActive && !signal.aborted;
@@ -932,13 +939,19 @@ class UploadManager extends EventEmitter {
});
break;
}
const pauseMode = classifyAccountFailure(lastError);
const alreadyMarked = this._failedAccounts.has(task.hoster + ':' + task.accountId);
if (!alreadyMarked) {
if (pauseMode !== 'none' && !alreadyMarked) {
this._failedAccounts.set(task.hoster + ':' + task.accountId, true);
this._rotLog('mark-failed', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
this.emit('account-paused', {
hoster: task.hoster,
accountId: task.accountId,
mode: pauseMode
});
this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId });
await this._sleep(800, signal);
// Re-check after the await: the user could have cancelled while
@@ -946,10 +959,15 @@ class UploadManager extends EventEmitter {
// this, rotation proceeds another full attempt-loop's worth of
// work before the next signal-check inside _executeUpload notices.
if (signal.aborted || this.stopAfterActive) break;
} else {
} else if (alreadyMarked) {
this._rotLog('already-marked', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId
});
} else {
this._rotLog('skip-account-pause', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
}
const override = this._accountOverrides.get(task.hoster);
if (!override) {
@@ -1045,6 +1063,7 @@ class UploadManager extends EventEmitter {
this.sessionBytes += fileSize;
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
recordFinalResult('done', { result });
this._emitAccountSucceeded(task);
return;
} catch (err) {
this.activeJobs.delete(uploadId);
@@ -1212,12 +1231,20 @@ class UploadManager extends EventEmitter {
// override and reroute normal-sized files away from a primary that
// still works for them.
if (err && err.accountError === true) {
this._failedAccounts.set(task.hoster + ':' + account.id, true);
const key = task.hoster + ':' + account.id;
const pauseMode = classifyAccountFailure(err);
if (pauseMode === 'none' || this._failedAccounts.has(key)) continue;
this._failedAccounts.set(key, true);
this._rotLog('mark-failed', {
jobId, hoster: task.hoster, fileName, accountId: account.id,
lastError: err && err.message ? err.message : String(err),
suspectAlternate: true
});
this.emit('account-paused', {
hoster: task.hoster,
accountId: account.id,
mode: pauseMode
});
}
}
}
+46 -18
View File
@@ -15,7 +15,7 @@ const VidmolyUploader = require('./lib/vidmoly-upload');
const VoeUploader = require('./lib/voe-upload');
const DoodstreamUploader = require('./lib/doodstream-upload');
const { selectUploadAuth } = require('./lib/account-auth');
const { createAccountPicker } = require('./lib/account-rotation');
const { createAccountCooldownController, createAccountPicker } = require('./lib/account-rotation');
const ClouddropUploader = require('./lib/clouddrop-upload');
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
const backupCrypto = require('./lib/backup-crypto');
@@ -340,7 +340,28 @@ if (!_hasSingleInstanceLock) {
// 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
let _sessionAccountPauseRevision = 0;
function _accountPauseSnapshot(records, cause = 'snapshot') {
return {
version: 2,
revision: _sessionAccountPauseRevision,
now: Date.now(),
cause,
accounts: Array.isArray(records) ? records : []
};
}
function _publishAccountPauseState(records, cause) {
_sessionAccountPauseRevision++;
safeSend('session-failed-accounts-changed', _accountPauseSnapshot(records, cause));
}
const _accountCooldowns = createAccountCooldownController({
onClearAccount: (hoster, accountId) => {
if (uploadManager && typeof uploadManager.clearFailedAccount === 'function') {
try { uploadManager.clearFailedAccount(hoster, accountId); } catch {}
}
},
onChange: _publishAccountPauseState
});
const _sessionAccountOverrides = new Map(); // hoster -> account object
// Per-job log collector: backs the right-click "Log anzeigen" modal so the
@@ -2284,10 +2305,16 @@ ipcMain.handle('start-upload', async (_event, payload) => {
sourceCleanup.settle(event);
});
uploadManager.on('account-paused', ({ hoster, accountId, mode }) => {
const record = _accountCooldowns.markFailure({ hoster, accountId, mode });
if (record) rotLog(`main: account-paused ${hoster} ${accountId} mode=${record.mode} failures=${record.failures} until=${record.pausedUntil || 'manual'}`);
});
uploadManager.on('account-succeeded', ({ hoster, accountId }) => {
if (_accountCooldowns.markSuccess(hoster, accountId)) rotLog(`main: account-pause reset after success ${hoster} ${accountId}`);
});
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) {
@@ -2397,9 +2424,11 @@ ipcMain.handle('start-upload', async (_event, payload) => {
_producerTracker.finish();
return;
}
debugLog(`setImmediate: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`);
_accountCooldowns.releaseExpired();
const pausedAccounts = _accountCooldowns.activeKeys();
debugLog(`setImmediate: calling startBatch now (priming ${pausedAccounts.length} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`);
_thisManager.startBatch(tasks, {
primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()),
primeFailedAccounts: pausedAccounts,
primeOverrides: Array.from(_sessionAccountOverrides.entries())
}).catch((err) => {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
@@ -2503,7 +2532,13 @@ ipcMain.handle('finish-after-active', () => {
});
ipcMain.handle('get-session-failed-accounts', () => {
return Array.from(_sessionFailedAccounts.keys());
_accountCooldowns.releaseExpired();
return _accountCooldowns.activeKeys();
});
ipcMain.handle('get-session-failed-account-states', () => {
_accountCooldowns.releaseExpired();
return _accountPauseSnapshot(_accountCooldowns.list());
});
ipcMain.handle('reset-session-failed-account', (_event, payload) => {
@@ -2511,20 +2546,13 @@ ipcMain.handle('reset-session-failed-account', (_event, payload) => {
const { hoster, accountId } = payload;
if (!hoster || !accountId) return { ok: false };
const key = `${hoster}:${accountId}`;
const removed = _sessionFailedAccounts.delete(key);
if (uploadManager && typeof uploadManager.clearFailedAccount === 'function') {
try { uploadManager.clearFailedAccount(hoster, accountId); } catch {}
}
const removed = _accountCooldowns.reset(hoster, accountId);
rotLog(`session-failed: manual reset ${key} (was set: ${removed})`);
return { ok: true, removed };
});
ipcMain.handle('reset-all-session-failed-accounts', () => {
const count = _sessionFailedAccounts.size;
_sessionFailedAccounts.clear();
if (uploadManager && typeof uploadManager.clearAllFailedAccounts === 'function') {
try { uploadManager.clearAllFailedAccounts(); } catch {}
}
const count = _accountCooldowns.clear();
rotLog(`session-failed: cleared all (${count})`);
return { ok: true, count };
});
@@ -2749,7 +2777,7 @@ async function applyImportedSettings(imported) {
try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {}
await configStore.replaceSettings(prepared);
_rotationCursors = {};
_sessionFailedAccounts.clear();
_accountCooldowns.clear();
_sessionAccountOverrides.clear();
_invalidateLogSettings();
const config = configStore.load();
+5
View File
@@ -135,8 +135,12 @@ contextBridge.exposeInMainWorld('api', {
openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId),
getSessionFailedAccounts: () => ipcRenderer.invoke('get-session-failed-accounts'),
getSessionFailedAccountStates: () => ipcRenderer.invoke('get-session-failed-account-states'),
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
onSessionFailedAccountsChanged: (callback) => {
ipcRenderer.on('session-failed-accounts-changed', (_event, data) => callback(data));
},
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
testWebhook: (payload) => ipcRenderer.invoke('test-webhook', payload),
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
@@ -174,6 +178,7 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.removeAllListeners('folder-monitor:new-files');
ipcRenderer.removeAllListeners('drop-target:files');
ipcRenderer.removeAllListeners('account-switched');
ipcRenderer.removeAllListeners('session-failed-accounts-changed');
ipcRenderer.removeAllListeners('remote:client-count');
}
});
+29 -1
View File
@@ -30,7 +30,35 @@
return 'warn';
}
const accountStatus = { getAccountGroupStatus, getAccountStatusPresentation };
function getAccountPausePresentation(record, now = Date.now()) {
if (record?.mode === 'manual') return { mode: 'manual', remainingSeconds: null, expired: false };
const pausedUntil = Number(record?.pausedUntil);
const remainingSeconds = Number.isFinite(pausedUntil) ? Math.max(0, Math.ceil((pausedUntil - now) / 1000)) : 0;
return { mode: 'cooldown', remainingSeconds, expired: remainingSeconds === 0 };
}
function formatAccountPauseRemaining(seconds) {
const total = Math.max(0, Math.ceil(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
return `${minutes}:${String(total % 60).padStart(2, '0')}`;
}
async function subscribeAccountPauseSnapshots(api, apply) {
if (typeof api?.onSessionFailedAccountsChanged === 'function') api.onSessionFailedAccountsChanged(apply);
if (typeof api?.getSessionFailedAccountStates === 'function') {
apply(await api.getSessionFailedAccountStates());
return;
}
if (typeof api?.getSessionFailedAccounts === 'function') apply(await api.getSessionFailedAccounts());
}
const accountStatus = {
formatAccountPauseRemaining,
getAccountGroupStatus,
getAccountPausePresentation,
getAccountStatusPresentation,
subscribeAccountPauseSnapshots
};
if (typeof module !== 'undefined' && module.exports) module.exports = accountStatus;
if (scope) scope.AccountStatus = accountStatus;
})(typeof window !== 'undefined' ? window : globalThis);
+75 -9
View File
@@ -432,7 +432,7 @@ async function init() {
importEntryCoordinator.ready();
restoreQueueColumnWidths();
loadHistory();
_refreshSessionFailedSnapshot();
await window.AccountStatus.subscribeAccountPauseSnapshots(window.api, _applySessionFailedSnapshot);
renderRecentUploadsPanel();
updateUploadView();
updateStatusBar();
@@ -556,6 +556,7 @@ async function init() {
window.api.onAccountSwitched((data) => {
window.api.debugLog(`account-switched: ${data.hoster} ${data.fromAccountId} -> ${data.toAccountId}`);
});
setInterval(_updateAccountPauseCountdowns, 1000);
// Drop target window: files dropped on the small floating window
window.api.onDropTargetFiles((paths) => {
@@ -3724,6 +3725,9 @@ function handleBatchDone(summary) {
}
let _sessionFailedKeys = new Set();
let _sessionFailedAccountStates = new Map();
let _sessionFailedRevision = -1;
let _sessionFailedRefreshPending = false;
const _autoRetryState = { round: 0, timer: null };
function _cancelAutoRetry(resetRound) {
@@ -3761,12 +3765,71 @@ function _scheduleAutoRetryIfNeeded() {
async function _refreshSessionFailedSnapshot() {
if (!window.api || !window.api.getSessionFailedAccounts) return;
try {
if (window.api.getSessionFailedAccountStates) {
_applySessionFailedSnapshot(await window.api.getSessionFailedAccountStates());
} else {
const keys = await window.api.getSessionFailedAccounts();
_sessionFailedKeys = new Set(Array.isArray(keys) ? keys : []);
renderAccounts();
_applySessionFailedSnapshot(Array.isArray(keys) ? keys : []);
}
} catch { /* ignore */ }
}
function _applySessionFailedSnapshot(snapshot) {
const revision = Number(snapshot?.revision);
if (Number.isFinite(revision) && revision < _sessionFailedRevision) return;
if (Number.isFinite(revision)) _sessionFailedRevision = revision;
const source = Array.isArray(snapshot) ? snapshot : snapshot?.accounts;
const next = new Map();
for (const value of Array.isArray(source) ? source : []) {
if (typeof value === 'string') {
next.set(value, { key: value, mode: 'manual', pausedUntil: null });
continue;
}
if (!value || typeof value !== 'object' || !value.hoster || !value.accountId) continue;
const key = `${value.hoster}:${value.accountId}`;
next.set(key, {
key,
hoster: value.hoster,
accountId: value.accountId,
mode: value.mode === 'manual' ? 'manual' : 'cooldown',
pausedUntil: value.mode === 'manual' ? null : Number(value.pausedUntil),
failures: Number(value.failures) || 1
});
}
if (snapshot?.cause === 'expired') {
for (const [key, record] of _sessionFailedAccountStates) {
if (next.has(key) || record.mode !== 'cooldown') continue;
showCopyToast(`${getHosterLabel(record.hoster)}: ${localizeUiText('Account automatisch wieder aktiv')}`);
}
}
_sessionFailedAccountStates = next;
_sessionFailedKeys = new Set(next.keys());
_sessionFailedRefreshPending = false;
renderAccounts();
}
function _accountPauseText(record, now = Date.now()) {
const presentation = window.AccountStatus.getAccountPausePresentation(record, now);
if (presentation.mode === 'manual') return localizeUiText('Pausiert Aktion nötig');
return `${localizeUiText('Pausiert noch')} ${window.AccountStatus.formatAccountPauseRemaining(presentation.remainingSeconds)}`;
}
function _updateAccountPauseCountdowns() {
let expired = false;
const now = Date.now();
for (const element of document.querySelectorAll('[data-account-pause-key]')) {
const record = _sessionFailedAccountStates.get(element.dataset.accountPauseKey);
if (!record) continue;
const presentation = window.AccountStatus.getAccountPausePresentation(record, now);
if (presentation.expired) expired = true;
element.textContent = _accountPauseText(record, now);
}
if (expired && !_sessionFailedRefreshPending) {
_sessionFailedRefreshPending = true;
_refreshSessionFailedSnapshot().finally(() => { _sessionFailedRefreshPending = false; });
}
}
function _maybeShowBatchSummary(summary) {
if (!window.Stats || !summary) return;
const buckets = window.Stats.summarizeBatchErrors(summary);
@@ -5838,9 +5901,12 @@ function _buildAccountCardHtml(name, account, idx) {
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
const isSessionPaused = _sessionFailedKeys.has(`${name}:${account.id}`);
const sessionPauseKey = `${name}:${account.id}`;
const sessionPause = _sessionFailedAccountStates.get(sessionPauseKey)
|| (_sessionFailedKeys.has(sessionPauseKey) ? { key: sessionPauseKey, mode: 'manual', pausedUntil: null } : null);
const isSessionPaused = Boolean(sessionPause);
const sessionPausedBadge = isSessionPaused
? `<span class="account-session-paused" title="Account wurde diese Session als fehlerhaft markiert. Klick = Wieder als aktiv markieren.">Pausiert (Session) <button class="account-session-reactivate" data-account-reactivate="${account.id}" data-account-reactivate-hoster="${name}" title="Wieder aktivieren">↻</button></span>`
? `<span class="account-session-paused" title="Account wurde diese Session als fehlerhaft markiert. Klick = Wieder als aktiv markieren."><span class="account-session-pause-text" data-account-pause-key="${escapeAttr(sessionPauseKey)}">${escapeHtml(_accountPauseText(sessionPause))}</span> <button class="account-session-reactivate" data-account-reactivate="${account.id}" data-account-reactivate-hoster="${name}" title="Wieder aktivieren">↻</button></span>`
: '';
const otpAction = !isDisabled && statusPresentation.requiresOtp
? `<div class="account-otp-action">
@@ -6292,10 +6358,10 @@ function bindAccountListeners(container) {
const hoster = btn.dataset.accountReactivateHoster;
if (!hoster || !accountId) return;
e.stopPropagation();
window.api.resetSessionFailedAccount({ hoster, accountId }).then(() => {
_sessionFailedKeys.delete(`${hoster}:${accountId}`);
renderAccounts();
showCopyToast(`${getHosterLabel(hoster)} Account wieder aktiv nächste Batch verwendet ihn`);
window.api.resetSessionFailedAccount({ hoster, accountId }).then((result) => {
if (!result?.ok) return;
_refreshSessionFailedSnapshot();
showCopyToast(`${getHosterLabel(hoster)} ${localizeUiText('Account wieder aktiv nächste Batch verwendet ihn')}`);
}).catch(() => {});
return;
}
+4
View File
@@ -410,6 +410,10 @@
['Account-Übersicht', 'Account overview'],
['Account wurde diese Session als fehlerhaft markiert. Klick = Wieder als aktiv markieren.', 'The account was marked as failed for this session. Click to reactivate it.'],
['Pausiert (Session)', 'Paused (session)'],
['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'],
['Wieder aktivieren', 'Reactivate'],
['Accounts mit Handlungsbedarf anzeigen', 'Show accounts requiring action'],
['Alle Accounts und Einstellungen wurden übernommen.', 'All accounts and settings were applied.'],
+5
View File
@@ -1464,6 +1464,11 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
padding: 0 2px;
}
.account-session-reactivate:hover { color: #fff; }
.account-session-pause-text {
min-width: 96px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.account-session-paused-card { opacity: 0.85; }
.batch-cat {
margin-bottom: 10px;
+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.