perf: O(1) lookups for selection buttons, applySummaryResults, file-drop dedup; batched upload log

Four more wins targeting batch-heavy paths:

  - updateQueueActionButtons replaced three O(n) queueJobs.some() scans
    with a single O(|selection|) pass over selectedJobIds, using the
    existing _jobIndexById map. Selection change cost on a 1000-job
    queue drops from ~3000 comparisons to |selection|.

  - applySummaryResults built a (fileName+hoster)→job Map once per call
    instead of running queueJobs.find() per result. Big batches
    (hundreds of files × multiple hosters) no longer scale O(n²).

  - addPathsToQueue and the folder-monitor auto-queue path built their
    dedup Set up front instead of running .find() per incoming path.
    Picking a folder with thousands of files now dedups in O(n+m)
    instead of O(n×m).

  - appendUploadLog became async + buffered like debugLog. A burst of
    20 files completing within a second becomes one fs.appendFile
    instead of 20 fs.appendFileSync that each blocked the main event
    loop. Fallback ladder (primary → Desktop → userData) is preserved;
    pending buffer flushes synchronously on before-quit.
This commit is contained in:
Administrator 2026-04-19 13:38:39 +02:00
parent 73e7190913
commit 879f6ade0e
2 changed files with 109 additions and 57 deletions

113
main.js
View File

@ -159,54 +159,70 @@ function getSafeDesktopDir() {
} }
let _uploadLogFallbackWarned = false; let _uploadLogFallbackWarned = false;
// Buffer upload-log lines so a burst of completing jobs (e.g. 20 files finishing
// within a second) becomes one file write instead of 20 sync writes.
const _uploadLogBuffer = [];
let _uploadLogFlushTimer = null;
let _uploadLogWriting = false;
function _resolveUploadLogTarget() {
// Try primary → desktop → userData, mirror the original fallback ladder.
const primary = getLogFilePath();
try {
fs.mkdirSync(path.dirname(primary), { recursive: true });
return { path: primary, isFallback: false };
} catch (err) {
debugLog(`uploadLog primary dir unavailable (${err.message})`);
}
const desktop = getSafeDesktopDir();
if (desktop) {
try {
const p = buildFallbackLogName(desktop);
fs.mkdirSync(path.dirname(p), { recursive: true });
return { path: p, isFallback: true };
} catch {}
}
try {
const p = buildFallbackLogName(app.getPath('userData'));
fs.mkdirSync(path.dirname(p), { recursive: true });
return { path: p, isFallback: true };
} catch (err) {
debugLog(`uploadLog: no writable target (${err.message})`);
return null;
}
}
function _flushUploadLog() {
if (_uploadLogWriting || _uploadLogBuffer.length === 0) return;
const target = _resolveUploadLogTarget();
if (!target) { _uploadLogBuffer.length = 0; return; }
const chunk = _uploadLogBuffer.join('');
_uploadLogBuffer.length = 0;
_uploadLogWriting = true;
fs.appendFile(target.path, chunk, 'utf-8', (err) => {
_uploadLogWriting = false;
if (err) {
debugLog(`uploadLog append failed: ${err.message}`);
} else if (target.isFallback && !_uploadLogFallbackWarned) {
_uploadLogFallbackWarned = true;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path });
}
}
if (_uploadLogBuffer.length) setImmediate(_flushUploadLog);
});
}
function appendUploadLog(hoster, link, fileName) { function appendUploadLog(hoster, link, fileName) {
const now = new Date(); const now = new Date();
const pad = (n) => String(n).padStart(2, '0'); const pad = (n) => String(n).padStart(2, '0');
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
const line = `${dateStr}|${hoster}|${link}||${fileName}|\n`; _uploadLogBuffer.push(`${dateStr}|${hoster}|${link}||${fileName}|\n`);
if (!_uploadLogFlushTimer) {
const tryWrite = (p) => { _uploadLogFlushTimer = setTimeout(() => {
fs.mkdirSync(path.dirname(p), { recursive: true }); _uploadLogFlushTimer = null;
fs.appendFileSync(p, line, 'utf-8'); _flushUploadLog();
}; }, 500);
try {
tryWrite(getLogFilePath());
return;
} catch (err) {
debugLog(`appendUploadLog primary failed (${err.message}); trying desktop fallback`);
}
// Fallback 1: current user's Desktop (visible, easy to find).
const desktop = getSafeDesktopDir();
if (desktop) {
try {
const fallbackPath = buildFallbackLogName(desktop);
tryWrite(fallbackPath);
if (!_uploadLogFallbackWarned) {
_uploadLogFallbackWarned = true;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-log-fallback', { fallbackPath });
}
}
return;
} catch (err) {
debugLog(`appendUploadLog desktop fallback failed (${err.message}); trying userData`);
}
}
// Fallback 2: userData (always writable by the current user).
try {
const fallbackPath = buildFallbackLogName(app.getPath('userData'));
tryWrite(fallbackPath);
if (!_uploadLogFallbackWarned) {
_uploadLogFallbackWarned = true;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-log-fallback', { fallbackPath });
}
}
} catch (err) {
debugLog(`appendUploadLog all fallbacks failed: ${err.message}`);
} }
} }
@ -751,13 +767,20 @@ app.on('before-quit', () => {
destroyCaptureWindow(); destroyCaptureWindow();
} catch {} } catch {}
destroyDropTargetWindow(); destroyDropTargetWindow();
// Flush pending debug-log buffer synchronously so no lines are lost. // Flush pending log buffers synchronously so no lines are lost.
try { try {
if (_debugLogBuffer.length) { if (_debugLogBuffer.length) {
fs.appendFileSync(getDebugLogPath(), _debugLogBuffer.join(''), 'utf-8'); fs.appendFileSync(getDebugLogPath(), _debugLogBuffer.join(''), 'utf-8');
_debugLogBuffer.length = 0; _debugLogBuffer.length = 0;
} }
} catch {} } catch {}
try {
if (_uploadLogBuffer.length) {
const target = _resolveUploadLogTarget();
if (target) fs.appendFileSync(target.path, _uploadLogBuffer.join(''), 'utf-8');
_uploadLogBuffer.length = 0;
}
} catch {}
}); });
// --- IPC Handlers --- // --- IPC Handlers ---

View File

@ -130,12 +130,15 @@ async function init() {
if (fmHosters.length > 0) { if (fmHosters.length > 0) {
// Pre-selected hosters: set them as active selection and add directly to queue // Pre-selected hosters: set them as active selection and add directly to queue
selectedUploadHosters = fmHosters.slice(); selectedUploadHosters = fmHosters.slice();
const existing = new Set();
for (const f of selectedFiles) existing.add(f.path);
for (const f of _pendingFiles) existing.add(f.path);
const newFiles = []; const newFiles = [];
for (const p of files) { for (const p of files) {
if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { if (existing.has(p)) continue;
const name = p.split('\\').pop().split('/').pop(); existing.add(p);
newFiles.push({ path: p, name, size: null }); const name = p.split('\\').pop().split('/').pop();
} newFiles.push({ path: p, name, size: null });
} }
if (newFiles.length > 0) { if (newFiles.length > 0) {
const newPaths = new Set(newFiles.map(f => f.path)); const newPaths = new Set(newFiles.map(f => f.path));
@ -646,12 +649,18 @@ async function pickFolder() {
} }
function 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 = []; const newFiles = [];
for (const p of paths) { for (const p of paths) {
if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { if (existing.has(p)) continue;
const name = p.split('\\').pop().split('/').pop(); existing.add(p);
newFiles.push({ path: p, name, size: null }); const name = p.split('\\').pop().split('/').pop();
} newFiles.push({ path: p, name, size: null });
} }
if (newFiles.length > 0) { if (newFiles.length > 0) {
_pendingFiles.push(...newFiles); _pendingFiles.push(...newFiles);
@ -687,13 +696,26 @@ function updateStartButton() {
btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection); btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection);
} }
const _UPLOAD_SELECTION_STATUSES = new Set(['done', 'error', 'aborted', 'skipped']);
const _ABORT_SELECTION_STATUSES = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']);
function updateQueueActionButtons() { function updateQueueActionButtons() {
updateStartButton(); updateStartButton();
const hasSelection = selectedJobIds.size > 0; const hasSelection = selectedJobIds.size > 0;
const hasUploadSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['done', 'error', 'aborted', 'skipped'].includes(job.status)); // Single pass over the (usually small) selection set instead of three O(n)
const hasAbortSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)); // scans over the entire queue. For 1000 jobs × 3 scans this drops the
const hasStartableSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status)); // selection-change cost from ~3000 checks to |selection|.
let hasUploadSelection = false, hasAbortSelection = false, hasStartableSelection = false;
for (const id of selectedJobIds) {
const job = _jobIndexById.get(id);
if (!job) continue;
const s = job.status;
if (!hasUploadSelection && _UPLOAD_SELECTION_STATUSES.has(s)) hasUploadSelection = true;
if (!hasAbortSelection && _ABORT_SELECTION_STATUSES.has(s)) hasAbortSelection = true;
if (!hasStartableSelection && isStartableQueueStatus(s)) hasStartableSelection = true;
if (hasUploadSelection && hasAbortSelection && hasStartableSelection) break;
}
const hasMovableSelection = hasSelection && !uploading; const hasMovableSelection = hasSelection && !uploading;
const startSelectedBtn = document.getElementById('startSelectedBtn'); const startSelectedBtn = document.getElementById('startSelectedBtn');
@ -1990,9 +2012,16 @@ function maybeAddSessionFile(job) {
function applySummaryResults(summary) { function applySummaryResults(summary) {
const files = Array.isArray(summary?.files) ? summary.files : []; const files = Array.isArray(summary?.files) ? summary.files : [];
// Build a (fileName + hoster) → job map once so the per-result lookup is O(1)
// instead of O(|queueJobs|). Big batches (hundreds of files × multiple hosters)
// otherwise become O(n²).
const jobByKey = new Map();
for (const j of queueJobs) {
jobByKey.set(`${j.fileName}\u0001${j.hoster}`, j);
}
for (const file of files) { for (const file of files) {
for (const result of file.results || []) { for (const result of file.results || []) {
const job = queueJobs.find((entry) => entry.fileName === file.name && entry.hoster === result.hoster); const job = jobByKey.get(`${file.name}\u0001${result.hoster}`);
if (!job) continue; if (!job) continue;
if (result.status === 'done') { if (result.status === 'done') {
job.status = 'done'; job.status = 'done';