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;
// 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 ---

View File

@ -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';