Compare commits

...

12 Commits

Author SHA1 Message Date
Administrator
7749699830 release: v3.3.58 2026-06-09 06:02:16 +02:00
Administrator
de371e56a3 fix(ui): hydrate missing file sizes for queued/waiting rows + show '...' fallback 2026-06-09 06:01:42 +02:00
Administrator
b93617ace9 release: v3.3.57 2026-06-09 05:03:05 +02:00
Administrator
82e0163d3f fix(ui): 'Ausgewählte starten' during active upload also accepts preview jobs 2026-06-09 05:02:28 +02:00
Administrator
05fae3209d release: v3.3.56 2026-06-09 04:58:22 +02:00
Administrator
0f72478a2e fix(ui): remove batch summary modal at end of upload 2026-06-09 04:57:54 +02:00
Administrator
f0fb5f881f release: v3.3.55 2026-06-08 23:04:14 +02:00
Administrator
d3fda31243 fix(ui): ETA includes waiting jobs — folder-added files now ship with bytesTotal 2026-06-08 23:03:29 +02:00
Administrator
127807d62a release: v3.3.54 2026-06-08 22:03:41 +02:00
Administrator
6cd7498f70 fix(critical): safeSend infinite recursion + queueMicrotask, plus 6 audit findings 2026-06-08 22:03:19 +02:00
Administrator
ddf2710fc6 release: v3.3.53 2026-06-08 21:28:34 +02:00
Administrator
0f57aef7c7 fix(stability): wrap hot timers/callbacks in try/catch, safeSend, updater waits for batch 2026-06-08 21:28:12 +02:00
9 changed files with 290 additions and 145 deletions

View File

@ -277,7 +277,12 @@ class ConfigStore {
if (fs.existsSync(this.filePath)) {
const existing = fs.readFileSync(this.filePath, 'utf-8');
if (existing && existing.trim().length > 2) {
fs.writeFileSync(backupPath, existing, 'utf-8');
let isValid = false;
try {
const parsed = JSON.parse(existing);
isValid = parsed && typeof parsed === 'object' && (parsed.hosters || parsed.hosterSettings || parsed.globalSettings);
} catch {}
if (isValid) fs.writeFileSync(backupPath, existing, 'utf-8');
}
}
} catch {}

View File

@ -233,7 +233,20 @@ async function installUpdate(onProgress) {
// Stage: done
if (onProgress) onProgress({ stage: 'done', percent: 100 });
setTimeout(() => app.quit(), 900);
const _doQuit = () => setTimeout(() => app.quit(), 900);
const _getActive = () => {
try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; }
catch { return 0; }
};
if (_getActive() > 0) {
const POLL_MS = 3000;
const poller = setInterval(() => {
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
}, POLL_MS);
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
} else {
_doQuit();
}
} catch (err) {
if (onProgress) onProgress({ stage: 'error', error: err.message });

View File

@ -316,6 +316,7 @@ class UploadManager extends EventEmitter {
const DEDUP_CHUNK = 200;
for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
if (signal.aborted) break;
const end = Math.min(i + DEDUP_CHUNK, tasks.length);
for (let j = i; j < end; j++) {
const task = tasks[j];
@ -334,6 +335,7 @@ class UploadManager extends EventEmitter {
const SPAWN_CHUNK = 100;
const promises = [];
for (let i = 0; i < tasks.length; i += SPAWN_CHUNK) {
if (signal.aborted) break;
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);
@ -558,6 +560,7 @@ class UploadManager extends EventEmitter {
speedAbort = new AbortController();
uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]);
speedMonitor = setInterval(() => {
try {
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
if (!lowSpeedSince) lowSpeedSince = Date.now();
if (Date.now() - lowSpeedSince > 6000) {
@ -566,6 +569,7 @@ class UploadManager extends EventEmitter {
} else {
lowSpeedSince = 0;
}
} catch (e) { this._rotLog('speed-monitor-error', { jobId, error: e && e.message }); }
}, 2000);
}
@ -579,10 +583,11 @@ class UploadManager extends EventEmitter {
const PROGRESS_EMIT_INTERVAL = 250; // ms throttle UI updates
const progressCb = (bytesUploaded, bytesTotal) => {
try {
const now = Date.now();
const elapsed = Math.round((now - jobStart) / 1000);
const timeDelta = (now - lastSpeedTime) / 1000;
if (timeDelta >= 1) {
if (Number.isFinite(timeDelta) && timeDelta >= 1) {
const bytesDelta = bytesUploaded - lastBytes;
currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024);
lastBytes = bytesUploaded;
@ -592,7 +597,6 @@ class UploadManager extends EventEmitter {
activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded;
// Throttle progress emissions to reduce IPC + rendering overhead
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
lastEmitTime = now;
@ -614,6 +618,7 @@ class UploadManager extends EventEmitter {
attempt,
maxAttempts
});
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
};
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
@ -995,7 +1000,7 @@ class UploadManager extends EventEmitter {
_startStatsTimer() {
if (this.statsInterval) clearInterval(this.statsInterval);
this.statsInterval = setInterval(() => {
// Single pass over active jobs instead of two.
try {
let globalSpeedKbs = 0;
let activeCount = 0;
let inProgressBytes = 0;
@ -1015,6 +1020,7 @@ class UploadManager extends EventEmitter {
activeJobs: activeCount,
pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0)
});
} catch { /* never let a stats tick crash the timer + caller */ }
}, 1000);
}

View File

@ -382,7 +382,7 @@ class VidmolyUploader {
}
}
if (best && (bestScore > 0 || newFiles.length === 1)) {
if (best && bestScore > 0) {
return this._buildUrlsFromCode(best.file_code);
}
}

122
main.js
View File

@ -208,6 +208,7 @@ function getAllLogPaths() {
debug: debugPath,
accountRotation: rot,
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
crashLog: path.join(dir, 'crash.log'),
logDir: dir
};
}
@ -227,6 +228,17 @@ function rotLog(msg, ts) {
} catch {}
}
function safeSend(channel, data) {
if (!mainWindow || mainWindow.isDestroyed()) return false;
try {
mainWindow.webContents.send(channel, data);
return true;
} catch (err) {
debugLog(`safeSend(${channel}) failed: ${err && err.message ? err.message : err}`);
return false;
}
}
function _writeCrashLog(prefix, err, extra) {
try {
const ts = new Date().toISOString();
@ -471,9 +483,7 @@ function _flushUploadLog() {
// next session writes here directly (no more fallback ladder) and
// the Settings input reflects reality.
_persistFallbackLogPath(target.path);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path });
}
safeSend('upload-log-fallback', { fallbackPath: target.path });
}
if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog);
});
@ -501,9 +511,7 @@ function _persistFallbackLogPath(workingPath) {
cfg.globalSettings = gs;
configStore.save({ globalSettings: gs }).catch(() => {});
_invalidateUploadLogTargetCache();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('log-path-auto-updated', { logFilePath: toSave });
}
safeSend('log-path-auto-updated', { logFilePath: toSave });
} catch (err) {
debugLog(`persist fallback logpath failed: ${err.message}`);
}
@ -1125,7 +1133,7 @@ app.whenReady().then(() => {
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
logDebug(`update-check result: ${JSON.stringify(result)}`);
if (result && result.available && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-available', result);
safeSend('app:update-available', result);
}
} catch (err) {
logError('update-check failed', err);
@ -1147,7 +1155,8 @@ app.on('before-quit', () => {
if (remoteServer) { remoteServer.stop(); remoteServer = null; }
destroyCaptureWindow();
} catch {}
destroyDropTargetWindow();
try { destroyDropTargetWindow(); } catch {}
try { if (tray && !tray.isDestroyed()) { tray.destroy(); tray = null; } } catch {}
// Flush pending log buffers synchronously so no lines are lost.
try {
if (_debugLogBuffer.length) {
@ -1208,13 +1217,11 @@ ipcMain.handle('save-config', async (_event, config) => {
rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`);
uploadManager.switchAccount(hoster, fallback);
_sessionAccountOverrides.set(hoster, fallback);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('account-switched', {
safeSend('account-switched', {
hoster, fromAccountId: failedAccountId, toAccountId: fallback.id
});
}
}
}
} catch (err) {
debugLog(`save-config re-resolve failed: ${err && err.message ? err.message : err}`);
}
@ -1372,6 +1379,17 @@ ipcMain.handle('select-folder', async () => {
});
if (result.canceled || !result.filePaths.length) return null;
const files = [];
for (const folder of result.filePaths) await walkFolderAsync(folder, files);
return files.length > 0 ? files.map(f => f.path) : null;
});
ipcMain.handle('select-folder-with-sizes', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'multiSelections']
});
if (result.canceled || !result.filePaths.length) return null;
const files = [];
for (const folder of result.filePaths) await walkFolderAsync(folder, files);
return files.length > 0 ? files : null;
@ -1389,7 +1407,11 @@ async function walkFolderAsync(rootDir, outFiles) {
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);
else if (entry.isFile()) {
let size = 0;
try { size = (await fsp.stat(full)).size; } catch {}
outFiles.push({ path: full, name: entry.name, size });
}
}
if ((++scanned % 8) === 0) await new Promise(setImmediate);
}
@ -1401,6 +1423,18 @@ ipcMain.handle('resolve-folder-files', async (_event, folderPath) => {
return files;
});
ipcMain.handle('get-file-sizes', async (_event, paths) => {
if (!Array.isArray(paths)) return {};
const fsp = fs.promises;
const out = {};
let i = 0;
for (const p of paths) {
try { out[p] = (await fsp.stat(p)).size; } catch { out[p] = 0; }
if ((++i % 32) === 0) await new Promise(setImmediate);
}
return out;
});
ipcMain.handle('start-upload', (_event, payload) => {
const config = configStore.load();
const files = payload && Array.isArray(payload.files) ? payload.files : [];
@ -1456,6 +1490,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
// Pass hoster settings to the upload manager
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
globalThis._mhuUploadManagerRef = uploadManager;
const _progressByJob = new Map();
const _progressTerminalQueue = [];
@ -1473,7 +1508,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
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);
if (batch.length) safeSend('upload-progress-batch', batch);
}, PROGRESS_BATCH_INTERVAL_MS);
}
@ -1511,16 +1546,16 @@ ipcMain.handle('start-upload', (_event, payload) => {
});
uploadManager.on('stats', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-stats', data);
}
// Update tray tooltip with upload progress
try {
if (!data || typeof data !== 'object') return;
safeSend('upload-stats', data);
if (data.state === 'uploading' && data.activeJobs > 0) {
const speedMb = ((data.globalSpeedKbs || 0) / 1024).toFixed(1);
const speedMb = ((Number(data.globalSpeedKbs) || 0) / 1024).toFixed(1);
updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`);
} else {
updateTrayTooltip('Multi-Hoster-Upload');
}
} catch (e) { debugLog(`stats listener error: ${e && e.message}`); }
});
uploadManager.on('account-failed', ({ hoster, accountId }) => {
@ -1533,9 +1568,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`);
uploadManager.switchAccount(hoster, fallback);
_sessionAccountOverrides.set(hoster, fallback);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
}
safeSend('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
} else {
rotLog(`main: account-failed ${hoster} ${accountId} → NO fallback available (end of chain)`);
}
@ -1551,17 +1584,25 @@ ipcMain.handle('start-upload', (_event, payload) => {
'doodstream-via-web'
]);
uploadManager.on('rot-log', (entry) => {
try {
if (!entry || typeof entry !== 'object') return;
const { ts, event, ...rest } = entry;
const pairs = Object.entries(rest)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.map(([k, v]) => {
let sv;
try { sv = typeof v === 'string' ? v : JSON.stringify(v); }
catch { sv = '<unserializable>'; }
return `${k}=${sv}`;
})
.join(' ');
rotLog(`[${event}] ${pairs}`, ts);
if (entry.jobId) {
_appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest });
}
if (mainWindow && !mainWindow.isDestroyed() && ROT_LOG_RENDERER_EVENTS.has(event)) {
mainWindow.webContents.send('account-rotation-log', entry);
if (ROT_LOG_RENDERER_EVENTS.has(event)) {
safeSend('account-rotation-log', entry);
}
} catch (e) { debugLog(`rot-log listener error: ${e && e.message}`); }
});
// Capture the manager identity at listener-registration time so the post-
@ -1578,13 +1619,11 @@ ipcMain.handle('start-upload', (_event, payload) => {
try { await configStore.appendHistory(summary); } catch (err) {
debugLog(`appendHistory failed: ${err.message}`);
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', summary);
}
safeSend('upload-batch-done', summary);
// Shutdown after finish
handleShutdownAfterFinish();
if (uploadManager === _thisManager) uploadManager = null;
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await');
});
@ -1600,8 +1639,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
}).catch((err) => {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
// Forward error to renderer as batch-done with failure
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', {
safeSend('upload-batch-done', {
id: 'error',
timestamp: new Date().toISOString(),
total: tasks.length,
@ -1610,7 +1648,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
files: [],
error: err ? err.message : 'Unbekannter Fehler'
});
}
});
});
@ -1797,6 +1834,7 @@ ipcMain.handle('create-support-bundle', async () => {
{ label: 'debug.log (last 5 MB)', path: paths.debug, maxBytes: 5 * 1024 * 1024 },
{ label: 'account-rotation.log (last 2 MB)', path: paths.accountRotation, maxBytes: 2 * 1024 * 1024 },
{ label: 'doodstream-debug.log (last 2 MB)', path: paths.doodstreamDebug, maxBytes: 2 * 1024 * 1024 },
{ label: 'crash.log', path: path.join(paths.logDir || path.dirname(paths.debug), 'crash.log'), maxBytes: 1 * 1024 * 1024 },
{ label: 'fileuploader.log (last 1 MB)', path: paths.fileuploader, maxBytes: 1 * 1024 * 1024 }
]
});
@ -2011,13 +2049,9 @@ ipcMain.handle('app:check-updates', async () => {
ipcMain.handle('app:install-update', () => {
installUpdate((progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-progress', progress);
}
safeSend('app:update-progress', progress);
}).catch((err) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-progress', { stage: 'error', error: err.message });
}
safeSend('app:update-progress', { stage: 'error', error: err.message });
});
return { started: true };
});
@ -2096,9 +2130,7 @@ function startFolderMonitor(settings) {
folderMonitor.removeAllListeners();
folderMonitor.on('new-files', (files) => {
debugLog(`folder-monitor: ${files.length} new file(s)`);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('folder-monitor:new-files', files);
}
safeSend('folder-monitor:new-files', files);
});
folderMonitor.on('error', (err) => {
debugLog(`folder-monitor error: ${err.message}`);
@ -2342,9 +2374,7 @@ ipcMain.handle('remote:get-capture-source-id', async () => {
// IPC: Client count updates from capture window
ipcMain.on('remote:client-count', (_event, count) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('remote:client-count', count);
}
safeSend('remote:client-count', count);
});
// IPC: Remote settings
@ -2449,7 +2479,7 @@ ipcMain.on('drop-target:files', (_event, paths) => {
mainWindow.show();
mainWindow.focus();
}
mainWindow.webContents.send('drop-target:files', paths);
safeSend('drop-target:files', paths);
}
});
@ -2486,9 +2516,7 @@ function handleShutdownAfterFinish() {
const { exec } = require('child_process');
// Notify renderer
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('shutdown-countdown', { mode: shutdownMode, seconds: 60 });
}
safeSend('shutdown-countdown', { mode: shutdownMode, seconds: 60 });
// Clear any previous countdown to prevent orphaned timers
if (shutdownTimer) clearTimeout(shutdownTimer);

View File

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

View File

@ -30,7 +30,9 @@ contextBridge.exposeInMainWorld('api', {
// File selection
selectFiles: () => ipcRenderer.invoke('select-files'),
selectFolder: () => ipcRenderer.invoke('select-folder'),
selectFolderWithSizes: () => ipcRenderer.invoke('select-folder-with-sizes'),
resolveFolderFiles: (folderPath) => ipcRenderer.invoke('resolve-folder-files', folderPath),
getFileSizes: (paths) => ipcRenderer.invoke('get-file-sizes', paths),
// Upload control
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),

View File

@ -99,6 +99,7 @@ async function init() {
syncSelectedUploadHosters();
restoreQueueStateFromConfig();
await _autoDeduplicateFromLog();
_hydrateMissingJobSizes();
renderHosterSummary();
renderHosterModal();
renderSettings();
@ -695,11 +696,12 @@ async function addDroppedFiles(fileList) {
const folderFiles = await window.api.resolveFolderFiles(filePath);
if (folderFiles && folderFiles.length > 0) {
for (const fp of folderFiles) {
if (!existingPaths.has(fp)) {
const name = fp.split('\\').pop().split('/').pop();
newFiles.push({ path: fp, name, size: null });
existingPaths.add(fp);
}
const p = typeof fp === 'string' ? fp : (fp && fp.path);
if (!p || existingPaths.has(p)) continue;
const name = typeof fp === 'string' ? p.split('\\').pop().split('/').pop() : (fp.name || p.split('\\').pop().split('/').pop());
const size = typeof fp === 'string' ? null : (fp.size || 0);
newFiles.push({ path: p, name, size });
existingPaths.add(p);
}
continue;
}
@ -730,28 +732,52 @@ async function pickFiles() {
}
async function pickFolder() {
const richFiles = window.api.selectFolderWithSizes ? await window.api.selectFolderWithSizes() : null;
if (richFiles && Array.isArray(richFiles)) { addPathsToQueue(richFiles); return; }
const paths = await window.api.selectFolder();
if (!paths) return;
addPathsToQueue(paths);
}
function addPathsToQueue(paths) {
// Build path-Set once so dedup is O(1) per candidate instead of O(n+m).
// Matters when the user picks a folder with thousands of files.
const existing = new Set();
for (const f of selectedFiles) existing.add(f.path);
for (const f of _pendingFiles) existing.add(f.path);
const newFiles = [];
for (const p of paths) {
if (existing.has(p)) continue;
const pendingSizeFetch = [];
for (const entry of paths) {
const p = typeof entry === 'string' ? entry : (entry && entry.path);
if (!p || existing.has(p)) continue;
existing.add(p);
const name = p.split('\\').pop().split('/').pop();
newFiles.push({ path: p, name, size: null });
const name = typeof entry === 'string' ? p.split('\\').pop().split('/').pop() : (entry.name || p.split('\\').pop().split('/').pop());
const size = typeof entry === 'string' ? null : (entry.size || 0);
newFiles.push({ path: p, name, size });
if (size === null || size === undefined || size === 0) pendingSizeFetch.push(p);
}
if (newFiles.length > 0) {
_pendingFiles.push(...newFiles);
openHosterModal();
if (pendingSizeFetch.length > 0 && window.api.getFileSizes) {
window.api.getFileSizes(pendingSizeFetch).then((sizeMap) => {
if (!sizeMap || typeof sizeMap !== 'object') return;
let changed = false;
for (const f of _pendingFiles) {
if (sizeMap[f.path] && (!f.size || f.size === 0)) { f.size = sizeMap[f.path]; changed = true; }
}
for (const f of selectedFiles) {
if (sizeMap[f.path] && (!f.size || f.size === 0)) { f.size = sizeMap[f.path]; changed = true; }
}
for (const j of queueJobs) {
if (sizeMap[j.file] && (!j.bytesTotal || j.bytesTotal === 0)) { j.bytesTotal = sizeMap[j.file]; changed = true; }
}
if (changed) {
_queueStatsCache = null;
if (typeof renderQueueTable === 'function') renderQueueTable();
if (typeof updateStatusBar === 'function') updateStatusBar();
}
}).catch(() => {});
}
}
}
@ -972,12 +998,54 @@ function scheduleStatusChangeUpdate() {
});
}
function _hydrateMissingJobSizes(jobsLike) {
if (!window.api || !window.api.getFileSizes) return;
const paths = [];
const seen = new Set();
const source = Array.isArray(jobsLike) ? jobsLike : queueJobs;
for (const j of source) {
if (!j || !j.file) continue;
if (j.bytesTotal && j.bytesTotal > 0) continue;
if (seen.has(j.file)) continue;
seen.add(j.file);
paths.push(j.file);
}
if (paths.length === 0) return;
window.api.getFileSizes(paths).then((sizeMap) => {
if (!sizeMap || typeof sizeMap !== 'object') return;
let changed = false;
for (const j of queueJobs) {
if (sizeMap[j.file] && (!j.bytesTotal || j.bytesTotal === 0)) {
j.bytesTotal = sizeMap[j.file];
changed = true;
}
}
for (const f of selectedFiles) {
if (sizeMap[f.path] && (!f.size || f.size === 0)) f.size = sizeMap[f.path];
}
if (changed) {
_queueStatsCache = null;
if (typeof renderQueueTable === 'function') renderQueueTable();
if (typeof updateStatusBar === 'function') updateStatusBar();
}
}).catch(() => {});
}
function _formatUploadedSize(job) {
const bt = job.bytesTotal || 0;
const bu = job.bytesUploaded || 0;
const s = job.status;
if (s === 'preview') return bt > 0 ? formatSize(bt) : '...';
if (s === 'queued' || s === 'getting-server' || s === 'retrying') {
return bt > 0 ? `${formatSize(bu)} / ${formatSize(bt)}` : '...';
}
return `${formatSize(bu)} / ${formatSize(bt)}`;
}
function buildRowHtml(job) {
const statusClass = `status-${job.status}`;
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
const uploadedSize = job.status === 'preview'
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
const uploadedSize = _formatUploadedSize(job);
const statusText = getStatusText(job);
const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining);
@ -1007,9 +1075,7 @@ function buildRowHtml(job) {
// In-place update of a single row's cells (avoids full innerHTML rebuild)
function _updateRowInPlace(tr, job) {
const statusClass = `status-${job.status}`;
const uploadedSize = job.status === 'preview'
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
const uploadedSize = _formatUploadedSize(job);
const statusText = getStatusText(job);
const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining);
@ -1703,6 +1769,7 @@ async function startUpload() {
if (uploading) return;
uploading = true; // set immediately to prevent double-click race
updateQueueActionButtons();
_hydrateMissingJobSizes();
const hosters = getSelectedHosters();
if (queueJobs.length === 0 && selectedFiles.length > 0) {
@ -1775,10 +1842,13 @@ function _markSkippedJobs(result) {
async function startSelectedUpload() {
if (uploading) {
// Batch already running — add selected jobs (queued/error/aborted/skipped) to running batch
// Upload-manager has duplicate protection (skips jobs already tracked)
const addable = queueJobs.filter(j => selectedJobIds.has(j.id) && ['queued', 'error', 'aborted', 'skipped'].includes(j.status));
if (addable.length > 0) {
_hydrateMissingJobSizes();
const addable = queueJobs.filter(j => selectedJobIds.has(j.id) && isStartableQueueStatus(j.status));
if (addable.length === 0) {
if (selectedJobIds.size > 0) showCopyToast('Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).');
return;
}
{
addable.forEach(j => {
j.status = 'queued'; j.error = null; j.result = null;
j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null;
@ -1823,7 +1893,6 @@ async function startSelectedUpload() {
}
return;
}
return;
}
uploading = true; // set immediately to prevent double-click race
updateQueueActionButtons();
@ -1896,6 +1965,14 @@ async function cancelUpload() {
// --- Progress handling ---
function handleProgress(data) {
try {
if (!data || typeof data !== 'object') return;
_handleProgressImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleProgress error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleProgressImpl(data) {
let job = data.jobId ? _jobIndexById.get(data.jobId) : null;
if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
if (!job) {
@ -2075,7 +2152,6 @@ function handleBatchDone(summary) {
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
updateStatusBar();
_maybeShowBatchSummary(summary);
_refreshSessionFailedSnapshot();
}
@ -2157,6 +2233,14 @@ function _retryFailedFromBuckets(buckets, transientOnly) {
}
function handleStats(data) {
try {
if (!data || typeof data !== 'object') return;
_handleStatsImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleStats error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleStatsImpl(data) {
lastUploadStats = {
state: data.state || 'idle',
globalSpeedKbs: data.globalSpeedKbs || 0,
@ -2484,7 +2568,7 @@ function _computeQueueStats() {
}
_queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
queueMicrotask(() => { _queueStatsCache = null; });
(typeof queueMicrotask === 'function' ? queueMicrotask : (fn) => Promise.resolve().then(fn))(() => { _queueStatsCache = null; });
return _queueStatsCache;
}
@ -3675,7 +3759,10 @@ async function deleteAccount(accountId) {
// Fire-and-forget the persist. The earlier `await getConfig()` round-trip
// was redundant (we already have the truth in memory) and was the main
// source of perceived lag on add/delete.
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
window.api.saveConfig({ hosters: config.hosters }).catch((err) => {
if (window.api && window.api.debugLog) window.api.debugLog(`deleteAccount saveConfig failed: ${err && err.message ? err.message : err}`);
showCopyToast('Account-Löschung konnte nicht persistiert werden — bitte erneut versuchen.');
});
}
function readAccountCredsFromModal(authType) {
@ -3881,6 +3968,15 @@ async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
if (idx >= 0) {
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
} else {
_accountModalBusy = false;
const _sb = document.getElementById('saveAccountBtn'); if (_sb) _sb.disabled = false;
const _st = document.getElementById('accountModalStatus');
if (_st) {
_st.textContent = 'Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.';
_st.className = 'account-modal-status error';
}
return;
}
} else {
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
@ -4828,4 +4924,15 @@ function updateStatsPanel() {
}
// --- Start ---
init();
init().catch((err) => {
try {
if (window.api && window.api.debugLog) window.api.debugLog(`init failed: ${err && err.stack ? err.stack : err}`);
const root = document.getElementById('app') || document.body;
if (root) {
const banner = document.createElement('div');
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#5a1e1e;color:#fff;padding:8px;z-index:99999;font-family:sans-serif;font-size:13px';
banner.textContent = 'Initialisierung fehlgeschlagen: ' + (err && err.message ? err.message : err) + ' — bitte Diagnose-Paket exportieren oder Programm neu starten.';
root.appendChild(banner);
}
} catch {}
});

View File

@ -342,22 +342,6 @@
</div>
</div>
<div class="modal" id="batchSummaryModal" style="display:none">
<div class="modal-content" style="max-width:680px">
<div class="modal-header">
<h2>Batch-Zusammenfassung</h2>
<button class="icon-btn" id="batchSummaryClose" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<div id="batchSummaryList"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="batchSummaryRetryTransient">Transiente erneut hochladen</button>
<button class="btn btn-primary" id="batchSummaryRetryAll">Alle Fehler erneut versuchen</button>
</div>
</div>
</div>
<script src="../lib/queue-prune.js"></script>
<script src="../lib/queue-dedup.js"></script>
<script src="../lib/log-mode.js"></script>