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 ? `