diff --git a/lib/account-rotation.js b/lib/account-rotation.js index de6ee7c..27ac9c8 100644 --- a/lib/account-rotation.js +++ b/lib/account-rotation.js @@ -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 +}; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index ee2b367..165befe 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -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 + }); } } } diff --git a/main.js b/main.js index 7bc49fb..6d97e90 100644 --- a/main.js +++ b/main.js @@ -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(); diff --git a/preload.js b/preload.js index da696f2..bd1756c 100644 --- a/preload.js +++ b/preload.js @@ -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'); } }); diff --git a/renderer/account-status.js b/renderer/account-status.js index c9d2add..fc1bbf9 100644 --- a/renderer/account-status.js +++ b/renderer/account-status.js @@ -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); diff --git a/renderer/app.js b/renderer/app.js index fc24126..7e9a688 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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 { - const keys = await window.api.getSessionFailedAccounts(); - _sessionFailedKeys = new Set(Array.isArray(keys) ? keys : []); - renderAccounts(); + if (window.api.getSessionFailedAccountStates) { + _applySessionFailedSnapshot(await window.api.getSessionFailedAccountStates()); + } else { + const keys = await window.api.getSessionFailedAccounts(); + _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 - ? `Pausiert (Session) ` + ? `${escapeHtml(_accountPauseText(sessionPause))} ` : ''; const otpAction = !isDisabled && statusPresentation.requiresOtp ? `
@@ -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; } diff --git a/renderer/i18n.js b/renderer/i18n.js index cef0ea8..d9230ba 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -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.'], diff --git a/renderer/styles.css b/renderer/styles.css index 7317729..9538094 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -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; diff --git a/tests/account-rotation.test.js b/tests/account-rotation.test.js index fd47b55..3c09913 100644 --- a/tests/account-rotation.test.js +++ b/tests/account-rotation.test.js @@ -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'); +}); diff --git a/tests/account-status.test.js b/tests/account-status.test.js index fd14b40..dbe836f 100644 --- a/tests/account-status.test.js +++ b/tests/account-status.test.js @@ -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: [] } + ]); +}); diff --git a/tests/i18n.test.js b/tests/i18n.test.js index f6b02da..c0df1be 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -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'); diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index 64dfb71..53da69f 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -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); +}); diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index 1650d08..b10b368 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -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.