From b769467d0865f1dc516826c95a4ea1e246413c5b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:18:02 +0200 Subject: [PATCH] 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. --- lib/account-rotation.js | 153 +++++++++++++++++++++++++++++- lib/upload-manager.js | 33 ++++++- main.js | 64 +++++++++---- preload.js | 5 + renderer/account-status.js | 30 +++++- renderer/app.js | 86 +++++++++++++++-- renderer/i18n.js | 4 + renderer/styles.css | 5 + tests/account-rotation.test.js | 138 ++++++++++++++++++++++++++- tests/account-status.test.js | 54 ++++++++++- tests/i18n.test.js | 13 +++ tests/package-build-files.test.js | 44 +++++++++ tests/upload-manager.test.js | 59 ++++++++++++ 13 files changed, 653 insertions(+), 35 deletions(-) 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 ? `