release: v2.1.16

Add queue search and filters, safe upload diagnostics, session report exports, account check visibility, and explicit interrupted-upload recovery.\n\nSanitize diagnostic response snippets in persisted results and rotation logs, protect CSV exports against formula injection, and add regression coverage for diagnostics and reports.
This commit is contained in:
Sucukdeluxe
2026-08-12 12:04:47 +02:00
parent c843b4e623
commit a299fc204b
16 changed files with 361 additions and 17 deletions
+5 -1
View File
@@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to
Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest).
The latest public release is version 2.1.15. Use the release page for the executables and the full English changelog.
The latest public release is version 2.1.16. Use the release page for the executables and the full English changelog.
## Features
@@ -18,6 +18,8 @@ The latest public release is version 2.1.15. Use the release page for the execut
- Build one job per selected file and destination.
- Upload to several supported hosts from the same queue.
- Filter the workspace by all, active, queued, completed, or failed jobs.
- Search and filter queue entries by file name, host, and status.
- Open per-upload diagnostics with the selected account, retry count, and safe error details.
- Track status, progress, transferred size, speed, and the selected host account.
- Read total, remaining, running, completed, and failed upload activity from the persistent sidebar telemetry.
- Follow current upload speed in the sidebar and the synchronized header graph.
@@ -42,6 +44,8 @@ The latest public release is version 2.1.15. Use the release page for the execut
- Keep upload history entries and filter the archive by all, successful, or failed views.
- Retain all history, a time window, or the latest 100 or 1,000 uploads.
- Export history as CSV or JSON.
- Export a per-session CSV or JSON report with host success rates, duration, bytes, attempts, and errors.
- Clearly mark interrupted uploads after a restart so they can be resumed deliberately.
- Use the complete interface in English or German and switch at runtime.
- Export settings locally or transfer them with an encrypted online backup key.
- Send a batch summary to a Discord webhook or another HTTP webhook.
+16
View File
@@ -81,6 +81,7 @@ const DEFAULTS = {
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null,
uploadRecovery: null,
scramble: {
active: false,
prefix: '',
@@ -584,6 +585,19 @@ class ConfigStore {
}, options);
}
saveUploadRecovery(uploadRecovery, options = {}) {
const snapshot = uploadRecovery === null || uploadRecovery === undefined ? null : this._clone(uploadRecovery);
return this._enqueueWrite(() => {
const current = this.load();
current.globalSettings = {
...(current.globalSettings || {}),
uploadRecovery: snapshot
};
this._guardHosters(current, false);
return this._commit(current);
}, options);
}
saveLastBrowseDirectory(directory) {
const snapshot = String(directory || '').trim();
return this._enqueueWrite(() => {
@@ -607,6 +621,7 @@ class ConfigStore {
current.globalSettings = {
...snapshot,
pendingQueue: currentGlobalSettings.pendingQueue ?? null,
uploadRecovery: currentGlobalSettings.uploadRecovery ?? null,
lastBrowseDirectory: currentGlobalSettings.lastBrowseDirectory || '',
diagnostics: this._clone(currentGlobalSettings.diagnostics || {}),
historyRetention: currentGlobalSettings.historyRetention || 'all',
@@ -640,6 +655,7 @@ class ConfigStore {
const current = this.load();
const globalSettings = this._clone(config.globalSettings);
globalSettings.pendingQueue = current.globalSettings.pendingQueue ?? null;
globalSettings.uploadRecovery = current.globalSettings.uploadRecovery ?? null;
return this._commit({
hosters: this._clone(config.hosters),
hosterSettings: this._clone(config.hosterSettings),
+73
View File
@@ -0,0 +1,73 @@
function normalNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
const { normalizeFailureDetails } = require('./upload-diagnostics');
function safeError(value) {
const text = normalizeFailureDetails({ payloadSnippet: value })?.responseSnippet || '';
return text || 'Unbekannter Fehler';
}
function createHosterSummary() {
return { total: 0, succeeded: 0, failed: 0, skipped: 0, aborted: 0, bytes: 0, durationSec: 0, attempts: 0, errors: {} };
}
function buildSessionReport(summary) {
const hosters = {};
const totals = createHosterSummary();
for (const file of Array.isArray(summary && summary.files) ? summary.files : []) {
const size = normalNumber(file && file.size);
for (const result of Array.isArray(file && file.results) ? file.results : []) {
const hoster = String(result && result.hoster || 'Unbekannt');
const bucket = hosters[hoster] || (hosters[hoster] = createHosterSummary());
const status = String(result && result.status || 'error');
for (const target of [bucket, totals]) {
target.total++;
target.bytes += size;
target.durationSec += normalNumber(result && result.durationSec);
target.attempts += normalNumber(result && result.attempt);
if (status === 'done') target.succeeded++;
else if (status === 'skipped') target.skipped++;
else if (status === 'aborted') target.aborted++;
else target.failed++;
if (status !== 'done' && result && result.error) {
const error = safeError(result.error);
target.errors[error] = (target.errors[error] || 0) + 1;
}
}
}
}
for (const target of [totals, ...Object.values(hosters)]) {
target.successRate = target.total ? target.succeeded / target.total : 0;
}
return {
generatedAt: new Date().toISOString(),
batchId: summary && summary.id ? String(summary.id) : '',
batchTimestamp: summary && summary.timestamp ? String(summary.timestamp) : '',
totals,
hosters
};
}
function csvCell(value) {
let text = value === null || value === undefined ? '' : String(value);
if (/^[=+\-@]/.test(text)) text = `'${text}`;
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function buildSessionReportCsv(report) {
const header = ['Hoster', 'Gesamt', 'Erfolgreich', 'Fehler', 'Übersprungen', 'Abgebrochen', 'Erfolgsrate', 'Bytes', 'Dauer Sekunden', 'Versuche', 'Fehlerdetails'];
const lines = [header.join(',')];
for (const [hoster, row] of Object.entries(report && report.hosters || {})) {
lines.push([
hoster, row.total, row.succeeded, row.failed, row.skipped, row.aborted,
`${Math.round((row.successRate || 0) * 100)}%`, row.bytes, row.durationSec, row.attempts,
Object.entries(row.errors || {}).map(([error, count]) => `${count}× ${error}`).join(' | ')
].map(csvCell).join(','));
}
return `${lines.join('\n')}\n`;
}
module.exports = { buildSessionReport, buildSessionReportCsv };
+33
View File
@@ -0,0 +1,33 @@
function cleanText(value, limit = 320) {
let text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!text) return '';
text = text.replace(/https?:\/\/[^\s"'<>]+/gi, (raw) => {
try {
const url = new URL(raw);
return `${url.host}${url.pathname}`;
} catch {
return '[URL]';
}
});
text = text.replace(/(["']?(?:api[_-]?key|token|password|cookie|authorization|session)["']?\s*[:=]\s*)[^,;\s}"']+/gi, '$1[redacted]');
if (/^(?:bearer|basic)\s+/i.test(text)) {
text = '[redacted authorization]';
}
return text.slice(0, limit);
}
function normalizeFailureDetails(diagnostic) {
if (!diagnostic || typeof diagnostic !== 'object') return null;
const httpStatus = Number.isInteger(Number(diagnostic.http)) && Number(diagnostic.http) >= 100 && Number(diagnostic.http) <= 599
? Number(diagnostic.http)
: null;
const contentType = cleanText(diagnostic.contentType, 120);
const responseSnippet = cleanText(diagnostic.payloadSnippet, 320);
const details = {};
if (httpStatus !== null) details.httpStatus = httpStatus;
if (contentType) details.contentType = contentType;
if (responseSnippet) details.responseSnippet = responseSnippet;
return Object.keys(details).length > 0 ? details : null;
}
module.exports = { normalizeFailureDetails };
+15 -1
View File
@@ -11,6 +11,7 @@ const ClouddropUploader = require('./clouddrop-upload');
const Semaphore = require('./semaphore');
const Throttle = require('./throttle');
const { probeFileHead } = require('./file-probe');
const { normalizeFailureDetails } = require('./upload-diagnostics');
const DEFAULT_SETTINGS = {
retries: 3,
@@ -433,6 +434,7 @@ class UploadManager extends EventEmitter {
const uploadId = crypto.randomBytes(8).toString('hex');
const jobId = task.jobId || uploadId;
const fileName = path.basename(task.file);
const jobStartedAt = Date.now();
let fileSize = 0;
let fileNotFound = false;
const cachedResult = results && results.get(task.file);
@@ -452,6 +454,8 @@ class UploadManager extends EventEmitter {
let finalResultRecorded = false;
let finalStatus = 'error';
let lastError = null;
let lastFailureDetails = null;
let finalAttempt = 0;
const recordFinalResult = (status, payload = {}) => {
if (finalResultRecorded) return;
@@ -462,6 +466,11 @@ class UploadManager extends EventEmitter {
hoster: task.hoster,
status,
error: payload.error || null,
accountId: task.accountId || null,
attempt: payload.attempt || finalAttempt,
maxAttempts,
durationSec: Number.isFinite(payload.elapsed) ? payload.elapsed : Math.round((Date.now() - jobStartedAt) / 1000),
failureDetails: payload.failureDetails || lastFailureDetails,
download_url: payload.result ? payload.result.download_url || null : null,
embed_url: payload.result ? payload.result.embed_url || null : null,
file_code: payload.result ? payload.result.file_code || null : null
@@ -482,6 +491,7 @@ class UploadManager extends EventEmitter {
elapsed: payload.elapsed || 0,
remaining: 0,
error: payload.error || null,
failureDetails: payload.failureDetails || lastFailureDetails,
result: payload.result || null,
attempt: payload.attempt || maxAttempts,
maxAttempts
@@ -582,6 +592,7 @@ class UploadManager extends EventEmitter {
const attemptsAllowed = memoSuspect ? 0 : maxAttempts;
for (let attempt = 1; attempt <= attemptsAllowed; attempt++) {
finalAttempt = attempt;
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
@@ -721,6 +732,7 @@ class UploadManager extends EventEmitter {
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
if (!signal.aborted && !isSpeedRestart) {
const diag = (err && typeof err === 'object' && err.diagnostic) || {};
lastFailureDetails = normalizeFailureDetails(diag);
this._rotLog('upload-failure', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
attempt,
@@ -733,7 +745,7 @@ class UploadManager extends EventEmitter {
detectedKind: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.kind) ? fileProbe.kind : null,
isVideoLike: !!(typeof fileProbe !== 'undefined' && fileProbe && fileProbe.isVideoLike),
headHex: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.headHex) ? fileProbe.headHex.slice(0, 32) : null,
payloadSnippet: diag.payloadSnippet || null
payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null
});
}
if (signal.aborted) {
@@ -955,6 +967,7 @@ class UploadManager extends EventEmitter {
// loop iterates: marks this account failed too, asks main for the next
// fallback, and so on.
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
finalAttempt = attempt;
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
@@ -1011,6 +1024,7 @@ class UploadManager extends EventEmitter {
} catch (err) {
this.activeJobs.delete(uploadId);
lastError = err;
lastFailureDetails = normalizeFailureDetails(err && err.diagnostic);
if (!signal.aborted) {
this._rotLog('upload-failure', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
+29
View File
@@ -34,6 +34,7 @@ const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent');
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable();
@@ -116,6 +117,7 @@ let tray = null;
const configStore = new ConfigStore(app);
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
let uploadManager = null;
let lastSessionSummary = null;
let sourceDeleteJournal = null;
const pendingUploadFinalizations = new Map();
@@ -1964,6 +1966,23 @@ ipcMain.handle('resolve-folder-files', async (_event, folderPath) => {
return walkFolderAsync(folderPath);
});
ipcMain.handle('export-session-report', async (_event, format) => {
if (!lastSessionSummary) return { ok: false, error: 'Für diese Sitzung liegt noch kein abgeschlossener Upload vor' };
const normalizedFormat = String(format || 'csv').toLowerCase() === 'json' ? 'json' : 'csv';
const report = buildSessionReport(lastSessionSummary);
const datePrefix = new Date().toISOString().slice(0, 10);
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: shellText('Sitzungsbericht exportieren', 'Export session report'),
defaultPath: `upload-session-report-${datePrefix}.${normalizedFormat}`,
filters: normalizedFormat === 'json'
? [{ name: shellText('JSON-Datei', 'JSON file'), extensions: ['json'] }]
: [{ name: shellText('CSV-Datei', 'CSV file'), extensions: ['csv'] }]
});
if (canceled || !filePath) return { ok: false, canceled: true };
fs.writeFileSync(filePath, normalizedFormat === 'json' ? JSON.stringify(report, null, 2) : buildSessionReportCsv(report), 'utf-8');
return { ok: true, path: filePath, format: normalizedFormat, totalRows: report.totals.total };
});
ipcMain.handle('get-file-sizes', async (_event, paths) => {
if (!Array.isArray(paths)) return {};
const fsp = fs.promises;
@@ -2031,6 +2050,13 @@ ipcMain.handle('start-upload', async (_event, payload) => {
return { started: true, taskCount: 0, skippedJobs };
}
const recovery = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
startedAt: new Date().toISOString(),
jobIds: tasks.map(task => task.jobId).filter(Boolean)
};
try { await configStore.saveUploadRecovery(recovery); } catch (error) { debugLog(`upload recovery state could not be saved: ${error.message}`); }
// Pre-resolve a fallback for every hoster that has one. Lets the upload
// manager break out of the retry loop after a single generic failure and
// try the alternate account immediately, instead of hammering a probably-
@@ -2208,6 +2234,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
// orphans (cancel/addJobs see null, the new batch keeps running invisibly).
uploadManager.on('batch-done', async (summary) => {
summary = stats.mergeSkippedIntoSummary(summary, skippedJobs);
lastSessionSummary = summary;
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');
@@ -2228,6 +2255,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
_progressByJob.clear();
if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch);
const queuePersisted = await requestUploadFinalization(summary);
try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared: ${error.message}`); }
if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
await sourceCleanup.finishBatch({ historyPersisted, queuePersisted });
_producerTracker.finish();
@@ -2278,6 +2306,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
error: err ? err.message : 'Unbekannter Fehler'
};
safeSend('upload-batch-done', errorSummary);
configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`));
_producerTracker.finish();
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-hoster-uploader",
"version": "2.1.15",
"version": "2.1.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-hoster-uploader",
"version": "2.1.15",
"version": "2.1.16",
"dependencies": {
"chokidar": "^3.6.0",
"undici": "^7.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
"version": "2.1.15",
"version": "2.1.16",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
+1
View File
@@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld('api', {
clearHistory: () => ipcRenderer.invoke('clear-history'),
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
exportHistory: (format) => ipcRenderer.invoke('export-history', format),
exportSessionReport: (format) => ipcRenderer.invoke('export-session-report', format),
saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters),
// Hoster settings
+81 -10
View File
@@ -343,6 +343,9 @@ const _doneRemovalCoalescer = window.CoalescedSet
: null;
const queueSortState = { key: 'filename', direction: 'asc' };
let uploadSidebarFilter = 'all';
let queueSearchQuery = '';
let queueHosterFilter = '';
let queueStatusFilter = '';
let _queueFilterCache = { filter: '', source: null, result: [] };
// History state
@@ -415,6 +418,8 @@ async function init() {
renderRecentUploadsPanel();
updateUploadView();
updateStatusBar();
const interruptedCount = queueJobs.filter(job => job.interrupted).length;
if (interruptedCount > 0) showCopyToast(interruptedCount === 1 ? '1 unterbrochener Upload kann fortgesetzt werden.' : `${interruptedCount} unterbrochene Uploads können fortgesetzt werden.`, 7000);
// Version display
try {
@@ -1178,6 +1183,7 @@ function restoreQueueStateFromConfig() {
.map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 }))
: [];
const interruptedJobIds = new Set(Array.isArray(config?.globalSettings?.uploadRecovery?.jobIds) ? config.globalSettings.uploadRecovery.jobIds : []);
const rawJobs = Array.isArray(pending.queueJobs)
? pending.queueJobs
.filter(job => job && job.fileName && job.hoster)
@@ -1194,6 +1200,8 @@ function restoreQueueStateFromConfig() {
elapsed: 0,
remaining: 0,
error: job.error || null,
failureDetails: job.failureDetails || null,
interrupted: interruptedJobIds.size > 0 && !['done', 'error', 'skipped'].includes(job.status),
result: job.result || null,
sourceCleanupToken: job.sourceCleanupToken || null,
sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [],
@@ -1270,6 +1278,7 @@ function buildPersistedQueueState() {
status: isTerminal ? job.status : 'preview',
bytesTotal: job.bytesTotal || 0,
error: isTerminal ? (job.error || null) : null,
failureDetails: isTerminal ? (job.failureDetails || null) : null,
result: isTerminal ? (job.result || null) : null,
sourceCleanupToken: job.sourceCleanupToken || null,
sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [],
@@ -1894,10 +1903,20 @@ function _matchesUploadSidebarFilter(job, filter = uploadSidebarFilter) {
return true;
}
function _matchesQueueDetailFilter(job) {
if (queueSearchQuery && !String(job.fileName || '').toLocaleLowerCase(getUiLocale()).includes(queueSearchQuery)) return false;
if (queueHosterFilter && job.hoster !== queueHosterFilter) return false;
if (queueStatusFilter === 'active') return ['uploading', 'getting-server', 'retrying'].includes(job.status);
if (queueStatusFilter === 'waiting') return ['preview', 'queued'].includes(job.status);
if (queueStatusFilter === 'done') return job.status === 'done';
return !queueStatusFilter || job.status === queueStatusFilter;
}
function _getVisibleQueueJobs() {
if (uploadSidebarFilter === 'all') return queueJobs;
const filtered = queueJobs.filter(job => _matchesUploadSidebarFilter(job));
if (_queueFilterCache.filter === uploadSidebarFilter && _queueFilterCache.source === queueJobs && _queueFilterCache.result.length === filtered.length) {
const filterKey = `${uploadSidebarFilter}\u0001${queueSearchQuery}\u0001${queueHosterFilter}\u0001${queueStatusFilter}`;
if (filterKey === 'all\u0001\u0001\u0001') return queueJobs;
const filtered = queueJobs.filter(job => _matchesUploadSidebarFilter(job) && _matchesQueueDetailFilter(job));
if (_queueFilterCache.filter === filterKey && _queueFilterCache.source === queueJobs && _queueFilterCache.result.length === filtered.length) {
let unchanged = true;
for (let index = 0; index < filtered.length; index++) {
if (_queueFilterCache.result[index] !== filtered[index]) {
@@ -1907,7 +1926,7 @@ function _getVisibleQueueJobs() {
}
if (unchanged) return _queueFilterCache.result;
}
_queueFilterCache = { filter: uploadSidebarFilter, source: queueJobs, result: filtered };
_queueFilterCache = { filter: filterKey, source: queueJobs, result: filtered };
return filtered;
}
@@ -1934,13 +1953,14 @@ function renderQueueTable() {
const tbody = document.getElementById('queueBody');
if (!tbody) return;
syncQueueHosterFilterOptions();
const visibleJobs = _getVisibleQueueJobs();
const selectionChanged = _normalizeQueueSelectionToVisible(visibleJobs);
_sortedJobsCache = sortQueueJobs(visibleJobs);
if (selectionChanged) updateQueueActionButtons();
const totalRows = _sortedJobsCache.length;
const queueContainer = document.getElementById('queueContainer');
const filteredEmpty = totalRows === 0 && queueJobs.length > 0 && uploadSidebarFilter !== 'all';
const filteredEmpty = totalRows === 0 && queueJobs.length > 0 && (uploadSidebarFilter !== 'all' || queueSearchQuery || queueHosterFilter || queueStatusFilter);
queueContainer?.classList.toggle('filter-empty', filteredEmpty);
if (queueContainer) queueContainer.dataset.emptyLabel = filteredEmpty ? localizeUiText('Keine Uploads entsprechen diesem Filter.') : '';
@@ -2150,7 +2170,7 @@ function getStatusText(job) {
const accSuffix = acc ? ` · ${acc}` : '';
let text;
switch (job.status) {
case 'preview': text = 'Bereit'; break;
case 'preview': text = job.interrupted ? 'Unterbrochen · bereit zum Fortsetzen' : 'Bereit'; break;
case 'queued': text = 'Wartet'; break;
case 'getting-server': text = `Server…${accSuffix}`; break;
case 'uploading': text = `Upload${accSuffix}`; break;
@@ -3089,6 +3109,7 @@ function _handleProgressImpl(data) {
// Update job state
job.status = data.status;
if (data.status !== 'preview') job.interrupted = false;
job.bytesUploaded = data.bytesUploaded || 0;
job.bytesTotal = data.bytesTotal || job.bytesTotal;
// Track session total bytes (survives removeFromQueueOnDone)
@@ -3100,6 +3121,7 @@ function _handleProgressImpl(data) {
job.elapsed = data.elapsed || 0;
job.remaining = data.remaining || 0;
job.error = data.error || null;
job.failureDetails = data.failureDetails || job.failureDetails || null;
job.result = data.result || job.result;
job.attempt = data.attempt || 0;
job.maxAttempts = data.maxAttempts || 0;
@@ -3107,6 +3129,11 @@ function _handleProgressImpl(data) {
// Track which account the backend is currently using so the status cell
// can display "Primär" vs "Fallback #N" during rotation.
if (data.accountId) job.accountId = data.accountId;
if (data.accountId && data.hoster) {
const status = accountStatuses[data.accountId] || { status: 'unchecked', message: '' };
if (data.status === 'done') accountStatuses[data.accountId] = { ...status, status: 'ok', message: 'Letzter Upload erfolgreich', lastSuccessAt: new Date().toISOString() };
else if (data.status === 'error') accountStatuses[data.accountId] = { ...status, status: 'error', message: data.error || 'Letzter Upload fehlgeschlagen', lastFailureAt: new Date().toISOString() };
}
if (data.uploadId) {
job.uploadId = data.uploadId;
_jobIndexByUploadId.set(data.uploadId, job);
@@ -3430,7 +3457,17 @@ async function showJobLogModal() {
.join(' ');
return `[${t}] [${e.event}] ${rest}`;
};
bodyEl.textContent = entries.map(fmt).join('\n');
const details = job.failureDetails && typeof job.failureDetails === 'object'
? Object.entries(job.failureDetails).map(([key, value]) => `${key}: ${value}`).join('\n')
: '';
const summary = [
`Hoster: ${job.hoster || ''}`,
`Account: ${getAccountLabel(job) || job.accountId || ''}`,
`Versuch: ${job.attempt || ''} / ${job.maxAttempts || ''}`,
job.error ? `Fehler: ${job.error}` : '',
details ? `Diagnose:\n${details}` : ''
].filter(Boolean).join('\n');
bodyEl.textContent = `${summary}\n\n${entries.map(fmt).join('\n')}`;
}
function hideJobLogModal() {
@@ -3652,12 +3689,14 @@ function applySummaryResults(summary) {
job.error = null;
job.progress = 1;
job.bytesUploaded = job.bytesTotal || file.size || 0;
job.failureDetails = null;
} else if (result.status === 'aborted') {
job.status = 'aborted';
job.error = result.error || 'Abgebrochen';
} else if (result.status === 'error') {
job.status = 'error';
job.error = result.error || 'Fehlgeschlagen';
job.failureDetails = result.failureDetails || job.failureDetails || null;
} else if (result.status === 'skipped') {
job.status = 'skipped';
job.error = result.error || 'Übersprungen';
@@ -3732,6 +3771,26 @@ function setUploadSidebarFilter(value) {
renderQueueTable();
}
function applyQueueDetailFilters() {
queueSearchQuery = String(document.getElementById('queueSearchInput')?.value || '').trim().toLocaleLowerCase(getUiLocale());
queueHosterFilter = String(document.getElementById('queueHosterFilter')?.value || '');
queueStatusFilter = String(document.getElementById('queueStatusFilter')?.value || '');
_queueFilterCache = { filter: '', source: null, result: [] };
_normalizeQueueSelectionToVisible();
_lastVisibleRange = { start: -1, end: -1 };
renderQueueTable();
}
function syncQueueHosterFilterOptions() {
const select = document.getElementById('queueHosterFilter');
if (!select) return;
const names = [...new Set(queueJobs.map(job => job.hoster).filter(Boolean))].sort(_collatorSimple.compare);
const current = select.value;
select.innerHTML = `<option value="">Alle Hoster</option>${names.map(name => `<option value="${escapeAttr(name)}">${escapeHtml(getHosterLabel(name))}</option>`).join('')}`;
select.value = names.includes(current) ? current : '';
queueHosterFilter = select.value;
}
function updateUploadSidebarSummary(stats = _computeQueueStats()) {
let waiting = 0;
let active = 0;
@@ -4090,7 +4149,8 @@ async function executeHealthCheck(hosters, _mode) {
if (key) {
accountStatuses[key] = {
status: row.status || 'unchecked',
message: row.message || ''
message: row.message || '',
checkedAt: result.checkedAt || new Date().toISOString()
};
}
});
@@ -4121,7 +4181,7 @@ async function runHealthCheck(mode = 'manual', requestedHosters = null) {
// Mark all accounts as checking
for (const h of hosters) {
const key = typeof h === 'string' ? h : (h.accountId || h.hoster);
accountStatuses[key] = { status: 'checking', message: '' };
accountStatuses[key] = { status: 'checking', message: '', checkedAt: null };
}
renderAccounts();
try {
@@ -5180,6 +5240,7 @@ function _buildAccountCardHtml(name, account, idx) {
// disambiguator for accounts that otherwise look identical (e.g. two byse
// API-key accounts where you can't tell what's what from the masked key).
const subtitleText = (userLabel ? `Label: ${userLabel}` : '') + credLabel;
const checkedText = st.checkedAt ? ` • geprüft ${new Date(st.checkedAt).toLocaleTimeString(getUiLocale(), { hour: '2-digit', minute: '2-digit' })}` : '';
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
@@ -5199,7 +5260,7 @@ function _buildAccountCardHtml(name, account, idx) {
<div class="account-card-drag-handle" role="button" tabindex="0" aria-label="Priorität ändern. Alt und Pfeil nach oben oder unten verwenden" title="Ziehen oder Alt und Pfeiltasten zum Sortieren">&#9776;</div>
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span> ${sessionPausedBadge}</div>
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}${checkedText}</div>
${otpAction}
</div>
<span class="account-status status-${statusClass}">
@@ -6946,6 +7007,16 @@ function setupListeners() {
closeHistoryClearModal();
}, true);
document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory);
document.getElementById('exportSessionReportBtn').addEventListener('click', async () => {
const format = await showAppChoice({ title: 'Sitzungsbericht exportieren', message: 'Welches Format möchtest du speichern?', confirmText: 'CSV', alternateText: 'JSON' });
if (format === false) return;
const result = await window.api.exportSessionReport(format === 'alternate' ? 'json' : 'csv');
if (result?.ok) showCopyToast(`Sitzungsbericht mit ${result.totalRows} Uploads exportiert`);
else if (!result?.canceled) await showAppAlert(result?.error || 'Sitzungsbericht konnte nicht exportiert werden.');
});
document.getElementById('queueSearchInput').addEventListener('input', applyQueueDetailFilters);
document.getElementById('queueHosterFilter').addEventListener('change', applyQueueDetailFilters);
document.getElementById('queueStatusFilter').addEventListener('change', applyQueueDetailFilters);
const historyRetentionPicker = document.getElementById('historyRetentionPicker');
const historyRetentionTrigger = document.getElementById('historyRetentionTrigger');
+12
View File
@@ -100,6 +100,18 @@
['Jetzt updaten', 'Install now'],
['Alle Dateien', 'All files'],
['Aktiv', 'Active'],
['Alle Hoster', 'All hosts'],
['Alle Status', 'All statuses'],
['Dateiname suchen', 'Search file name'],
['Nach Hoster filtern', 'Filter by host'],
['Nach Status filtern', 'Filter by status'],
['Nur Fehler', 'Errors only'],
['Sitzungsbericht', 'Session report'],
['Sitzungsbericht exportieren', 'Export session report'],
['Welches Format möchtest du speichern?', 'Which format would you like to save?'],
['Für diese Sitzung liegt noch kein abgeschlossener Upload vor', 'There is no completed upload for this session yet'],
['Wartend', 'Waiting'],
['Warteschlange filtern', 'Filter queue'],
['Warteschlange', 'Queue'],
['Fertig', 'Completed'],
['Fehler', 'Failed'],
+9
View File
@@ -276,6 +276,14 @@
</button>
</div>
<div class="queue-filter-bar" aria-label="Warteschlange filtern">
<input class="key-input" id="queueSearchInput" type="search" placeholder="Dateiname suchen" aria-label="Dateiname suchen" style="max-width:260px">
<select class="hs-input" id="queueHosterFilter" aria-label="Nach Hoster filtern" style="width:auto;min-width:150px"><option value="">Alle Hoster</option></select>
<select class="hs-input" id="queueStatusFilter" aria-label="Nach Status filtern" style="width:auto;min-width:150px">
<option value="">Alle Status</option><option value="error">Nur Fehler</option><option value="active">Aktiv</option><option value="waiting">Wartend</option><option value="done">Erfolgreich</option>
</select>
</div>
<div class="queue-container" id="queueContainer">
<table class="queue-table" id="queueTable">
<thead>
@@ -596,6 +604,7 @@
<option value="1000">Letzte 1000 Uploads</option>
</select>
<button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button>
<button class="btn btn-secondary" id="exportSessionReportBtn">Sitzungsbericht</button>
<button class="btn btn-danger" id="clearHistoryBtn" disabled>Verlauf löschen</button>
</div>
</div>
+4
View File
@@ -53,12 +53,14 @@ const sourceFiles = [
'lib/startup-renderer.js',
'lib/stats.js',
'lib/support-bundle.js',
'lib/session-report.js',
'lib/throttle-timer.js',
'lib/throttle.js',
'lib/throttled-cache.js',
'lib/updater.js',
'lib/upload-log.js',
'lib/upload-confirmation.js',
'lib/upload-diagnostics.js',
'lib/upload-manager.js',
'lib/vidmoly-upload.js',
'lib/voe-upload.js',
@@ -144,7 +146,9 @@ const sourceFiles = [
'tests/updater-version.test.js',
'tests/upload-log.test.js',
'tests/upload-confirmation.test.js',
'tests/upload-diagnostics.test.js',
'tests/upload-manager.test.js',
'tests/session-report.test.js',
'tests/validate-credentials.test.js',
'tests/webhook-notify.test.js'
];
+31
View File
@@ -0,0 +1,31 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildSessionReport, buildSessionReportCsv } = require('../lib/session-report');
test('erstellt einen Hosterbericht mit Erfolgsquote, Dauer, Bytes und Fehlern', () => {
const report = buildSessionReport({
id: 'batch-1',
timestamp: '2026-08-12T12:00:00.000Z',
files: [{
name: 'video.mp4',
size: 1024,
results: [
{ hoster: 'byse.sx', status: 'done', durationSec: 4, attempt: 1 },
{ hoster: 'doodstream.com', status: 'error', durationSec: 3, attempt: 2, error: 'HTTP 503' }
]
}]
});
assert.equal(report.totals.total, 2);
assert.equal(report.totals.succeeded, 1);
assert.equal(report.hosters['byse.sx'].bytes, 1024);
assert.equal(report.hosters['doodstream.com'].attempts, 2);
assert.deepEqual(report.hosters['doodstream.com'].errors, { 'HTTP 503': 1 });
assert.match(buildSessionReportCsv(report), /doodstream\.com/);
});
test('entschärft Formeln und Geheimnisse im CSV-Bericht', () => {
const report = buildSessionReport({ files: [{ size: 1, results: [{ hoster: 'byse.sx', status: 'error', error: '=CMD() token=secret' }] }] });
assert.match(buildSessionReportCsv(report), /1× =CMD\(\) token=\[redacted\]/);
});
+27
View File
@@ -0,0 +1,27 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizeFailureDetails } = require('../lib/upload-diagnostics');
test('normalisiert Fehlerdiagnosen ohne URLs oder lange Antworten zu speichern', () => {
const result = normalizeFailureDetails({
http: 503,
contentType: 'text/html; charset=utf-8',
payloadSnippet: 'https://private.example/upload?key=secret '.repeat(80),
uploadUrl: 'https://private.example/upload?key=secret'
});
assert.equal(result.httpStatus, 503);
assert.equal(result.contentType, 'text/html; charset=utf-8');
assert.match(result.responseSnippet, /^private\.example\/upload/);
assert.ok(result.responseSnippet.length <= 320);
});
test('ignoriert leere und ungültige Diagnosefelder', () => {
assert.equal(normalizeFailureDetails({ http: 'x', contentType: '', payloadSnippet: '' }), null);
});
test('bereinigt Zugangsdaten und mehrere URLs aus Hosterantworten', () => {
const result = normalizeFailureDetails({ payloadSnippet: 'token=secret https://one.example/a?key=one password: hunter2 https://two.example/b' });
assert.equal(result.responseSnippet, 'token=[redacted] one.example/a password: [redacted] two.example/b');
});
+20
View File
@@ -199,6 +199,26 @@ describe('UploadManager', () => {
assert.equal(summary.succeeded, 0);
});
it('stores only sanitized hoster diagnostics in rotation logs', async () => {
const error = new Error('Upload rejected');
error.fileRejected = true;
error.diagnostic = {
http: 403,
contentType: 'application/json',
payloadSnippet: 'token=very-secret https://cdn.example.test/upload?apiKey=also-secret'
};
mockUploadFile.mock.mockImplementation(async () => { throw error; });
const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
const logs = [];
mgr.on('rot-log', (entry) => logs.push(entry));
await mgr.startBatch([{ file: '/test/diagnostic.mp4', hoster: 'doodstream.com', apiKey: 'key1' }]);
const failure = logs.find(entry => entry.event === 'upload-failure');
assert.equal(failure.payloadSnippet, 'token=[redacted] cdn.example.test/upload');
});
it('cancel aborts running uploads', async () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
// Simulate a slow upload