Compare commits

..

No commits in common. "99a2c2fe969526766f87f2c64c4d0f45706dea5e" and "8300d13817ec376825ae5b8db8bccec1bc9376d3" have entirely different histories.

5 changed files with 33 additions and 152 deletions

View File

@ -1,13 +1,5 @@
function isDiscordWebhook(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) + '…';
return /(^https?:\/\/)(ptb\.|canary\.)?(discord(app)?\.com)\/api\/webhooks\//i.test(String(url || ''));
}
function formatDurationShort(sec) {
@ -65,21 +57,16 @@ function buildWebhookRequest(url, summary, meta) {
let body;
if (isDiscordWebhook(url)) {
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)
const hosterLines = Object.entries(perHoster)
.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 — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
`**Multi-Hoster-Upload — Batch fertig**${m.machineName ? ` (${m.machineName})` : ''}`,
`${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}`
];
if (hosterLines) lines.push(hosterLines);
const mention = resolveDiscordMention(m.mention);
const content = clampDiscordContent((mention ? mention.token + ' ' : '') + lines.join('\n'));
const payload = { content };
const payload = { content: (mention ? mention.token + ' ' : '') + lines.join('\n') };
payload.allowed_mentions = mention ? mention.allowed : { parse: [] };
body = JSON.stringify(payload);
} else {
@ -92,7 +79,6 @@ function buildWebhookRequest(url, summary, meta) {
succeeded,
failed,
durationSec: Math.round(Number(m.durationSec) || 0),
aborted: !!m.aborted,
perHoster,
timestamp: m.timestamp || null
});
@ -106,18 +92,4 @@ function buildWebhookRequest(url, summary, meta) {
};
}
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 };
module.exports = { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention };

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, isAllAborted } = require('./lib/webhook-notify');
const { buildWebhookRequest } = require('./lib/webhook-notify');
let mainWindow;
let _lastImportPath = null;
@ -280,66 +280,30 @@ function stopNetworkMonitor() {
debugLog('network-monitor: stopped');
}
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) {
function sendBatchWebhook(summary, durationSec) {
try {
const gs = configStore.load().globalSettings || {};
const url = String(gs.webhookUrl || '').trim();
if (!url || !/^https?:\/\//i.test(url)) return { ok: false, skipped: true };
if (!url || !/^https?:\/\//i.test(url)) return;
const req = buildWebhookRequest(url, summary, {
durationSec: Math.max(0, Number(durationSec) || 0),
durationSec,
appVersion: app.getVersion(),
machineName: require('os').hostname() || 'unknown-host',
machineName: require('os').hostname(),
mention: gs.webhookMention || '',
aborted: !!(extra && extra.aborted),
timestamp: new Date().toISOString()
});
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;
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}`);
return { ok: false, error: err && err.message ? err.message : String(err) };
}
}
@ -1556,7 +1520,6 @@ 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.
@ -1737,20 +1700,12 @@ 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; }
@ -1769,19 +1724,16 @@ ipcMain.handle('start-upload', (_event, payload) => {
primeOverrides: Array.from(_sessionAccountOverrides.entries())
}).catch((err) => {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
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; }
// 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'
});
});
});

View File

@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
"version": "3.3.62",
"version": "3.3.61",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {

View File

@ -1817,7 +1817,6 @@ async function startUpload(opts) {
const uploadPayload = {
hosters,
isAutoRetry: !!(opts && opts._autoRetry),
jobs: jobsToStart.map((job) => ({
id: job.id,
file: job.file,
@ -3271,7 +3270,6 @@ 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,
@ -3283,7 +3281,6 @@ 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, isAllAborted, clampDiscordContent, DISCORD_CONTENT_LIMIT } = require('../lib/webhook-notify');
const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention } = require('../lib/webhook-notify');
const SAMPLE_SUMMARY = {
total: 10,
@ -28,46 +28,6 @@ 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');