fix(webhook): retry+429+status handling, await before shutdown, error-path notify, abort/auto-retry suppress, Discord limits

This commit is contained in:
Administrator 2026-06-10 00:08:02 +02:00
parent 8300d13817
commit 0e5eaa89e6
4 changed files with 151 additions and 32 deletions

View File

@ -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 };

100
main.js
View File

@ -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; }
});
});

View File

@ -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(),

View File

@ -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');