From 34aaa365718a63591b831def3d1e1d76550a99fe Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 9 Jun 2026 20:39:59 +0200 Subject: [PATCH] feat(unattended): network outage auto-pause/resume, post-batch auto-retry rounds, webhook notifications --- lib/config-store.js | 3 + lib/upload-manager.js | 58 ++++++++++++++++++ lib/webhook-notify.js | 72 ++++++++++++++++++++++ main.js | 112 +++++++++++++++++++++++++++++++++++ preload.js | 4 ++ renderer/app.js | 112 ++++++++++++++++++++++++++++++++--- tests/upload-manager.test.js | 43 ++++++++++++++ tests/webhook-notify.test.js | 82 +++++++++++++++++++++++++ 8 files changed, 478 insertions(+), 8 deletions(-) create mode 100644 lib/webhook-notify.js create mode 100644 tests/webhook-notify.test.js diff --git a/lib/config-store.js b/lib/config-store.js index 18a9da6..4f2e048 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -59,6 +59,9 @@ const DEFAULTS = { logFilePath: '', sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load logVerbose: false, // when true, [DEBUG] level entries are written to debug.log + webhookUrl: '', // POST target on batch-done (Discord or generic JSON) + autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end + autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay) // NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge // would seed logMode='single' for every load, which would beat (and silently // erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in diff --git a/lib/upload-manager.js b/lib/upload-manager.js index f03d2c8..acc1fb4 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -43,6 +43,46 @@ class UploadManager extends EventEmitter { this._accountOverrides = new Map(); // hoster -> fallback account object this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none) this._baselineCache = new Map(); // hoster:apiKey -> Promise> (one fetch shared across all jobs in batch) + this._networkOnline = true; + this._networkWaiters = []; + } + + setNetworkOnline(online) { + const next = !!online; + if (next === this._networkOnline) return; + this._networkOnline = next; + if (next) { + const waiters = this._networkWaiters.splice(0); + for (const resolve of waiters) { try { resolve(); } catch {} } + this._rotLog('network-online', { releasedWaiters: waiters.length }); + } else { + this._rotLog('network-offline', {}); + } + } + + isNetworkOnline() { + return this._networkOnline; + } + + _waitForNetwork(signal) { + if (this._networkOnline) return Promise.resolve(); + if (signal && signal.aborted) return Promise.reject(new Error('Abgebrochen')); + return new Promise((resolve, reject) => { + let settled = false; + const onResume = () => { + if (settled) return; + settled = true; + if (signal) signal.removeEventListener('abort', onAbort); + resolve(); + }; + const onAbort = () => { + if (settled) return; + settled = true; + reject(new Error('Abgebrochen')); + }; + this._networkWaiters.push(onResume); + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + }); } switchAccount(hoster, fallbackAccount) { @@ -505,6 +545,12 @@ class UploadManager extends EventEmitter { for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (signal.aborted || this.stopAfterActive) break; + if (!this._networkOnline) { + this._rotLog('network-wait', { jobId, hoster: task.hoster, fileName, attempt }); + await this._waitForNetwork(signal); + if (signal.aborted || this.stopAfterActive) break; + } + if (attempt > 1) { this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, jobId, @@ -673,6 +719,18 @@ class UploadManager extends EventEmitter { } lastError = err; + // Network outage in progress: the failure is the outage, not the + // file or the account. Hold the job until the gate reopens and + // retry WITHOUT consuming an attempt — a 30-minute ISP flake must + // not burn through the whole retry budget. + if (this._isTransientNetworkError(err) && !this._networkOnline) { + this._rotLog('network-hold', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, attempt + }); + attempt--; + try { await this._waitForNetwork(signal); } catch { lastError = new Error('Abgebrochen'); break; } + continue; + } // File-specific rejection — re-uploading won't change the server's // mind. Break out immediately; the outer file-rejected branch then // records the final error without burning through 5 × 3s retries. diff --git a/lib/webhook-notify.js b/lib/webhook-notify.js new file mode 100644 index 0000000..5a098a8 --- /dev/null +++ b/lib/webhook-notify.js @@ -0,0 +1,72 @@ +function isDiscordWebhook(url) { + return /(^https?:\/\/)(ptb\.|canary\.)?(discord(app)?\.com)\/api\/webhooks\//i.test(String(url || '')); +} + +function formatDurationShort(sec) { + const s = Math.max(0, Math.round(Number(sec) || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = s % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${r}s`; + return `${r}s`; +} + +function summarizePerHosterFromBatch(summary) { + const out = {}; + if (!summary || !Array.isArray(summary.files)) return out; + for (const f of summary.files) { + if (!f || !Array.isArray(f.results)) continue; + for (const r of f.results) { + if (!r || !r.hoster) continue; + const b = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0 }); + if (r.status === 'done') b.ok++; + else b.fail++; + } + } + return out; +} + +function buildWebhookRequest(url, summary, meta) { + const m = meta || {}; + const total = Number(summary && summary.total) || 0; + const succeeded = Number(summary && summary.succeeded) || 0; + const failed = Number(summary && summary.failed) || 0; + const perHoster = summarizePerHosterFromBatch(summary); + const duration = formatDurationShort(m.durationSec); + + let body; + if (isDiscordWebhook(url)) { + const hosterLines = Object.entries(perHoster) + .map(([h, b]) => `${h}: ${b.ok}/${b.ok + b.fail}`) + .join(' · '); + const lines = [ + `**Multi-Hoster-Upload — Batch fertig**${m.machineName ? ` (${m.machineName})` : ''}`, + `✅ ${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}` + ]; + if (hosterLines) lines.push(hosterLines); + body = JSON.stringify({ content: lines.join('\n') }); + } else { + body = JSON.stringify({ + event: 'batch-done', + app: 'multi-hoster-upload', + version: m.appVersion || null, + machine: m.machineName || null, + total, + succeeded, + failed, + durationSec: Math.round(Number(m.durationSec) || 0), + perHoster, + timestamp: m.timestamp || null + }); + } + + return { + url: String(url), + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }; +} + +module.exports = { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest }; diff --git a/main.js b/main.js index afee109..00ec21a 100644 --- a/main.js +++ b/main.js @@ -17,6 +17,7 @@ const RemoteServer = require('./lib/remote-server'); const { maybeRotateLogFile } = require('./lib/log-rotation'); const { hosterLogToFileEnabled } = require('./lib/log-policy'); const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle'); +const { buildWebhookRequest } = require('./lib/webhook-notify'); let mainWindow; let _lastImportPath = null; @@ -228,6 +229,83 @@ function rotLog(msg, ts) { } catch {} } +const NET_CHECK_HOSTS = ['one.one.one.one', 'dns.google']; +let _netMonitorTimer = null; +let _netOnline = true; +let _netFails = 0; +let _netOks = 0; +let _netHostIdx = 0; + +function _dnsProbe(host) { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 5000); + try { + require('dns').resolve(host, (err) => { clearTimeout(timer); resolve(!err); }); + } catch { clearTimeout(timer); resolve(false); } + }); +} + +async function _netCheckTick() { + const host = NET_CHECK_HOSTS[_netHostIdx++ % NET_CHECK_HOSTS.length]; + const ok = await _dnsProbe(host); + if (ok) { _netOks++; _netFails = 0; } else { _netFails++; _netOks = 0; } + if (_netOnline && _netFails >= 2) { + _netOnline = false; + debugLog('network-monitor: OFFLINE (2 consecutive DNS probe failures)'); + rotLog('network-monitor: offline — holding job starts + retries'); + if (uploadManager && typeof uploadManager.setNetworkOnline === 'function') uploadManager.setNetworkOnline(false); + safeSend('network-status', { online: false }); + } else if (!_netOnline && _netOks >= 2) { + _netOnline = true; + debugLog('network-monitor: ONLINE again (2 consecutive DNS probe successes)'); + rotLog('network-monitor: online — resuming held jobs'); + if (uploadManager && typeof uploadManager.setNetworkOnline === 'function') uploadManager.setNetworkOnline(true); + safeSend('network-status', { online: true }); + } +} + +function startNetworkMonitor() { + if (_netMonitorTimer) return; + _netOnline = true; _netFails = 0; _netOks = 0; + _netMonitorTimer = setInterval(() => { _netCheckTick().catch(() => {}); }, 8000); + debugLog('network-monitor: started (8s probe interval)'); +} + +function stopNetworkMonitor() { + if (_netMonitorTimer) { clearInterval(_netMonitorTimer); _netMonitorTimer = null; } + if (!_netOnline && uploadManager && typeof uploadManager.setNetworkOnline === 'function') { + uploadManager.setNetworkOnline(true); + } + _netOnline = true; + debugLog('network-monitor: stopped'); +} + +function sendBatchWebhook(summary, durationSec) { + try { + const gs = configStore.load().globalSettings || {}; + const url = String(gs.webhookUrl || '').trim(); + if (!url || !/^https?:\/\//i.test(url)) return; + const req = buildWebhookRequest(url, summary, { + durationSec, + appVersion: app.getVersion(), + machineName: require('os').hostname(), + timestamp: new Date().toISOString() + }); + fetch(req.url, { + method: req.method, + headers: req.headers, + body: req.body, + signal: AbortSignal.timeout(10_000) + }).then((res) => { + debugLog(`webhook: sent batch-done notification (HTTP ${res.status})`); + }).catch((err) => { + debugLog(`webhook: send failed: ${err && err.message ? err.message : err}`); + }); + } catch (err) { + debugLog(`webhook: build failed: ${err && err.message ? err.message : err}`); + } +} + function safeSend(channel, data) { if (!mainWindow || mainWindow.isDestroyed()) return false; try { @@ -1150,6 +1228,7 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { if (uploadManager) try { uploadManager.cancel(); } catch {} + try { stopNetworkMonitor(); } catch {} try { folderMonitor.stop(); } catch {} try { if (remoteServer) { remoteServer.stop(); remoteServer = null; } @@ -1616,6 +1695,11 @@ ipcMain.handle('start-upload', (_event, payload) => { debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed }); logMemorySnapshot('batch-done'); + stopNetworkMonitor(); + const _batchDurationSec = _thisManager && _thisManager.startTime + ? Math.round((Date.now() - _thisManager.startTime) / 1000) + : 0; + sendBatchWebhook(summary, _batchDurationSec); try { await configStore.appendHistory(summary); } catch (err) { debugLog(`appendHistory failed: ${err.message}`); } @@ -1633,6 +1717,7 @@ ipcMain.handle('start-upload', (_event, payload) => { process.nextTick(() => { if (!uploadManager) { debugLog('nextTick: uploadManager was nulled before startBatch'); return; } debugLog(`nextTick: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`); + startNetworkMonitor(); uploadManager.startBatch(tasks, { primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()), primeOverrides: Array.from(_sessionAccountOverrides.entries()) @@ -1791,6 +1876,33 @@ ipcMain.handle('reveal-log-file', async (_event, target) => { } }); +ipcMain.handle('test-webhook', async (_event, url) => { + const target = String(url || '').trim(); + if (!target || !/^https?:\/\//i.test(target)) return { ok: false, error: 'Ungültige URL (muss mit http(s):// beginnen)' }; + try { + const req = buildWebhookRequest(target, { + total: 3, succeeded: 2, failed: 1, + files: [{ name: 'test.mkv', results: [ + { hoster: 'voe.sx', status: 'done' }, + { hoster: 'byse.sx', status: 'done' }, + { hoster: 'doodstream.com', status: 'error', error: 'Testfehler' } + ] }] + }, { + durationSec: 754, + appVersion: app.getVersion(), + machineName: require('os').hostname(), + timestamp: new Date().toISOString() + }); + const res = await fetch(req.url, { + method: req.method, headers: req.headers, body: req.body, + signal: AbortSignal.timeout(10_000) + }); + return { ok: res.status >= 200 && res.status < 300, status: res.status }; + } catch (err) { + return { ok: false, error: err && err.message ? err.message : String(err) }; + } +}); + ipcMain.handle('set-log-verbose', (_event, enabled) => { setLogVerbose(enabled); logMarker('VERBOSE TOGGLE', { enabled: _logVerbose }); diff --git a/preload.js b/preload.js index db53210..5b215a4 100644 --- a/preload.js +++ b/preload.js @@ -119,6 +119,10 @@ contextBridge.exposeInMainWorld('api', { resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload), resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'), getLogPaths: () => ipcRenderer.invoke('get-log-paths'), + testWebhook: (url) => ipcRenderer.invoke('test-webhook', url), + onNetworkStatus: (callback) => { + ipcRenderer.on('network-status', (_event, data) => callback(data)); + }, revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target), setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled), createSupportBundle: () => ipcRenderer.invoke('create-support-bundle'), diff --git a/renderer/app.js b/renderer/app.js index b75c87d..a038023 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -140,6 +140,18 @@ async function init() { handleStats(data); }); window.api.onShutdownCountdown(handleShutdownCountdown); + if (window.api.onNetworkStatus) { + window.api.onNetworkStatus((data) => { + if (!data || typeof data !== 'object') return; + _networkOffline = !data.online; + if (_networkOffline) { + showCopyToast('Netzwerk-Ausfall erkannt — Uploads pausiert bis die Verbindung zurück ist.', 10000); + } else { + showCopyToast('Netzwerk wieder verfügbar — Uploads werden fortgesetzt.', 6000); + } + updateStatusBar(); + }); + } window.api.onUploadLogFallback((data) => { const path = data && data.fallbackPath ? data.fallbackPath : '(Fallback)'; showCopyToast(`Log-Pfad nicht beschreibbar — schreibe nach: ${path}`, 8000); @@ -1765,8 +1777,10 @@ function getSelectedJobLinks() { } // --- Upload --- -async function startUpload() { +async function startUpload(opts) { if (uploading) return; + if (!(opts && opts._autoRetry)) _cancelAutoRetry(true); + else _cancelAutoRetry(false); uploading = true; // set immediately to prevent double-click race updateQueueActionButtons(); _hydrateMissingJobSizes(); @@ -1943,6 +1957,7 @@ async function startSelectedUpload() { } async function cancelUpload() { + _cancelAutoRetry(true); await window.api.cancelUpload(); uploading = false; // Reset all non-finished jobs back to queued state @@ -2153,9 +2168,45 @@ function handleBatchDone(summary) { lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; updateStatusBar(); _refreshSessionFailedSnapshot(); + _scheduleAutoRetryIfNeeded(); } let _sessionFailedKeys = new Set(); +let _networkOffline = false; + +const _autoRetryState = { round: 0, timer: null }; +function _cancelAutoRetry(resetRound) { + if (_autoRetryState.timer) { clearTimeout(_autoRetryState.timer); _autoRetryState.timer = null; } + if (resetRound) _autoRetryState.round = 0; +} +function _collectAutoRetryableJobs() { + if (!window.Stats) return []; + return queueJobs.filter(j => j.status === 'error' + && window.Stats.isRetryableCategory(window.Stats.classifyErrorCategory(j.error))); +} +function _scheduleAutoRetryIfNeeded() { + const rounds = Math.max(0, Math.min(5, Number(config.globalSettings?.autoRetryRounds) || 0)); + if (rounds <= 0) return; + if (_autoRetryState.round >= rounds) { _autoRetryState.round = 0; return; } + const retryable = _collectAutoRetryableJobs(); + if (retryable.length === 0) { _autoRetryState.round = 0; return; } + const delayMin = Math.max(1, Math.min(120, Number(config.globalSettings?.autoRetryDelayMin) || 5)); + const nextRound = _autoRetryState.round + 1; + const waitMin = delayMin * nextRound; + _autoRetryState.round = nextRound; + showCopyToast(`Auto-Retry Runde ${nextRound}/${rounds}: ${retryable.length} transiente Fehler werden in ${waitMin} min neu versucht.`, 10000); + _autoRetryState.timer = setTimeout(() => { + _autoRetryState.timer = null; + const jobs = _collectAutoRetryableJobs(); + if (jobs.length === 0) { _autoRetryState.round = 0; return; } + for (const j of jobs) { + j.status = 'queued'; j.error = null; j.result = null; + j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; + } + renderQueueTable(); + startUpload({ _autoRetry: true }); + }, waitMin * 60_000); +} async function _refreshSessionFailedSnapshot() { if (!window.api || !window.api.getSessionFailedAccounts) return; try { @@ -2579,13 +2630,15 @@ function updateStatusBar() { ? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024)) : 0; - const stateText = lastUploadStats.state === 'uploading' - ? 'Upload läuft...' - : lastUploadStats.state === 'stopping' - ? 'Stoppt nach aktiven Uploads...' - : uploading - ? 'Upload vorbereitet...' - : 'Bereit'; + const stateText = (_networkOffline && (uploading || lastUploadStats.state === 'uploading')) + ? 'Netzwerk-Ausfall — pausiert' + : lastUploadStats.state === 'uploading' + ? 'Upload läuft...' + : lastUploadStats.state === 'stopping' + ? 'Stoppt nach aktiven Uploads...' + : uploading + ? 'Upload vorbereitet...' + : 'Bereit'; document.getElementById('sbState').textContent = stateText; document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0); @@ -2771,6 +2824,23 @@ function renderSettings() { DEBUG-Einträge in debug.log schreiben (Performance ↓, Diagnostik ↑) + +
+ + + 0 = aus. Nach Batch-Ende werden transiente Fehler (Netzwerk, Hoster-Flake) automatisch bis zu N Runden neu versucht. +
+
+ + + Basis-Wartezeit; Runde N wartet N × diesen Wert (lineares Backoff). +
+
+ + + + Bei Batch-Ende wird eine Zusammenfassung gepostet (Discord wird automatisch erkannt, sonst generisches JSON). +
@@ -2787,6 +2857,28 @@ function renderSettings() { `; container.appendChild(generalPanel); _renderLogPathsList(generalPanel.querySelector('#logPathsList')); + const testWebhookBtn = generalPanel.querySelector('#testWebhookBtn'); + if (testWebhookBtn) { + testWebhookBtn.addEventListener('click', async () => { + const url = (document.getElementById('webhookUrlInput')?.value || '').trim(); + const hint = document.getElementById('webhookHint'); + if (!url) { if (hint) hint.textContent = 'Keine URL eingetragen.'; return; } + testWebhookBtn.disabled = true; + const prev = testWebhookBtn.textContent; + testWebhookBtn.textContent = 'Sende…'; + try { + const res = await window.api.testWebhook(url); + if (hint) hint.textContent = res && res.ok + ? `Test erfolgreich gesendet (HTTP ${res.status}).` + : `Test fehlgeschlagen: ${(res && (res.error || 'HTTP ' + res.status)) || 'unbekannt'}`; + } catch (err) { + if (hint) hint.textContent = `Test fehlgeschlagen: ${err.message || err}`; + } finally { + testWebhookBtn.disabled = false; + testWebhookBtn.textContent = prev; + } + }); + } const verboseInput = generalPanel.querySelector('#logVerboseInput'); if (verboseInput) { verboseInput.addEventListener('change', () => { @@ -3166,6 +3258,10 @@ async function saveSettings(options = {}) { removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked, showDropTarget: !!document.getElementById('showDropTargetInput')?.checked, globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)), + logVerbose: !!document.getElementById('logVerboseInput')?.checked, + webhookUrl: (document.getElementById('webhookUrlInput')?.value || '').trim(), + autoRetryRounds: Math.max(0, Math.min(5, parseInt(document.getElementById('autoRetryRoundsInput')?.value || '0', 10) || 0)), + autoRetryDelayMin: Math.max(1, Math.min(120, parseInt(document.getElementById('autoRetryDelayMinInput')?.value || '5', 10) || 5)), folderMonitor: { enabled: !!document.getElementById('fmEnabledInput')?.checked, folderPath: (document.getElementById('fmFolderPathInput')?.value || '').trim(), diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index 7c10273..e4e5a06 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -46,6 +46,49 @@ describe('UploadManager', () => { UploadManager = require('../lib/upload-manager'); }); + it('network gate: _waitForNetwork resolves immediately when online', async () => { + const mgr = new UploadManager({}); + assert.strictEqual(mgr.isNetworkOnline(), true); + await mgr._waitForNetwork(); + }); + + it('network gate: waiters block while offline and release on setNetworkOnline(true)', async () => { + const mgr = new UploadManager({}); + mgr.setNetworkOnline(false); + assert.strictEqual(mgr.isNetworkOnline(), false); + let resolved = false; + const waiter = mgr._waitForNetwork().then(() => { resolved = true; }); + await new Promise(r => setTimeout(r, 30)); + assert.strictEqual(resolved, false, 'must still be blocked while offline'); + mgr.setNetworkOnline(true); + await waiter; + assert.strictEqual(resolved, true); + }); + + it('network gate: abort signal rejects a pending waiter', async () => { + const mgr = new UploadManager({}); + mgr.setNetworkOnline(false); + const ac = new AbortController(); + const waiter = mgr._waitForNetwork(ac.signal); + ac.abort(); + await assert.rejects(waiter, /Abgebrochen/); + }); + + it('network gate: batch with offline gate holds queued job until resume', async () => { + const mgr = new UploadManager({}); + mgr.setNetworkOnline(false); + const statuses = []; + mgr.on('progress', (d) => statuses.push(d.status)); + const batch = mgr.startBatch([ + { file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + await new Promise(r => setTimeout(r, 60)); + assert.ok(!statuses.includes('done'), 'job must not complete while gate is closed'); + mgr.setNetworkOnline(true); + await batch; + assert.ok(statuses.includes('done'), 'job completes after gate reopens'); + }); + it('emits progress events for each task', async () => { const mgr = new UploadManager({}); const events = []; diff --git a/tests/webhook-notify.test.js b/tests/webhook-notify.test.js new file mode 100644 index 0000000..f884e3f --- /dev/null +++ b/tests/webhook-notify.test.js @@ -0,0 +1,82 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest } = require('../lib/webhook-notify'); + +const SAMPLE_SUMMARY = { + total: 10, + succeeded: 8, + failed: 2, + files: [ + { name: 'a.mkv', results: [ + { hoster: 'voe.sx', status: 'done' }, + { hoster: 'byse.sx', status: 'error', error: 'x' } + ] }, + { name: 'b.mkv', results: [ + { hoster: 'voe.sx', status: 'done' }, + { hoster: 'byse.sx', status: 'done' } + ] } + ] +}; + +test('isDiscordWebhook recognizes discord URLs incl. ptb/canary/discordapp', () => { + assert.ok(isDiscordWebhook('https://discord.com/api/webhooks/123/abc')); + assert.ok(isDiscordWebhook('https://discordapp.com/api/webhooks/123/abc')); + assert.ok(isDiscordWebhook('https://ptb.discord.com/api/webhooks/123/abc')); + assert.ok(isDiscordWebhook('https://canary.discord.com/api/webhooks/123/abc')); + assert.strictEqual(isDiscordWebhook('https://example.com/hook'), false); + assert.strictEqual(isDiscordWebhook(''), false); + assert.strictEqual(isDiscordWebhook(null), false); +}); + +test('formatDurationShort formats h/m/s tiers', () => { + assert.strictEqual(formatDurationShort(45), '45s'); + assert.strictEqual(formatDurationShort(125), '2m 5s'); + assert.strictEqual(formatDurationShort(3 * 3600 + 12 * 60), '3h 12m'); + assert.strictEqual(formatDurationShort(-5), '0s'); + assert.strictEqual(formatDurationShort(undefined), '0s'); +}); + +test('summarizePerHosterFromBatch counts ok/fail per hoster', () => { + const s = summarizePerHosterFromBatch(SAMPLE_SUMMARY); + assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0 }); + assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1 }); +}); + +test('summarizePerHosterFromBatch handles malformed input', () => { + assert.deepStrictEqual(summarizePerHosterFromBatch(null), {}); + assert.deepStrictEqual(summarizePerHosterFromBatch({}), {}); + assert.deepStrictEqual(summarizePerHosterFromBatch({ files: [{ results: null }] }), {}); +}); + +test('buildWebhookRequest produces Discord content body for discord URLs', () => { + const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 3700, appVersion: '3.3.59', machineName: 'srv-1' }); + assert.strictEqual(req.method, 'POST'); + assert.strictEqual(req.headers['Content-Type'], 'application/json'); + const body = JSON.parse(req.body); + assert.ok(typeof body.content === 'string'); + assert.match(body.content, /Batch fertig/); + assert.match(body.content, /srv-1/); + assert.match(body.content, /8 ok/); + assert.match(body.content, /2 Fehler/); + assert.match(body.content, /1h 1m/); + assert.match(body.content, /voe\.sx: 2\/2/); +}); + +test('buildWebhookRequest produces raw JSON payload for generic URLs', () => { + const req = buildWebhookRequest('https://example.com/hook', SAMPLE_SUMMARY, { durationSec: 60, appVersion: '3.3.59', timestamp: '2026-06-09T00:00:00Z' }); + const body = JSON.parse(req.body); + assert.strictEqual(body.event, 'batch-done'); + assert.strictEqual(body.total, 10); + assert.strictEqual(body.succeeded, 8); + assert.strictEqual(body.failed, 2); + assert.strictEqual(body.durationSec, 60); + assert.strictEqual(body.version, '3.3.59'); + assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1 }); +}); + +test('buildWebhookRequest tolerates empty summary', () => { + const req = buildWebhookRequest('https://example.com/hook', null, {}); + const body = JSON.parse(req.body); + assert.strictEqual(body.total, 0); + assert.strictEqual(body.succeeded, 0); +});