feat(unattended): network outage auto-pause/resume, post-batch auto-retry rounds, webhook notifications

This commit is contained in:
Administrator 2026-06-09 20:39:59 +02:00
parent 7749699830
commit 34aaa36571
8 changed files with 478 additions and 8 deletions

View File

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

View File

@ -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<Set<file_code>> (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.

72
lib/webhook-notify.js Normal file
View File

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

112
main.js
View File

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

View File

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

View File

@ -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,7 +2630,9 @@ function updateStatusBar() {
? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024))
: 0;
const stateText = lastUploadStats.state === 'uploading'
const stateText = (_networkOffline && (uploading || lastUploadStats.state === 'uploading'))
? 'Netzwerk-Ausfall — pausiert'
: lastUploadStats.state === 'uploading'
? 'Upload läuft...'
: lastUploadStats.state === 'stopping'
? 'Stoppt nach aktiven Uploads...'
@ -2771,6 +2824,23 @@ function renderSettings() {
<span>DEBUG-Einträge in debug.log schreiben (Performance , Diagnostik )</span>
</label>
</div>
<div class="settings-section-label">Unbeaufsichtigter Betrieb</div>
<div class="settings-row">
<label>Auto-Retry Runden</label>
<input type="number" class="hs-input settings-autosave" id="autoRetryRoundsInput" min="0" max="5" value="${Number(globalSettings.autoRetryRounds) || 0}">
<span class="hint">0 = aus. Nach Batch-Ende werden transiente Fehler (Netzwerk, Hoster-Flake) automatisch bis zu N Runden neu versucht.</span>
</div>
<div class="settings-row">
<label>Retry-Wartezeit (min)</label>
<input type="number" class="hs-input settings-autosave" id="autoRetryDelayMinInput" min="1" max="120" value="${Number(globalSettings.autoRetryDelayMin) || 5}">
<span class="hint">Basis-Wartezeit; Runde N wartet N × diesen Wert (lineares Backoff).</span>
</div>
<div class="settings-row">
<label>Webhook-URL</label>
<input type="text" class="key-input settings-autosave" id="webhookUrlInput" value="${escapeAttr(globalSettings.webhookUrl || '')}" placeholder="https://discord.com/api/webhooks/… oder eigene URL">
<button class="btn btn-xs btn-secondary" id="testWebhookBtn">Test</button>
<span class="hint" id="webhookHint">Bei Batch-Ende wird eine Zusammenfassung gepostet (Discord wird automatisch erkannt, sonst generisches JSON).</span>
</div>
<div class="settings-section-label">Diagnose</div>
<div class="settings-row" id="logPathsBlock">
<label>Log-Dateien</label>
@ -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(),

View File

@ -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 = [];

View File

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