diff --git a/lib/webhook-notify.js b/lib/webhook-notify.js index ea26507..b679df7 100644 --- a/lib/webhook-notify.js +++ b/lib/webhook-notify.js @@ -1,5 +1,13 @@ function isDiscordWebhook(url) { - return /(^https?:\/\/)(ptb\.|canary\.)?(discord(app)?\.com)\/api\/webhooks\//i.test(String(url || '')); + return /^https?:\/\/(ptb\.|canary\.)?(discord(app)?\.com)\/api\/webhooks\/\d+\/[\w-]+/i.test(String(url || '')); +} + +const DISCORD_CONTENT_LIMIT = 1900; + +function clampDiscordContent(text) { + const s = String(text || ''); + if (s.length <= DISCORD_CONTENT_LIMIT) return s; + return s.slice(0, DISCORD_CONTENT_LIMIT - 1) + '…'; } function formatDurationShort(sec) { @@ -57,16 +65,21 @@ function buildWebhookRequest(url, summary, meta) { let body; if (isDiscordWebhook(url)) { - const hosterLines = Object.entries(perHoster) + const headline = m.aborted ? 'Batch abgebrochen' : 'Batch fertig'; + const hosterEntries = Object.entries(perHoster); + const MAX_HOSTER_LINES = 12; + let hosterLines = hosterEntries.slice(0, MAX_HOSTER_LINES) .map(([h, b]) => `${h}: ${b.ok}/${b.ok + b.fail}`) .join(' · '); + if (hosterEntries.length > MAX_HOSTER_LINES) hosterLines += ` · …+${hosterEntries.length - MAX_HOSTER_LINES}`; const lines = [ - `**Multi-Hoster-Upload — Batch fertig**${m.machineName ? ` (${m.machineName})` : ''}`, + `**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`, `✅ ${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}` ]; if (hosterLines) lines.push(hosterLines); const mention = resolveDiscordMention(m.mention); - const payload = { content: (mention ? mention.token + ' ' : '') + lines.join('\n') }; + const content = clampDiscordContent((mention ? mention.token + ' ' : '') + lines.join('\n')); + const payload = { content }; payload.allowed_mentions = mention ? mention.allowed : { parse: [] }; body = JSON.stringify(payload); } else { @@ -79,6 +92,7 @@ function buildWebhookRequest(url, summary, meta) { succeeded, failed, durationSec: Math.round(Number(m.durationSec) || 0), + aborted: !!m.aborted, perHoster, timestamp: m.timestamp || null }); @@ -92,4 +106,18 @@ function buildWebhookRequest(url, summary, meta) { }; } -module.exports = { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention }; +function isAllAborted(summary) { + if (!summary || !Array.isArray(summary.files) || summary.files.length === 0) return false; + let sawResult = false; + for (const f of summary.files) { + if (!f || !Array.isArray(f.results)) continue; + for (const r of f.results) { + if (!r) continue; + sawResult = true; + if (r.status !== 'aborted') return false; + } + } + return sawResult; +} + +module.exports = { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention, isAllAborted, clampDiscordContent, DISCORD_CONTENT_LIMIT }; diff --git a/main.js b/main.js index 475e973..ad8d7a3 100644 --- a/main.js +++ b/main.js @@ -17,7 +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'); +const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); let mainWindow; let _lastImportPath = null; @@ -280,30 +280,66 @@ function stopNetworkMonitor() { debugLog('network-monitor: stopped'); } -function sendBatchWebhook(summary, durationSec) { +function _sleepMs(ms) { return new Promise((r) => setTimeout(r, ms)); } + +async function _postWebhookWithRetry(req, maxAttempts) { + let lastErr = null; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const res = await fetch(req.url, { + method: req.method, + headers: req.headers, + body: req.body, + signal: AbortSignal.timeout(10_000) + }); + if (res.status === 429) { + let waitMs = 2000 * attempt; + try { + const ra = res.headers.get('retry-after'); + if (ra) waitMs = Math.min(60_000, Math.max(waitMs, Math.ceil(parseFloat(ra) * 1000))); + } catch {} + debugLog(`webhook: 429 rate-limited, retrying in ${waitMs}ms (attempt ${attempt}/${maxAttempts})`); + if (attempt < maxAttempts) { await _sleepMs(waitMs); continue; } + return { ok: false, status: 429 }; + } + if (res.status >= 200 && res.status < 300) { + return { ok: true, status: res.status }; + } + if (res.status >= 400 && res.status < 500) { + debugLog(`webhook: client error HTTP ${res.status} — not retrying (check URL/payload)`); + return { ok: false, status: res.status }; + } + debugLog(`webhook: server error HTTP ${res.status} (attempt ${attempt}/${maxAttempts})`); + lastErr = new Error(`HTTP ${res.status}`); + } catch (err) { + lastErr = err; + debugLog(`webhook: send error: ${err && err.message ? err.message : err} (attempt ${attempt}/${maxAttempts})`); + } + if (attempt < maxAttempts) await _sleepMs(2000 * attempt); + } + return { ok: false, error: lastErr && lastErr.message ? lastErr.message : String(lastErr) }; +} + +async function sendBatchWebhook(summary, durationSec, extra) { try { const gs = configStore.load().globalSettings || {}; const url = String(gs.webhookUrl || '').trim(); - if (!url || !/^https?:\/\//i.test(url)) return; + if (!url || !/^https?:\/\//i.test(url)) return { ok: false, skipped: true }; const req = buildWebhookRequest(url, summary, { - durationSec, + durationSec: Math.max(0, Number(durationSec) || 0), appVersion: app.getVersion(), - machineName: require('os').hostname(), + machineName: require('os').hostname() || 'unknown-host', mention: gs.webhookMention || '', + aborted: !!(extra && extra.aborted), 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}`); - }); + const result = await _postWebhookWithRetry(req, 3); + if (result.ok) debugLog(`webhook: sent batch-done notification (HTTP ${result.status})`); + else debugLog(`webhook: gave up after retries (${result.status || result.error || 'unknown'})`); + return result; } catch (err) { debugLog(`webhook: build failed: ${err && err.message ? err.message : err}`); + return { ok: false, error: err && err.message ? err.message : String(err) }; } } @@ -1520,6 +1556,7 @@ ipcMain.handle('start-upload', (_event, payload) => { const files = payload && Array.isArray(payload.files) ? payload.files : []; const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : []; const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; + const isAutoRetry = !!(payload && payload.isAutoRetry); // At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines // per start-upload and added noticeable delay — log counts only. @@ -1700,12 +1737,20 @@ ipcMain.handle('start-upload', (_event, payload) => { 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}`); } safeSend('upload-batch-done', summary); + const fullyAborted = isAllAborted(summary); + if (isAutoRetry) { + debugLog('webhook: skipped — auto-retry round (initial batch already notified)'); + } else if (fullyAborted) { + debugLog('webhook: skipped — batch fully aborted/cancelled'); + } else { + await sendBatchWebhook(summary, _batchDurationSec, { aborted: fullyAborted }); + } + // Shutdown after finish handleShutdownAfterFinish(); if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } @@ -1724,16 +1769,19 @@ ipcMain.handle('start-upload', (_event, payload) => { primeOverrides: Array.from(_sessionAccountOverrides.entries()) }).catch((err) => { debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); - // Forward error to renderer as batch-done with failure - safeSend('upload-batch-done', { - id: 'error', - timestamp: new Date().toISOString(), - total: tasks.length, - succeeded: 0, - failed: tasks.length, - files: [], - error: err ? err.message : 'Unbekannter Fehler' - }); + stopNetworkMonitor(); + const errorSummary = { + id: 'error', + timestamp: new Date().toISOString(), + total: tasks.length, + succeeded: 0, + failed: tasks.length, + files: [], + error: err ? err.message : 'Unbekannter Fehler' + }; + safeSend('upload-batch-done', errorSummary); + if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); + if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } }); }); diff --git a/renderer/app.js b/renderer/app.js index 7f9a901..ec51fde 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1817,6 +1817,7 @@ async function startUpload(opts) { const uploadPayload = { hosters, + isAutoRetry: !!(opts && opts._autoRetry), jobs: jobsToStart.map((job) => ({ id: job.id, file: job.file, @@ -3270,6 +3271,7 @@ async function saveSettings(options = {}) { 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: { + ...((config.globalSettings || {}).folderMonitor || {}), enabled: !!document.getElementById('fmEnabledInput')?.checked, folderPath: (document.getElementById('fmFolderPathInput')?.value || '').trim(), recursive: !!document.getElementById('fmRecursiveInput')?.checked, @@ -3281,6 +3283,7 @@ async function saveSettings(options = {}) { hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster) }, remote: { + ...((config.globalSettings || {}).remote || {}), enabled: !!document.getElementById('remoteEnabledInput')?.checked, port: Math.max(1024, Math.min(65535, parseInt(document.getElementById('remotePortInput')?.value || '9100', 10) || 9100)), token: (document.getElementById('remoteTokenInput')?.value || '').trim(), diff --git a/tests/webhook-notify.test.js b/tests/webhook-notify.test.js index 25be146..3ae4ccb 100644 --- a/tests/webhook-notify.test.js +++ b/tests/webhook-notify.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert'); -const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention } = require('../lib/webhook-notify'); +const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention, isAllAborted, clampDiscordContent, DISCORD_CONTENT_LIMIT } = require('../lib/webhook-notify'); const SAMPLE_SUMMARY = { total: 10, @@ -28,6 +28,46 @@ test('isDiscordWebhook recognizes discord URLs incl. ptb/canary/discordapp', () assert.strictEqual(isDiscordWebhook(null), false); }); +test('isDiscordWebhook REJECTS incomplete discord URLs (no id/token)', () => { + assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/'), false); + assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks'), false); + assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/123'), false); + assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/123/'), false); + assert.ok(isDiscordWebhook('https://discord.com/api/webhooks/123456789/aBc-_token123')); +}); + +test('clampDiscordContent caps to the Discord limit with ellipsis', () => { + const short = 'hello'; + assert.strictEqual(clampDiscordContent(short), short); + const long = 'x'.repeat(5000); + const clamped = clampDiscordContent(long); + assert.ok(clamped.length <= DISCORD_CONTENT_LIMIT); + assert.ok(clamped.endsWith('…')); +}); + +test('buildWebhookRequest: many hosters does not exceed Discord limit', () => { + const files = [{ name: 'a.mkv', results: [] }]; + for (let i = 0; i < 60; i++) files[0].results.push({ hoster: `hoster-with-a-really-long-name-${i}.example.com`, status: 'done' }); + const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 60, succeeded: 60, failed: 0, files }, { durationSec: 60 }); + const body = JSON.parse(req.body); + assert.ok(body.content.length <= DISCORD_CONTENT_LIMIT, `content ${body.content.length} must be <= ${DISCORD_CONTENT_LIMIT}`); + assert.match(body.content, /\+\d+/); +}); + +test('buildWebhookRequest: aborted meta changes the headline', () => { + const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 5, succeeded: 0, failed: 5, files: [] }, { aborted: true }); + const body = JSON.parse(req.body); + assert.match(body.content, /Batch abgebrochen/); +}); + +test('isAllAborted: true only when every result is aborted', () => { + assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'aborted' }, { status: 'aborted' }] }] }), true); + assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'aborted' }, { status: 'done' }] }] }), false); + assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'error' }] }] }), false); + assert.strictEqual(isAllAborted({ files: [] }), false); + assert.strictEqual(isAllAborted(null), false); +}); + test('formatDurationShort formats h/m/s tiers', () => { assert.strictEqual(formatDurationShort(45), '45s'); assert.strictEqual(formatDurationShort(125), '2m 5s');