feat(unattended): network outage auto-pause/resume, post-batch auto-retry rounds, webhook notifications
This commit is contained in:
parent
7749699830
commit
34aaa36571
@ -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
|
||||
|
||||
@ -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
72
lib/webhook-notify.js
Normal 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
112
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 });
|
||||
|
||||
@ -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'),
|
||||
|
||||
112
renderer/app.js
112
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() {
|
||||
<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(),
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
82
tests/webhook-notify.test.js
Normal file
82
tests/webhook-notify.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user