Compare commits

...

12 Commits

Author SHA1 Message Date
Administrator
169817f707 release: v3.3.50 2026-06-08 14:20:16 +02:00
Administrator
1418c2bc17 feat(backup): plain JSON export/import + clearer error when decrypt fails 2026-06-08 14:19:47 +02:00
Administrator
8d33141294 release: v3.3.49 2026-06-08 03:04:25 +02:00
Administrator
35341b522a fix(accounts): allow health check during active uploads + toast when already running 2026-06-08 03:04:00 +02:00
Administrator
f9aa7f4168 release: v3.3.48 2026-06-08 01:30:19 +02:00
Administrator
d9199f8aaf fix(perf): chunked startBatch + async rotLog — kill remaining 30s freeze on 5k+ jobs 2026-06-08 01:29:31 +02:00
Administrator
ba4642e09a release: v3.3.47 2026-06-07 21:11:53 +02:00
Administrator
d59c5c1df8 perf: per-batch baseline cache, async folder walk, history-table fast path, progress IPC batching 2026-06-07 21:11:04 +02:00
Administrator
4bb18f7abc release: v3.3.46 2026-06-07 20:59:34 +02:00
Administrator
125e5f55ea fix(perf): kill per-progress renderer-to-main IPC + drop redundant queued emit + cache fileSize 2026-06-07 20:59:07 +02:00
Administrator
79fe3037eb release: v3.3.45 2026-06-07 20:41:25 +02:00
Administrator
d280765feb fix(perf): freeze on Start with 2000+ jobs — gate probe + rot-log behind semaphore 2026-06-07 20:40:55 +02:00
7 changed files with 239 additions and 130 deletions

View File

@ -499,25 +499,28 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
return null; return null;
} }
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) { async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle, opts) {
const config = HOSTER_CONFIGS[hosterName]; const config = HOSTER_CONFIGS[hosterName];
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`); if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
// For byse: snapshot the current file-code list so the post-upload poller
// can identify new arrivals even when the initial POST response has an
// empty filecode.
let byseBaseline = null; let byseBaseline = null;
if (hosterName === 'byse.sx') { if (hosterName === 'byse.sx') {
if (opts && opts.byseBaseline instanceof Set) {
byseBaseline = opts.byseBaseline;
} else {
const baseline = await _fetchByseFileList(apiKey, signal); const baseline = await _fetchByseFileList(apiKey, signal);
byseBaseline = new Set(baseline.map(f => f.file_code)); byseBaseline = new Set(baseline.map(f => f.file_code));
} }
// Doodstream: same snapshot so a codeless upload response can be recovered by }
// matching a newly-appeared file in the account by name (see below).
let doodBaseline = null; let doodBaseline = null;
if (hosterName === 'doodstream.com') { if (hosterName === 'doodstream.com') {
if (opts && opts.doodBaseline instanceof Set) {
doodBaseline = opts.doodBaseline;
} else {
const baseline = await _fetchDoodstreamFileList(apiKey, signal); const baseline = await _fetchDoodstreamFileList(apiKey, signal);
doodBaseline = new Set(baseline.map(f => f.file_code)); doodBaseline = new Set(baseline.map(f => f.file_code));
} }
}
// Step 1: Get upload server // Step 1: Get upload server
const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal); const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal);
@ -647,8 +650,23 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
throw new Error(msg); throw new Error(msg);
} }
async function prefetchBaseline(hosterName, apiKey, signal) {
try {
if (hosterName === 'byse.sx') {
const baseline = await _fetchByseFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
if (hosterName === 'doodstream.com') {
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
} catch { /* leave caller to fall back to per-job fetch */ }
return null;
}
module.exports = { module.exports = {
uploadFile, uploadFile,
prefetchBaseline,
HOSTER_CONFIGS, HOSTER_CONFIGS,
__test: { __test: {
extractUploadServerUrl, extractUploadServerUrl,

View File

@ -2,14 +2,14 @@ const { EventEmitter } = require('events');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const { uploadFile } = require('./hosters'); const { uploadFile, prefetchBaseline } = require('./hosters');
const VidmolyUploader = require('./vidmoly-upload'); const VidmolyUploader = require('./vidmoly-upload');
const VoeUploader = require('./voe-upload'); const VoeUploader = require('./voe-upload');
const DoodstreamUploader = require('./doodstream-upload'); const DoodstreamUploader = require('./doodstream-upload');
const ClouddropUploader = require('./clouddrop-upload'); const ClouddropUploader = require('./clouddrop-upload');
const Semaphore = require('./semaphore'); const Semaphore = require('./semaphore');
const Throttle = require('./throttle'); const Throttle = require('./throttle');
const { probeFileHead, summarizeFileStat } = require('./file-probe'); const { probeFileHead } = require('./file-probe');
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
retries: 3, retries: 3,
@ -42,6 +42,7 @@ class UploadManager extends EventEmitter {
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
this._accountOverrides = new Map(); // hoster -> fallback account object this._accountOverrides = new Map(); // hoster -> fallback account object
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none) 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)
} }
switchAccount(hoster, fallbackAccount) { switchAccount(hoster, fallbackAccount) {
@ -278,6 +279,7 @@ class UploadManager extends EventEmitter {
this.jobAbortControllers.clear(); this.jobAbortControllers.clear();
this.cancelledJobIds.clear(); this.cancelledJobIds.clear();
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
this.semaphores = {}; this.semaphores = {};
this.globalSemaphore = null; this.globalSemaphore = null;
this.globalThrottle = null; this.globalThrottle = null;
@ -308,18 +310,30 @@ class UploadManager extends EventEmitter {
this._batchResults = results; this._batchResults = results;
this._additionalPromises = []; // Track jobs added mid-batch via addJobs() this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
for (const task of tasks) { const DEDUP_CHUNK = 200;
const fileName = path.basename(task.file); for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
const end = Math.min(i + DEDUP_CHUNK, tasks.length);
for (let j = i; j < end; j++) {
const task = tasks[j];
if (!results.has(task.file)) { if (!results.has(task.file)) {
const fileName = path.basename(task.file);
let size = 0; let size = 0;
try { size = fs.statSync(task.file).size; } catch {} try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] }); results.set(task.file, { name: fileName, size, results: [] });
} }
} }
if (end < tasks.length) await new Promise(setImmediate);
}
this._startStatsTimer(); this._startStatsTimer();
const promises = tasks.map((task) => this._runJob(task, results, signal)); const SPAWN_CHUNK = 100;
const promises = [];
for (let i = 0; i < tasks.length; i += SPAWN_CHUNK) {
const end = Math.min(i + SPAWN_CHUNK, tasks.length);
for (let j = i; j < end; j++) promises.push(this._runJob(tasks[j], results, signal));
if (end < tasks.length) await new Promise(setImmediate);
}
await Promise.allSettled(promises); await Promise.allSettled(promises);
// Wait for any jobs added mid-batch via addJobs() // Wait for any jobs added mid-batch via addJobs()
while (this._additionalPromises.length > 0) { while (this._additionalPromises.length > 0) {
@ -355,7 +369,12 @@ class UploadManager extends EventEmitter {
const fileName = path.basename(task.file); const fileName = path.basename(task.file);
let fileSize = 0; let fileSize = 0;
let fileNotFound = false; let fileNotFound = false;
const cachedResult = results && results.get(task.file);
if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) {
fileSize = cachedResult.size;
} else {
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; } try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
}
const maxAttempts = Math.max(1, (settings.retries || 0) + 1); const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
const jobAbortController = new AbortController(); const jobAbortController = new AbortController();
@ -420,36 +439,30 @@ class UploadManager extends EventEmitter {
return; return;
} }
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, // The initial 'queued' emit per job is suppressed: with N=2000+ tasks
jobId, // it produces 2000+ main→renderer IPCs back-to-back at startBatch and
status: 'queued', // freezes the renderer event loop for tens of seconds. The renderer
progress: 0, // already holds each job in 'queued'/'preview' state from its own
bytesUploaded: 0, // queueJobs array; the first event it actually needs from main is the
bytesTotal: fileSize, // 'getting-server' / 'uploading' transition for the jobs that the
speedKbs: 0, // semaphore lets through.
elapsed: 0, await hosterSemaphore.acquire(signal);
remaining: 0, hosterSlotAcquired = true;
error: null,
result: null,
attempt: 0,
maxAttempts
});
const fileStat = summarizeFileStat(task.file); let fileProbe = null;
const fileProbe = await probeFileHead(task.file, 64).catch((err) => ({ ok: false, error: err.message, kind: 'unreadable' })); try {
fileProbe = await probeFileHead(task.file, 64);
} catch (err) {
fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' };
}
this._rotLog('upload-start', { this._rotLog('upload-start', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName, jobId, hoster: task.hoster, accountId: task.accountId, fileName,
fileSize: fileStat.size, fileMtime: fileStat.mtime, fileSize,
detectedKind: fileProbe && fileProbe.kind ? fileProbe.kind : 'unknown', detectedKind: fileProbe && fileProbe.kind ? fileProbe.kind : 'unknown',
isVideoLike: !!(fileProbe && fileProbe.isVideoLike), isVideoLike: !!(fileProbe && fileProbe.isVideoLike),
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
}); });
// Acquire hoster semaphore first so jobs waiting for a hoster slot
// don't waste global slots (prevents underutilization)
await hosterSemaphore.acquire(signal);
hosterSlotAcquired = true;
if (globalSemaphore) { if (globalSemaphore) {
await globalSemaphore.acquire(signal); await globalSemaphore.acquire(signal);
globalSlotAcquired = true; globalSlotAcquired = true;
@ -920,7 +933,9 @@ class UploadManager extends EventEmitter {
const apiKey = await this._resolveDoodstreamApiKey(task); const apiKey = await this._resolveDoodstreamApiKey(task);
if (apiKey) { if (apiKey) {
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) }); this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle); return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, {
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
});
} }
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) }); this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
const dood = new DoodstreamUploader(); const dood = new DoodstreamUploader();
@ -930,10 +945,23 @@ class UploadManager extends EventEmitter {
const clouddrop = new ClouddropUploader(task.apiKey); const clouddrop = new ClouddropUploader(task.apiKey);
return clouddrop.upload(task.file, progressCb, signal, throttle); return clouddrop.upload(task.file, progressCb, signal, throttle);
} else { } else {
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle); const baselineOpts = {};
if (task.hoster === 'byse.sx') baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
} }
} }
_getBaseline(hosterName, apiKey, signal) {
if (!apiKey) return Promise.resolve(null);
const key = `${hosterName}:${apiKey}`;
let pending = this._baselineCache.get(key);
if (pending) return pending;
pending = prefetchBaseline(hosterName, apiKey, signal);
this._baselineCache.set(key, pending);
return pending;
}
// Resolve (and cache per batch) the doodstream API key for a login-only // Resolve (and cache per batch) the doodstream API key for a login-only
// account by logging in once and scraping+validating it from the session. // account by logging in once and scraping+validating it from the session.
// Returns the key string, or '' when none could be derived (cached either way // Returns the key string, or '' when none could be derived (cached either way

135
main.js
View File

@ -216,23 +216,10 @@ function rotLog(msg, ts) {
try { try {
const iso = new Date(ts || Date.now()).toISOString(); const iso = new Date(ts || Date.now()).toISOString();
const line = `[${iso}] ${msg}\n`; const line = `[${iso}] ${msg}\n`;
// Write synchronously. Rotation events are rare (a handful per batch) so _rotLogBuffer.push(line);
// the batching optimization from debugLog doesn't buy us anything, and if (!_rotLogFlushTimer) {
// syncing guarantees the user can refresh the file and see fresh entries _rotLogFlushTimer = setTimeout(() => { _rotLogFlushTimer = null; _flushRotLog(); }, 500);
// without waiting on a flush timer.
const candidates = [
getRotLogPath(),
path.join(app.getPath('desktop') || app.getPath('userData'), 'account-rotation.log'),
path.join(app.getPath('userData'), 'account-rotation.log')
];
for (const target of candidates) {
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.appendFileSync(target, line, 'utf-8');
break;
} catch {}
} }
// Mirror into the main debug log for single-file-grep convenience.
_debugLogBuffer.push(`[${iso}] [ROT] ${msg}\n`); _debugLogBuffer.push(`[${iso}] [ROT] ${msg}\n`);
if (!_debugLogFlushTimer) { if (!_debugLogFlushTimer) {
_debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500); _debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500);
@ -1297,33 +1284,32 @@ ipcMain.handle('select-folder', async () => {
}); });
if (result.canceled || !result.filePaths.length) return null; if (result.canceled || !result.filePaths.length) return null;
// Recursively collect all files from selected folders
const files = []; const files = [];
const walk = (dir) => { for (const folder of result.filePaths) await walkFolderAsync(folder, files);
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) files.push(full);
}
} catch {}
};
for (const folder of result.filePaths) walk(folder);
return files.length > 0 ? files : null; return files.length > 0 ? files : null;
}); });
async function walkFolderAsync(rootDir, outFiles) {
const fsp = fs.promises;
const stack = [rootDir];
let scanned = 0;
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
catch { continue; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) stack.push(full);
else if (entry.isFile()) outFiles.push(full);
}
if ((++scanned % 8) === 0) await new Promise(setImmediate);
}
}
ipcMain.handle('resolve-folder-files', async (_event, folderPath) => { ipcMain.handle('resolve-folder-files', async (_event, folderPath) => {
const files = []; const files = [];
const walk = (dir) => { await walkFolderAsync(folderPath, files);
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) files.push(full);
}
} catch {}
};
walk(folderPath);
return files; return files;
}); });
@ -1383,8 +1369,27 @@ ipcMain.handle('start-upload', (_event, payload) => {
// Pass hoster settings to the upload manager // Pass hoster settings to the upload manager
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}); uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
const _progressByJob = new Map();
const _progressTerminalQueue = [];
let _progressFlushTimer = null;
const PROGRESS_BATCH_INTERVAL_MS = 100;
function _scheduleProgressFlush() {
if (_progressFlushTimer) return;
_progressFlushTimer = setTimeout(() => {
_progressFlushTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) {
_progressByJob.clear();
_progressTerminalQueue.length = 0;
return;
}
const batch = _progressTerminalQueue.splice(0);
for (const v of _progressByJob.values()) batch.push(v);
_progressByJob.clear();
if (batch.length) mainWindow.webContents.send('upload-progress-batch', batch);
}, PROGRESS_BATCH_INTERVAL_MS);
}
uploadManager.on('progress', (data) => { uploadManager.on('progress', (data) => {
// Only log state changes, not continuous progress updates
if (data.status !== 'uploading') { if (data.status !== 'uploading') {
debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`); debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`);
_appendJobLog(data.jobId, { _appendJobLog(data.jobId, {
@ -1393,10 +1398,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
error: data.error || null, attempt: data.attempt || 0, maxAttempts: data.maxAttempts || 0 error: data.error || null, attempt: data.attempt || 0, maxAttempts: data.maxAttempts || 0
}); });
} }
// Write to fileuploader.log immediately when a single upload finishes —
// unless the user disabled logging for this hoster (per-hoster toggle).
// Read from the live uploadManager.hosterSettings so a mid-batch toggle
// (which calls updateSettings) takes effect immediately.
if (data.status === 'done' && data.result) { if (data.status === 'done' && data.result) {
const link = data.result.download_url || data.result.embed_url || data.result.file_code || ''; const link = data.result.download_url || data.result.embed_url || data.result.file_code || '';
if (link) { if (link) {
@ -1409,9 +1410,16 @@ ipcMain.handle('start-upload', (_event, payload) => {
debugLog(`WARNING: done but no link for ${data.fileName} @ ${data.hoster}: ${JSON.stringify(data.result)}`); debugLog(`WARNING: done but no link for ${data.fileName} @ ${data.hoster}: ${JSON.stringify(data.result)}`);
} }
} }
if (mainWindow && !mainWindow.isDestroyed()) { const isTerminal = data.status === 'done' || data.status === 'error' || data.status === 'aborted' || data.status === 'skipped';
mainWindow.webContents.send('upload-progress', data); if (isTerminal) {
if (data.jobId) _progressByJob.delete(data.jobId);
_progressTerminalQueue.push(data);
} else if (data.jobId) {
_progressByJob.set(data.jobId, data);
} else {
_progressTerminalQueue.push(data);
} }
_scheduleProgressFlush();
}); });
uploadManager.on('stats', (data) => { uploadManager.on('stats', (data) => {
@ -1445,6 +1453,15 @@ ipcMain.handle('start-upload', (_event, payload) => {
} }
}); });
const ROT_LOG_RENDERER_EVENTS = new Set([
'switchAccount',
'pre-job-swap',
'try-alternate-after-fail',
'mark-failed',
'rotation-end',
'doodstream-via-api',
'doodstream-via-web'
]);
uploadManager.on('rot-log', (entry) => { uploadManager.on('rot-log', (entry) => {
const { ts, event, ...rest } = entry; const { ts, event, ...rest } = entry;
const pairs = Object.entries(rest) const pairs = Object.entries(rest)
@ -1454,7 +1471,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
if (entry.jobId) { if (entry.jobId) {
_appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest }); _appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest });
} }
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed() && ROT_LOG_RENDERER_EVENTS.has(event)) {
mainWindow.webContents.send('account-rotation-log', entry); mainWindow.webContents.send('account-rotation-log', entry);
} }
}); });
@ -1733,12 +1750,19 @@ ipcMain.handle('export-backup', async () => {
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Backup exportieren', title: 'Backup exportieren',
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`, defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }] filters: [
{ name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] },
{ name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] }
]
}); });
if (canceled || !filePath) return { ok: false, canceled: true }; if (canceled || !filePath) return { ok: false, canceled: true };
const config = configStore.load(); const config = configStore.load();
if (filePath.toLowerCase().endsWith('.json')) {
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
} else {
const encrypted = backupCrypto.encrypt(config); const encrypted = backupCrypto.encrypt(config);
fs.writeFileSync(filePath, encrypted); fs.writeFileSync(filePath, encrypted);
}
return { ok: true, path: filePath }; return { ok: true, path: filePath };
}); });
@ -1750,7 +1774,11 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
} else { } else {
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Backup importieren', title: 'Backup importieren',
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }], filters: [
{ name: 'Multi-Hoster Backup', extensions: ['mhu', 'json'] },
{ name: 'Verschlüsselt (.mhu)', extensions: ['mhu'] },
{ name: 'Klartext (.json)', extensions: ['json'] }
],
properties: ['openFile'] properties: ['openFile']
}); });
if (canceled || !filePaths.length) return { ok: false, canceled: true }; if (canceled || !filePaths.length) return { ok: false, canceled: true };
@ -1759,15 +1787,26 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
_lastImportPath = sourcePath; _lastImportPath = sourcePath;
} }
let imported; let imported;
const looksLikeJson = buffer.length >= 1 && (buffer[0] === 0x7B || buffer[0] === 0x20 || buffer[0] === 0x0A || buffer[0] === 0x0D || buffer[0] === 0x09 || buffer[0] === 0xEF);
if (looksLikeJson) {
try {
const text = buffer.toString('utf-8').replace(/^\uFEFF/, '');
imported = JSON.parse(text);
} catch (err) {
_lastImportPath = null;
return { ok: false, error: 'Klartext-Backup ist kein gültiges JSON: ' + (err.message || err) };
}
} else {
try { try {
imported = backupCrypto.decrypt(buffer, legacyPassword); imported = backupCrypto.decrypt(buffer, legacyPassword);
} catch (err) { } catch (err) {
if (err && err.needsPassword) { if (err && err.needsPassword) {
return { ok: false, needsPassword: true }; return { ok: false, needsPassword: true, hint: 'Falls dieses Backup mit der aktuellen Version erzeugt wurde, ist die Datei vermutlich beim Transfer beschädigt worden (z. B. FTP-Text-Modus). Versuch es mit einem Klartext-JSON-Export.' };
} }
_lastImportPath = null; _lastImportPath = null;
throw err; throw err;
} }
}
_lastImportPath = null; _lastImportPath = null;
// Validate imported data has required structure // Validate imported data has required structure
if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) { if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) {

View File

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

View File

@ -93,6 +93,9 @@ contextBridge.exposeInMainWorld('api', {
onUploadProgress: (callback) => { onUploadProgress: (callback) => {
ipcRenderer.on('upload-progress', (_event, data) => callback(data)); ipcRenderer.on('upload-progress', (_event, data) => callback(data));
}, },
onUploadProgressBatch: (callback) => {
ipcRenderer.on('upload-progress-batch', (_event, batch) => callback(batch));
},
onUploadBatchDone: (callback) => { onUploadBatchDone: (callback) => {
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data)); ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
}, },

View File

@ -108,24 +108,19 @@ async function init() {
window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress); window.api.onUpdateProgress(handleUpdateProgress);
// Upload event listeners — debug log only on state transitions; the 'uploading'
// tick fires 4×/sec per active job and an IPC roundtrip per event would
// backlog the renderer↔main channel with hundreds of messages/sec.
window.api.onUploadProgress((data) => { window.api.onUploadProgress((data) => {
if (data.status !== 'uploading') {
window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || ''));
}
handleProgress(data); handleProgress(data);
}); });
if (window.api.onUploadProgressBatch) {
window.api.onUploadProgressBatch((batch) => {
if (!Array.isArray(batch)) return;
for (let i = 0; i < batch.length; i++) handleProgress(batch[i]);
});
}
window.api.onUploadBatchDone((data) => { window.api.onUploadBatchDone((data) => {
window.api.debugLog('RX upload-batch-done');
handleBatchDone(data); handleBatchDone(data);
}); });
window.api.onUploadStats((data) => { window.api.onUploadStats((data) => {
// Stats fire every second per upload session — skip while uploading.
if (data.state !== 'uploading') {
window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs);
}
handleStats(data); handleStats(data);
}); });
window.api.onShutdownCountdown(handleShutdownCountdown); window.api.onShutdownCountdown(handleShutdownCountdown);
@ -1448,7 +1443,7 @@ async function doBackupExport() {
} }
} }
function askLegacyBackupPassword() { function askLegacyBackupPassword(hint) {
return new Promise((resolve) => { return new Promise((resolve) => {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'modal-overlay'; overlay.className = 'modal-overlay';
@ -1461,7 +1456,7 @@ function askLegacyBackupPassword() {
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'modal-header'; header.className = 'modal-header';
const h3 = document.createElement('h3'); const h3 = document.createElement('h3');
h3.textContent = 'Passwort erforderlich'; h3.textContent = 'Backup nicht entschlüsselbar';
header.appendChild(h3); header.appendChild(h3);
const body = document.createElement('div'); const body = document.createElement('div');
@ -1469,7 +1464,15 @@ function askLegacyBackupPassword() {
const p = document.createElement('p'); const p = document.createElement('p');
p.style.margin = '0 0 10px'; p.style.margin = '0 0 10px';
p.style.fontSize = '13px'; p.style.fontSize = '13px';
p.textContent = 'Dieses Backup wurde mit einem Passwort verschlüsselt.'; p.textContent = 'Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.';
if (hint) {
const p2 = document.createElement('p');
p2.style.margin = '0 0 10px';
p2.style.fontSize = '12px';
p2.style.color = 'var(--text-dim)';
p2.textContent = hint;
body.appendChild(p2);
}
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'password'; input.type = 'password';
input.className = 'key-input'; input.className = 'key-input';
@ -1513,7 +1516,7 @@ async function doBackupImport(legacyPassword) {
const result = await window.api.importBackup(pw); const result = await window.api.importBackup(pw);
if (!result || result.canceled) return; if (!result || result.canceled) return;
if (result.needsPassword) { if (result.needsPassword) {
const entered = await askLegacyBackupPassword(); const entered = await askLegacyBackupPassword(result.hint);
if (entered) doBackupImport(entered); if (entered) doBackupImport(entered);
return; return;
} }
@ -2527,8 +2530,10 @@ async function executeHealthCheck(hosters, _mode) {
} }
async function runHealthCheck(mode = 'manual', requestedHosters = null) { async function runHealthCheck(mode = 'manual', requestedHosters = null) {
if (healthCheckRunning || (uploading && mode === 'manual')) return []; if (healthCheckRunning) {
// Build check list: all enabled accounts with creds if (mode === 'manual') showCopyToast('Account-Check läuft bereits.');
return [];
}
let hosters; let hosters;
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) { if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
hosters = requestedHosters; hosters = requestedHosters;
@ -4108,17 +4113,32 @@ function renderHistoryTable(container) {
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
</tr></thead><tbody>`; </tr></thead><tbody>`;
rows.forEach(row => { const parts = [html];
html += `<tr class="history-row${row.isError ? ' error' : ''}" data-link="${escapeAttr(row.link)}"> const len = rows.length;
<td class="col-date">${escapeHtml(row.date)}</td> for (let i = 0; i < len; i++) {
<td class="col-filename">${escapeHtml(row.filename)}</td> const row = rows[i];
<td class="col-host">${escapeHtml(row.host)}</td> const link = row.link || '';
<td class="col-link">${escapeHtml(row.link)}</td> const date = escapeHtml(row.date);
</tr>`; const filename = escapeHtml(row.filename);
}); const host = escapeHtml(row.host);
const linkHtml = escapeHtml(link);
html += '</tbody></table>'; const linkAttr = escapeAttr(link);
container.innerHTML = html; parts.push('<tr class="history-row');
if (row.isError) parts.push(' error');
parts.push('" data-link="');
parts.push(linkAttr);
parts.push('"><td class="col-date">');
parts.push(date);
parts.push('</td><td class="col-filename">');
parts.push(filename);
parts.push('</td><td class="col-host">');
parts.push(host);
parts.push('</td><td class="col-link">');
parts.push(linkHtml);
parts.push('</td></tr>');
}
parts.push('</tbody></table>');
container.innerHTML = parts.join('');
// Delegated listeners: bind once per render-target instead of once per // Delegated listeners: bind once per render-target instead of once per
// row/header. With a 5000-row history the per-row bind path was a // row/header. With a 5000-row history the per-row bind path was a

View File

@ -31,6 +31,7 @@ describe('UploadManager', () => {
const origRequire = module.constructor.prototype.require; const origRequire = module.constructor.prototype.require;
const hosters = require('../lib/hosters'); const hosters = require('../lib/hosters');
hosters.uploadFile = mockUploadFile; hosters.uploadFile = mockUploadFile;
hosters.prefetchBaseline = async () => null;
// Mock fs.statSync for test file paths // Mock fs.statSync for test file paths
const fs = require('fs'); const fs = require('fs');
@ -55,8 +56,8 @@ describe('UploadManager', () => {
]); ]);
const statuses = events.map(e => e.status); const statuses = events.map(e => e.status);
assert.ok(statuses.includes('queued'), 'should have queued status');
assert.ok(statuses.includes('done'), 'should have done status'); assert.ok(statuses.includes('done'), 'should have done status');
assert.ok(events.length > 0, 'should emit at least one progress event');
}); });
it('emits batch-done with correct summary', async () => { it('emits batch-done with correct summary', async () => {