diff --git a/lib/stats.js b/lib/stats.js new file mode 100644 index 0000000..277c21e --- /dev/null +++ b/lib/stats.js @@ -0,0 +1,142 @@ +(function (root) { + function summarizePerHoster(history, opts) { + const out = {}; + if (!Array.isArray(history)) return out; + const cutoff = opts && Number.isFinite(opts.sinceMs) ? opts.sinceMs : null; + const limitBatches = opts && Number.isFinite(opts.lastNBatches) && opts.lastNBatches > 0 ? opts.lastNBatches : null; + + const entries = [...history]; + entries.sort((a, b) => { + const ta = a && a.timestamp ? Date.parse(a.timestamp) : 0; + const tb = b && b.timestamp ? Date.parse(b.timestamp) : 0; + return tb - ta; + }); + const sliced = limitBatches ? entries.slice(0, limitBatches) : entries; + + for (const batch of sliced) { + if (!batch || !Array.isArray(batch.files)) continue; + if (cutoff !== null) { + const ts = batch.timestamp ? Date.parse(batch.timestamp) : 0; + if (!ts || ts < cutoff) continue; + } + for (const file of batch.files) { + if (!file || !Array.isArray(file.results)) continue; + for (const r of file.results) { + if (!r || !r.hoster) continue; + const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, total: 0 }); + bucket.total++; + if (r.status === 'done') bucket.ok++; + else bucket.fail++; + } + } + } + for (const h of Object.keys(out)) { + const b = out[h]; + b.rate = b.total > 0 ? b.ok / b.total : null; + } + return out; + } + + function classifyErrorCategory(err) { + if (!err || typeof err !== 'string') return 'unknown'; + const s = err.toLowerCase(); + if (/abgebrochen|aborted|cancel/.test(s)) return 'aborted'; + if (/not video file format|kein videoformat|invalid file|wrong format|duplicate|already exists|file too (small|big|large)|datei zu (gro|klein)/.test(s)) return 'file-rejected'; + if (/quota|storage (full|exhausted|voll)|account (full|banned|suspended)|disk (space )?full|insufficient (disk )?space|not enough (disk )?(space|storage)/.test(s)) return 'account-error'; + if (/csrf|kein upload-server|server.*?(busy|unavailable|try again)|no servers available|filecode|kein filecode|empty.*?(form|response)/.test(s)) return 'hoster-transient'; + if (/timeout|econnreset|enotfound|fetch failed|network|socket hang up|abort/.test(s)) return 'network'; + return 'unknown'; + } + + function summarizeBatchErrors(batchSummary) { + const buckets = { + 'file-rejected': [], + 'account-error': [], + 'hoster-transient': [], + 'network': [], + 'unknown': [], + 'aborted': [] + }; + if (!batchSummary || !Array.isArray(batchSummary.files)) return buckets; + for (const f of batchSummary.files) { + if (!f || !Array.isArray(f.results)) continue; + for (const r of f.results) { + if (!r || r.status === 'done') continue; + const cat = classifyErrorCategory(r.error); + buckets[cat].push({ + fileName: f.name || f.fileName || '', + hoster: r.hoster || '', + error: r.error || '', + jobId: r.jobId || null + }); + } + } + return buckets; + } + + const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']); + function isRetryableCategory(cat) { + return RETRYABLE_CATEGORIES.has(cat); + } + + const CATEGORY_LABELS = { + 'file-rejected': 'Datei abgelehnt', + 'account-error': 'Account-Problem', + 'hoster-transient': 'Hoster-Flake', + 'network': 'Netzwerk', + 'unknown': 'Unbekannt', + 'aborted': 'Abgebrochen' + }; + + function formatLinks(rows, format) { + if (!Array.isArray(rows)) return ''; + const safe = rows.filter(r => r && r.url); + if (safe.length === 0) return ''; + switch (format) { + case 'plain': + return safe.map(r => r.url).join('\n'); + case 'bbcode': + return safe.map(r => { + const label = r.fileName || r.hoster || r.url; + return `[url=${r.url}]${label}[/url]`; + }).join('\n'); + case 'markdown': + return safe.map(r => { + const label = r.fileName || r.hoster || r.url; + return `- [${label}](${r.url})`; + }).join('\n'); + case 'html': + return safe.map(r => { + const label = r.fileName || r.hoster || r.url; + return `${label}`; + }).join('\n'); + case 'csv': { + const head = 'fileName,hoster,url\n'; + return head + safe.map(r => { + const esc = (v) => `"${String(v || '').replace(/"/g, '""')}"`; + return [esc(r.fileName), esc(r.hoster), esc(r.url)].join(','); + }).join('\n'); + } + case 'json': + return JSON.stringify(safe.map(r => ({ fileName: r.fileName || '', hoster: r.hoster || '', url: r.url })), null, 2); + default: + return safe.map(r => r.url).join('\n'); + } + } + + const api = { + summarizePerHoster, + classifyErrorCategory, + summarizeBatchErrors, + isRetryableCategory, + RETRYABLE_CATEGORIES, + CATEGORY_LABELS, + formatLinks + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } else if (root) { + root.Stats = api; + } +})(typeof window !== 'undefined' ? window : this); diff --git a/lib/upload-manager.js b/lib/upload-manager.js index ce99002..5e8102f 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -66,6 +66,16 @@ class UploadManager extends EventEmitter { return this._accountOverrides.get(hoster) || null; } + clearFailedAccount(hoster, accountId) { + return this._failedAccounts.delete(`${hoster}:${accountId}`); + } + + clearAllFailedAccounts() { + const n = this._failedAccounts.size; + this._failedAccounts.clear(); + return n; + } + // 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 diff --git a/main.js b/main.js index 78f6833..5b879ba 100644 --- a/main.js +++ b/main.js @@ -1575,6 +1575,33 @@ ipcMain.handle('finish-after-active', () => { return true; }); +ipcMain.handle('get-session-failed-accounts', () => { + return Array.from(_sessionFailedAccounts.keys()); +}); + +ipcMain.handle('reset-session-failed-account', (_event, payload) => { + if (!payload || typeof payload !== 'object') return { ok: false }; + 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 {} + } + 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 {} + } + rotLog(`session-failed: cleared all (${count})`); + return { ok: true, count }; +}); + ipcMain.handle('get-job-log', (_event, jobId) => { if (!jobId || typeof jobId !== 'string') return []; const arr = _jobLogCollector.get(jobId); diff --git a/preload.js b/preload.js index 8eb10a6..c3e6446 100644 --- a/preload.js +++ b/preload.js @@ -110,6 +110,9 @@ contextBridge.exposeInMainWorld('api', { }, openLogFolder: () => ipcRenderer.invoke('open-log-folder'), getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId), + getSessionFailedAccounts: () => ipcRenderer.invoke('get-session-failed-accounts'), + resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload), + resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'), getLogPaths: () => ipcRenderer.invoke('get-log-paths'), revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target), setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled), diff --git a/renderer/app.js b/renderer/app.js index a2b112a..61b75cb 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -92,6 +92,7 @@ async function init() { setupDragDrop(); restoreQueueColumnWidths(); loadHistory(); + _refreshSessionFailedSnapshot(); renderRecentUploadsPanel(); updateUploadView(); updateStatusBar(); @@ -2056,6 +2057,85 @@ function handleBatchDone(summary) { lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; updateStatusBar(); + _maybeShowBatchSummary(summary); + _refreshSessionFailedSnapshot(); +} + +let _sessionFailedKeys = new Set(); +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(); + } catch { /* ignore */ } +} + +function _maybeShowBatchSummary(summary) { + if (!window.Stats || !summary) return; + const buckets = window.Stats.summarizeBatchErrors(summary); + const total = Object.values(buckets).reduce((n, arr) => n + arr.length, 0); + if (total === 0) return; + + const modal = document.getElementById('batchSummaryModal'); + if (!modal) return; + const list = modal.querySelector('#batchSummaryList'); + const retryAllBtn = modal.querySelector('#batchSummaryRetryAll'); + const retryTransientBtn = modal.querySelector('#batchSummaryRetryTransient'); + const closeBtn = modal.querySelector('#batchSummaryClose'); + + const order = ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error', 'aborted']; + list.innerHTML = order + .filter(cat => buckets[cat].length > 0) + .map(cat => { + const items = buckets[cat]; + const sample = items.slice(0, 3).map(i => `
  • ${escapeHtml(i.fileName)} → ${escapeHtml(i.hoster)}: ${escapeHtml(i.error)}
  • `).join(''); + const more = items.length > 3 ? `
  • … +${items.length - 3} weitere
  • ` : ''; + const retryable = window.Stats.isRetryableCategory(cat); + const tag = retryable ? 'erneut versuchbar' : 'manuell'; + return `
    +
    ${escapeHtml(window.Stats.CATEGORY_LABELS[cat] || cat)} ${items.length} ${tag}
    + +
    `; + }).join(''); + + const transientCount = ['hoster-transient', 'network', 'unknown'].reduce((n, c) => n + buckets[c].length, 0); + retryTransientBtn.textContent = transientCount > 0 ? `Transiente erneut hochladen (${transientCount})` : 'Keine transienten Fehler'; + retryTransientBtn.disabled = transientCount === 0; + const allRetryable = total - buckets['aborted'].length; + retryAllBtn.textContent = `Alle Fehler erneut versuchen (${allRetryable})`; + retryAllBtn.disabled = allRetryable === 0; + + const close = () => { modal.style.display = 'none'; }; + closeBtn.onclick = close; + retryAllBtn.onclick = () => { _retryFailedFromBuckets(buckets, false); close(); }; + retryTransientBtn.onclick = () => { _retryFailedFromBuckets(buckets, true); close(); }; + modal.style.display = 'flex'; +} + +function _retryFailedFromBuckets(buckets, transientOnly) { + const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error']; + const toRetry = []; + for (const cat of cats) { + for (const item of (buckets[cat] || [])) toRetry.push(item); + } + if (toRetry.length === 0) return; + const jobsToRetry = []; + for (const item of toRetry) { + const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && (j.status === 'error' || j.status === 'skipped')); + if (job) { + job.status = 'queued'; + job.progress = 0; + job.bytesUploaded = 0; + job.error = null; + job.result = null; + jobsToRetry.push(job); + } + } + if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; } + renderQueueTable(); + showCopyToast(`${jobsToRetry.length} Job(s) zum erneuten Upload zurückgesetzt`); + if (typeof startUpload === 'function') startUpload(); } function handleStats(data) { @@ -3118,11 +3198,16 @@ 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 sessionPausedBadge = isSessionPaused + ? `Pausiert (Session) ` + : ''; + return ` -
    +
    + + + diff --git a/renderer/styles.css b/renderer/styles.css index d5bee46..13af7ac 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -916,6 +916,40 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } color: var(--danger, #e57373); background: rgba(229, 115, 115, 0.12); } +.account-session-paused { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: #f0c36c; + background: rgba(240, 195, 108, 0.12); + padding: 1px 6px; + border-radius: 4px; + margin-left: 6px; +} +.account-session-reactivate { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 0 2px; +} +.account-session-reactivate:hover { color: #fff; } +.account-session-paused-card { opacity: 0.85; } +.batch-cat { + margin-bottom: 10px; + padding: 6px 8px; + border-radius: 6px; + background: rgba(255,255,255,0.03); +} +.batch-cat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; } +.batch-cat-count { color: var(--text-muted); font-variant-numeric: tabular-nums; } +.batch-cat-tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-muted); } +.batch-cat-tag.retryable { background: rgba(76, 175, 80, 0.18); color: #a5d6a7; } +.batch-cat-list { margin: 0; padding-left: 18px; font-size: 11px; color: var(--text-muted); } +.batch-cat-list em { color: var(--text-muted); font-style: italic; } .account-hoster-group-body { padding: 8px; border-top: 1px solid var(--border); diff --git a/tests/stats.test.js b/tests/stats.test.js new file mode 100644 index 0000000..2dd4dab --- /dev/null +++ b/tests/stats.test.js @@ -0,0 +1,132 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { + summarizePerHoster, + classifyErrorCategory, + summarizeBatchErrors, + isRetryableCategory +} = require('../lib/stats'); + +function makeBatch(timestamp, results) { + return { + id: 'b-' + timestamp, + timestamp: new Date(timestamp).toISOString(), + files: [{ name: 'foo.mp4', size: 1, results }] + }; +} + +test('summarizePerHoster counts ok and fail per hoster across all batches', () => { + const history = [ + makeBatch(1, [ + { hoster: 'voe.sx', status: 'done' }, + { hoster: 'byse.sx', status: 'error', error: 'Not video file format' } + ]), + makeBatch(2, [ + { hoster: 'voe.sx', status: 'done' }, + { hoster: 'voe.sx', status: 'error', error: 'CSRF' }, + { hoster: 'byse.sx', status: 'done' } + ]) + ]; + const s = summarizePerHoster(history); + assert.strictEqual(s['voe.sx'].ok, 2); + assert.strictEqual(s['voe.sx'].fail, 1); + assert.strictEqual(s['voe.sx'].total, 3); + assert.strictEqual(Math.round(s['voe.sx'].rate * 100), 67); + assert.strictEqual(s['byse.sx'].ok, 1); + assert.strictEqual(s['byse.sx'].fail, 1); + assert.strictEqual(s['byse.sx'].rate, 0.5); +}); + +test('summarizePerHoster honors sinceMs cutoff', () => { + const history = [ + makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]), + makeBatch(5000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }]) + ]; + const s = summarizePerHoster(history, { sinceMs: 3000 }); + assert.strictEqual(s['voe.sx'].ok, 0); + assert.strictEqual(s['voe.sx'].fail, 1); +}); + +test('summarizePerHoster honors lastNBatches (newest first)', () => { + const history = [ + makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]), + makeBatch(2000, [{ hoster: 'voe.sx', status: 'done' }]), + makeBatch(3000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }]) + ]; + const s = summarizePerHoster(history, { lastNBatches: 1 }); + assert.strictEqual(s['voe.sx'].ok, 0); + assert.strictEqual(s['voe.sx'].fail, 1); +}); + +test('summarizePerHoster handles empty / malformed input', () => { + assert.deepStrictEqual(summarizePerHoster(null), {}); + assert.deepStrictEqual(summarizePerHoster([]), {}); + assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {}); +}); + +test('classifyErrorCategory: file-rejected phrases', () => { + assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected'); + assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected'); + assert.strictEqual(classifyErrorCategory('Datei zu groß (Max: 5 GB)'), 'file-rejected'); +}); + +test('classifyErrorCategory: account-error phrases', () => { + assert.strictEqual(classifyErrorCategory('Quota exceeded'), 'account-error'); + assert.strictEqual(classifyErrorCategory('account banned'), 'account-error'); + assert.strictEqual(classifyErrorCategory('not enough disk space'), 'account-error'); +}); + +test('classifyErrorCategory: hoster-transient phrases', () => { + assert.strictEqual(classifyErrorCategory('CSRF-Token nicht gefunden'), 'hoster-transient'); + assert.strictEqual(classifyErrorCategory('Kein Upload-Server erhalten: server busy'), 'hoster-transient'); + assert.strictEqual(classifyErrorCategory('Kein Filecode'), 'hoster-transient'); +}); + +test('classifyErrorCategory: network phrases', () => { + assert.strictEqual(classifyErrorCategory('socket hang up'), 'network'); + assert.strictEqual(classifyErrorCategory('fetch failed'), 'network'); + assert.strictEqual(classifyErrorCategory('Timeout while reading'), 'network'); +}); + +test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => { + assert.strictEqual(classifyErrorCategory('Abgebrochen'), 'aborted'); + assert.strictEqual(isRetryableCategory('aborted'), false); +}); + +test('classifyErrorCategory: unknown for everything else', () => { + assert.strictEqual(classifyErrorCategory(''), 'unknown'); + assert.strictEqual(classifyErrorCategory(null), 'unknown'); + assert.strictEqual(classifyErrorCategory('Some weird thing'), 'unknown'); +}); + +test('summarizeBatchErrors buckets results by category', () => { + const summary = { + files: [ + { name: 'a.mp4', results: [ + { hoster: 'voe.sx', status: 'done' }, + { hoster: 'byse.sx', status: 'error', error: 'Not video file format' } + ] }, + { name: 'b.mp4', results: [ + { hoster: 'voe.sx', status: 'error', error: 'CSRF' }, + { hoster: 'doodstream.com', status: 'error', error: 'socket hang up' } + ] } + ] + }; + const buckets = summarizeBatchErrors(summary); + assert.strictEqual(buckets['file-rejected'].length, 1); + assert.strictEqual(buckets['file-rejected'][0].hoster, 'byse.sx'); + assert.strictEqual(buckets['hoster-transient'].length, 1); + assert.strictEqual(buckets['hoster-transient'][0].hoster, 'voe.sx'); + assert.strictEqual(buckets['network'].length, 1); + assert.strictEqual(buckets['network'][0].hoster, 'doodstream.com'); + assert.strictEqual(buckets['account-error'].length, 0); +}); + +test('isRetryableCategory: only transient + network + unknown retry-worthy', () => { + assert.strictEqual(isRetryableCategory('hoster-transient'), true); + assert.strictEqual(isRetryableCategory('network'), true); + assert.strictEqual(isRetryableCategory('unknown'), true); + assert.strictEqual(isRetryableCategory('file-rejected'), false); + assert.strictEqual(isRetryableCategory('account-error'), false); + assert.strictEqual(isRetryableCategory('aborted'), false); +});