From 879f6ade0e07526c6b80f9aedf3ad21a12424927 Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 19 Apr 2026 13:38:39 +0200 Subject: [PATCH] perf: O(1) lookups for selection buttons, applySummaryResults, file-drop dedup; batched upload log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- main.js | 113 +++++++++++++++++++++++++++++------------------- renderer/app.js | 53 ++++++++++++++++++----- 2 files changed, 109 insertions(+), 57 deletions(-) diff --git a/main.js b/main.js index 4a4feaf..fd62423 100644 --- a/main.js +++ b/main.js @@ -159,54 +159,70 @@ function getSafeDesktopDir() { } 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) { const now = new Date(); 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 line = `${dateStr}|${hoster}|${link}||${fileName}|\n`; - - const tryWrite = (p) => { - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.appendFileSync(p, line, 'utf-8'); - }; - - 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}`); + _uploadLogBuffer.push(`${dateStr}|${hoster}|${link}||${fileName}|\n`); + if (!_uploadLogFlushTimer) { + _uploadLogFlushTimer = setTimeout(() => { + _uploadLogFlushTimer = null; + _flushUploadLog(); + }, 500); } } @@ -751,13 +767,20 @@ app.on('before-quit', () => { destroyCaptureWindow(); } catch {} destroyDropTargetWindow(); - // Flush pending debug-log buffer synchronously so no lines are lost. + // Flush pending log buffers synchronously so no lines are lost. try { if (_debugLogBuffer.length) { fs.appendFileSync(getDebugLogPath(), _debugLogBuffer.join(''), 'utf-8'); _debugLogBuffer.length = 0; } } catch {} + try { + if (_uploadLogBuffer.length) { + const target = _resolveUploadLogTarget(); + if (target) fs.appendFileSync(target.path, _uploadLogBuffer.join(''), 'utf-8'); + _uploadLogBuffer.length = 0; + } + } catch {} }); // --- IPC Handlers --- diff --git a/renderer/app.js b/renderer/app.js index 6c7dfaf..e5cd0fc 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -130,12 +130,15 @@ async function init() { if (fmHosters.length > 0) { // Pre-selected hosters: set them as active selection and add directly to queue 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 = []; for (const p of files) { - if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { - const name = p.split('\\').pop().split('/').pop(); - newFiles.push({ path: p, name, size: null }); - } + if (existing.has(p)) continue; + existing.add(p); + const name = p.split('\\').pop().split('/').pop(); + newFiles.push({ path: p, name, size: null }); } if (newFiles.length > 0) { const newPaths = new Set(newFiles.map(f => f.path)); @@ -646,12 +649,18 @@ async function pickFolder() { } 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 (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { - const name = p.split('\\').pop().split('/').pop(); - newFiles.push({ path: p, name, size: null }); - } + if (existing.has(p)) continue; + existing.add(p); + const name = p.split('\\').pop().split('/').pop(); + newFiles.push({ path: p, name, size: null }); } if (newFiles.length > 0) { _pendingFiles.push(...newFiles); @@ -687,13 +696,26 @@ function updateStartButton() { 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() { updateStartButton(); const hasSelection = selectedJobIds.size > 0; - const hasUploadSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['done', 'error', 'aborted', 'skipped'].includes(job.status)); - const hasAbortSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)); - const hasStartableSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status)); + // Single pass over the (usually small) selection set instead of three O(n) + // scans over the entire queue. For 1000 jobs × 3 scans this drops the + // 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 startSelectedBtn = document.getElementById('startSelectedBtn'); @@ -1990,9 +2012,16 @@ function maybeAddSessionFile(job) { function applySummaryResults(summary) { 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 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 (result.status === 'done') { job.status = 'done';