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:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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">☰</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');
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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\]/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user