From cfe73951044f83e9f022881bcd325de28a1c0e44 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:46:28 +0200 Subject: [PATCH] feat: enforce automatic queue admission --- renderer/app.js | 400 +++++++++++++++++++++++++++------ tests/startup-renderer.test.js | 355 ++++++++++++++++++++++++++++- 2 files changed, 684 insertions(+), 71 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index 6c6acda..591168d 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -63,6 +63,7 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; +let automationRuntimeStatus = Object.freeze({}); let managedOnlineBackups = []; let managedOnlineBackupsAuthoritative = false; let managedOnlineBackupMutationGeneration = 0; @@ -414,58 +415,307 @@ function resolveFolderMonitorQueueAction({ autoStart, uploading: isUploading, he return isHealthCheckRunning ? 'queue' : 'start'; } -function handleFolderMonitorFiles(files) { - window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); - const fm = config.globalSettings && config.globalSettings.folderMonitor; - const fmHosters = fm && Array.isArray(fm.hosters) && fm.hosters.length > 0 ? fm.hosters : []; +function freezeAutomationValue(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + for (const nested of Object.values(value)) freezeAutomationValue(nested); + return Object.freeze(value); +} - if (fmHosters.length === 0) { - addPathsToQueue(files, { folderMonitorAutoStart: !!fm.autoStart }); - return; +function normalizeAutomationPath(value) { + return String(value || '').replace(/\\/g, '/').toLowerCase(); +} + +function normalizeAutomationCandidate(value, index) { + const source = value && typeof value === 'object' ? value : { path: value }; + const filePath = String(source.path || '').trim(); + return { + ...source, + path: filePath, + name: String(source.name || filePath.split(/[\\/]/).pop() || ''), + size: Number.isFinite(Number(source.size)) ? Number(source.size) : null, + mtimeMs: Number(source.mtimeMs) || index, + filterMatched: source.filterMatched !== false && source.allowed !== false && source.classification?.allowed !== false + }; +} + +function flattenAutomationHistoryRows(history) { + const rows = []; + for (const entry of Array.isArray(history) ? history : []) { + if (!Array.isArray(entry?.files)) { + rows.push(entry); + continue; + } + for (const file of entry.files) { + rows.push({ + path: file?.path || file?.file || '', + fileName: file?.fileName || file?.filename || file?.name || '' + }); + } } + return rows; +} - selectedUploadHosters = fmHosters.slice(); - const existing = new Set(); - for (const file of selectedFiles) existing.add(file.path); - for (const file of _pendingFiles) existing.add(file.path); - const newFiles = []; - for (const filePath of files) { - if (existing.has(filePath)) continue; - existing.add(filePath); - const name = filePath.split('\\').pop().split('/').pop(); - newFiles.push({ path: filePath, name, size: null }); +function automationSettings() { + return window.AutomationControl.normalizeAutomationSettings(config.globalSettings?.folderMonitor || {}); +} + +function applyAutomationRuntimeStatus(value) { + automationRuntimeStatus = freezeAutomationValue({ ...(value || {}) }); + return automationRuntimeStatus; +} + +async function refreshAutomationRuntimeStatus() { + if (typeof window.api.automationGetStatus !== 'function') return automationRuntimeStatus; + return applyAutomationRuntimeStatus(await window.api.automationGetStatus()); +} + +async function isAutomationPaused() { + try { + const status = await refreshAutomationRuntimeStatus(); + return status.paused === true || config.globalSettings?.folderMonitor?.paused === true; + } catch { + return true; } - if (newFiles.length === 0) return; +} - const newPaths = new Set(newFiles.map(file => file.path)); - clearDedupKeysForPaths(newPaths); - selectedFiles.push(...newFiles); - buildQueuePreview(); - updateUploadView(); - const action = resolveFolderMonitorQueueAction({ - autoStart: fm.autoStart, +function createAutomationStatusSnapshot() { + const folderSettings = config.globalSettings?.folderMonitor || {}; + const normalized = window.AutomationControl.normalizeAutomationSettings(folderSettings); + const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs); + const availableSlots = normalized.queueLimitJobs === 0 ? null : Math.max(0, normalized.queueLimitJobs - currentJobCount); + const telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry); + const snapshot = { + ...automationRuntimeStatus, + enabled: folderSettings.enabled === true, + folderPath: String(folderSettings.folderPath || automationRuntimeStatus.folderPath || ''), + paused: automationRuntimeStatus.paused === true || normalized.paused, + pausedAt: automationRuntimeStatus.pausedAt ?? normalized.pausedAt, + queueLimitJobs: normalized.queueLimitJobs, + currentJobCount, + availableSlots, + queueLimited: normalized.queueLimitJobs !== 0 && availableSlots === 0, + telemetry + }; + snapshot.state = window.AutomationControl.deriveAutomationState(snapshot); + return freezeAutomationValue(snapshot); +} + +async function evaluateAutomationCandidates(files, options = {}) { + const source = Array.isArray(files) ? files : []; + const candidates = source.map(normalizeAutomationCandidate); + const matched = candidates.filter(candidate => candidate.path && candidate.filterMatched); + const folderSettings = config.globalSettings?.folderMonitor || {}; + const selectedHosters = Array.from(new Set((Array.isArray(options.selectedHosters) ? options.selectedHosters : folderSettings.hosters || []) + .map(value => String(value || '').trim()) + .filter(Boolean))); + const [history, uploadLog] = await Promise.all([ + window.api.getHistory(), + window.api.readOwnUploadLog() + ]); + const processed = window.AutomationControl.classifyProcessedCandidates({ + candidates: matched, + queuePaths: [...queueJobs.map(job => job.file), ...selectedFiles.map(file => file.path)], + historyRows: flattenAutomationHistoryRows(history), + uploadLogRows: uploadLog + }); + const processedPaths = new Set(processed.processedPaths.map(normalizeAutomationPath)); + const unprocessed = matched.filter(candidate => !processedPaths.has(normalizeAutomationPath(candidate.path))); + const inspection = await window.api.inspectImportFiles(unprocessed, []); + const metadata = new Map(unprocessed.map(candidate => [normalizeAutomationPath(candidate.path), candidate])); + const accepted = (Array.isArray(inspection?.accepted) ? inspection.accepted : []).map(file => ({ + ...(metadata.get(normalizeAutomationPath(file?.path)) || {}), + ...file + })); + const plannedCandidates = accepted.map(file => { + const eligibleHosters = window.ImportPreflight.getEligibleImportHosters(file, selectedHosters, hosterSettings); + return { + path: file.path, + name: file.name, + size: file.size, + mtimeMs: file.mtimeMs, + eligibleHosters, + eligibleJobCount: eligibleHosters.length + }; + }); + const normalizedSettings = automationSettings(); + const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs); + const availableSlots = normalizedSettings.queueLimitJobs === 0 ? null : Math.max(0, normalizedSettings.queueLimitJobs - currentJobCount); + const admission = window.AutomationControl.planAtomicAdmissions({ + candidates: plannedCandidates, + currentJobCount, + queueLimitJobs: normalizedSettings.queueLimitJobs + }); + const admittedPaths = new Set(admission.admittedPaths.map(normalizeAutomationPath)); + const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath)); + const admittedFiles = plannedCandidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path))); + const deferredFiles = plannedCandidates.filter(candidate => deferredPaths.has(normalizeAutomationPath(candidate.path))); + const resultingJobs = plannedCandidates.reduce((total, candidate) => total + candidate.eligibleJobCount, 0); + const summary = { + found: candidates.length, + filterMatched: matched.length, + alreadyProcessed: processed.processedPaths.length, + unavailable: Number(inspection?.unavailableCount) || 0, + sizeLimitedJobs: plannedCandidates.length * selectedHosters.length - resultingJobs, + acceptedFiles: plannedCandidates.length, + selectedTargets: selectedHosters.length, + resultingJobs, + availableSlots, + deferredFiles: deferredFiles.length + }; + return freezeAutomationValue({ + dryRun: options.dryRun === true, + trigger: String(options.trigger || 'watcher'), + queueJobCount: currentJobCount, + queueLimitJobs: normalizedSettings.queueLimitJobs, + selectedHosters, + candidates: plannedCandidates, + admittedFiles, + deferredFiles, + summary, + telemetryDelta: { + detected: summary.found, + queued: admittedFiles.length, + skipped: summary.alreadyProcessed + summary.unavailable, + deferred: deferredFiles.length, + lastDetectedName: plannedCandidates.at(-1)?.name || '' + } + }); +} + +function createAutomationPreviewJob(file, hoster) { + return { + id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + file: file.path, + fileName: file.name, + hoster, + status: 'preview', + bytesUploaded: 0, + bytesTotal: file.size || 0, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: null, + result: null, + attempt: 0, + maxAttempts: 0, + link: '' + }; +} + +async function persistAutomationTelemetry(delta) { + const globalSettings = config.globalSettings || {}; + const folderSettings = globalSettings.folderMonitor || {}; + const telemetry = window.AutomationControl.applyTelemetryDelta(folderSettings.telemetry, delta); + const nextSettings = { + ...globalSettings, + folderMonitor: { ...folderSettings, telemetry } + }; + config.globalSettings = nextSettings; + try { + await saveGlobalSettingsTracked(nextSettings); + } catch {} + return telemetry; +} + +async function applyAutomationEvaluation(evaluation) { + if (!evaluation || evaluation.dryRun === true) { + return freezeAutomationValue({ admittedFiles: [], deferredFiles: [], paused: false, dryRun: true }); + } + const manualPreview = evaluation.trigger === 'manual-host' || evaluation.trigger === 'manual-import'; + const paused = await isAutomationPaused(); + if (paused && !manualPreview) { + return freezeAutomationValue({ admittedFiles: [], deferredFiles: evaluation.candidates || [], paused: true, dryRun: false }); + } + const currentPaths = new Set([...queueJobs.map(job => job.file), ...selectedFiles.map(file => file.path)].map(normalizeAutomationPath)); + const candidates = evaluation.candidates.filter(candidate => !currentPaths.has(normalizeAutomationPath(candidate.path))); + if (evaluation.selectedHosters.length === 0) { + if (candidates.length > 0) { + _pendingFiles.push(...candidates.map(file => ({ path: file.path, name: file.name, size: file.size, mtimeMs: file.mtimeMs }))); + mergePendingImportInspection({ + candidateCount: evaluation.summary.filterMatched, + duplicateCount: evaluation.summary.alreadyProcessed, + unavailableCount: evaluation.summary.unavailable, + accepted: candidates + }); + markPendingFolderMonitorFiles(candidates, config.globalSettings?.folderMonitor?.autoStart === true); + if (document.getElementById('hosterModal')?.style.display === 'flex') renderHosterModal(); + else openHosterModal(); + } + return freezeAutomationValue({ admittedFiles: [], deferredFiles: [], paused, awaitingHostSelection: candidates.length > 0 }); + } + const normalizedSettings = automationSettings(); + const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs); + const admission = window.AutomationControl.planAtomicAdmissions({ + candidates, + currentJobCount, + queueLimitJobs: normalizedSettings.queueLimitJobs + }); + const admittedPaths = new Set(admission.admittedPaths.map(normalizeAutomationPath)); + const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath)); + const admittedFiles = candidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path))); + const deferredFiles = candidates.filter(candidate => deferredPaths.has(normalizeAutomationPath(candidate.path))); + if (admittedFiles.length === 0) { + return freezeAutomationValue({ admittedFiles: [], deferredFiles, paused, dryRun: false }); + } + const filePaths = new Set(admittedFiles.map(file => file.path)); + const newJobs = admittedFiles.flatMap(file => file.eligibleHosters.map(hoster => createAutomationPreviewJob(file, hoster))); + selectedUploadHosters = evaluation.selectedHosters.slice(); + clearDedupKeysForPaths(filePaths); + selectedFiles.push(...admittedFiles.map(file => ({ path: file.path, name: file.name, size: file.size }))); + queueJobs.push(...newJobs); + rebuildJobIndex(); + _queueStatsCache = null; + renderHosterSummary(); + renderQueueTable(); + updateUploadView({ rebuildPreview: false }); + updateStatusBar(); + updateStatsPanel(); + persistQueueStateSoon(true); + await persistAutomationTelemetry({ + detected: evaluation.summary.found, + queued: admittedFiles.length, + skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable, + deferred: deferredFiles.length, + lastDetectedName: admittedFiles.at(-1)?.name || '' + }); + const action = paused && manualPreview ? 'queue' : resolveFolderMonitorQueueAction({ + autoStart: config.globalSettings?.folderMonitor?.autoStart === true, uploading, healthCheckRunning }); - if (action === 'start') { - startUpload(); - return; + if (action === 'inject' && !(await isAutomationPaused())) { + const cleanupPreparation = prepareSourceCleanup(newJobs); + try { + const result = await window.api.addJobsToBatch({ + jobs: newJobs.map(serializeUploadJob), + sourceCleanupGroups: cleanupPreparation.groups + }); + if (!result?.error) newJobs.forEach(job => { job.status = 'queued'; }); + _markSkippedJobs(result); + if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); + } catch {} + renderQueueTable(); + persistQueueStateSoon(true); + } else if (action === 'start') { + await startUpload(); } - if (action !== 'inject') return; + return freezeAutomationValue({ admittedFiles, deferredFiles, paused, dryRun: false, plannedJobs: admission.plannedJobs }); +} - const newJobs = queueJobs.filter(job => job.status === 'preview' && newPaths.has(job.file)); - if (newJobs.length === 0) return; - const cleanupPreparation = prepareSourceCleanup(newJobs); - newJobs.forEach(job => { job.status = 'queued'; }); - renderQueueTable(); - window.api.addJobsToBatch({ - jobs: newJobs.map(serializeUploadJob), - sourceCleanupGroups: cleanupPreparation.groups - }).then(result => { - _markSkippedJobs(result); - if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); - }).catch(() => {}); - persistQueueStateSoon(true); +async function runFolderMonitorTestScan() { + const result = await window.api.folderMonitorTestScan(); + return evaluateAutomationCandidates(result?.files || [], { dryRun: true, trigger: result?.trigger || 'test' }); +} + +window.evaluateAutomationCandidates = evaluateAutomationCandidates; +window.applyAutomationEvaluation = applyAutomationEvaluation; +window.createAutomationStatusSnapshot = createAutomationStatusSnapshot; +window.runFolderMonitorTestScan = runFolderMonitorTestScan; + +async function handleFolderMonitorFiles(files) { + window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); + const evaluation = await evaluateAutomationCandidates(files, { dryRun: false, trigger: 'watcher' }); + return applyAutomationEvaluation(evaluation); } // --- Init --- @@ -476,6 +726,9 @@ async function init() { await showAppAlert(error.message || String(error), 'Zugangsdaten gesperrt'); throw error; } + try { + await refreshAutomationRuntimeStatus(); + } catch {} setUiLanguage(config.globalSettings?.language); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); @@ -561,7 +814,15 @@ async function init() { } }); - window.api.onFolderMonitorNewFiles(handleFolderMonitorFiles); + window.api.onFolderMonitorNewFiles(files => { + handleFolderMonitorFiles(files).catch(error => window.api.debugLog(`folder-monitor renderer evaluation failed: ${error.message || String(error)}`)); + }); + if (typeof window.api.onAutomationStatus === 'function') { + window.api.onAutomationStatus(status => { + applyAutomationRuntimeStatus(status); + updateQueueActionButtons(); + }); + } // Account switched notification window.api.onAccountSwitched((data) => { @@ -1167,51 +1428,54 @@ function closeHosterModal() { if (modal) modal.style.display = 'none'; } -function applyHosterSelection() { +async function applyHosterSelection() { if (isImportConfirmationBlocked()) return false; selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) .map(input => input.dataset.hosterModal); - const admittedFiles = _pendingFiles.filter(file => window.ImportPreflight + const pendingFiles = _pendingFiles.slice(); + const automationFiles = pendingFiles.filter(file => _pendingFolderMonitorAutoStart.has(file.path)); + const regularFiles = pendingFiles.filter(file => !_pendingFolderMonitorAutoStart.has(file.path)); + const admittedFiles = regularFiles.filter(file => window.ImportPreflight .getEligibleImportHosters(file, selectedUploadHosters, hosterSettings).length > 0); const pendingPaths = new Set(admittedFiles.map(f => f.path)); - const pathsToInject = new Set(admittedFiles - .filter(file => !_pendingFolderMonitorAutoStart.has(file.path) || _pendingFolderMonitorAutoStart.get(file.path)) - .map(file => file.path)); - const shouldAutoStart = !uploading && admittedFiles.some(file => _pendingFolderMonitorAutoStart.get(file.path) === true); if (admittedFiles.length > 0) { selectedFiles.push(...admittedFiles); } _pendingFiles = []; clearDedupKeysForPaths(pendingPaths); renderHosterSummary(); - - // During an active upload, build preview jobs for the new files and inject - // them into the running batch immediately (otherwise they'd be lost on - // handleBatchDone via syncSelectedFilesFromQueue) if (pendingPaths.size > 0) buildQueuePreview(); - if (uploading && pathsToInject.size > 0) { - const newJobs = queueJobs.filter(j => j.status === 'preview' && pathsToInject.has(j.file)); + if (uploading && pendingPaths.size > 0 && !(await isAutomationPaused())) { + const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file)); if (newJobs.length > 0) { const cleanupPreparation = prepareSourceCleanup(newJobs); - newJobs.forEach(j => { j.status = 'queued'; }); - renderQueueTable(); - window.api.addJobsToBatch({ - jobs: newJobs.map(serializeUploadJob), - sourceCleanupGroups: cleanupPreparation.groups - }).then(result => { + try { + const result = await window.api.addJobsToBatch({ + jobs: newJobs.map(serializeUploadJob), + sourceCleanupGroups: cleanupPreparation.groups + }); + if (!result?.error) newJobs.forEach(job => { job.status = 'queued'; }); _markSkippedJobs(result); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); - }).catch(() => {}); + } catch {} + renderQueueTable(); persistQueueStateSoon(true); } } - updateUploadView(); - persistQueueStateSoon(true); // immediate persist after adding files + persistQueueStateSoon(true); + const selectedHosters = selectedUploadHosters.slice(); _pendingFolderMonitorAutoStart.clear(); _pendingImportInspection = null; document.getElementById('hosterModal').style.display = 'none'; - if (shouldAutoStart) startUpload(); + if (automationFiles.length > 0) { + const evaluation = await evaluateAutomationCandidates(automationFiles, { + dryRun: false, + trigger: 'manual-host', + selectedHosters + }); + await applyAutomationEvaluation(evaluation); + } return true; } @@ -1626,7 +1890,7 @@ function addPathsToQueue(paths, options) { return coordinateImportEntries(paths, options); } -function updateUploadView() { +function updateUploadView(options = {}) { const dropZone = document.getElementById('dropZone'); const queueShell = document.getElementById('queueShell'); const queueActions = document.getElementById('queueActions'); @@ -1639,7 +1903,7 @@ function updateUploadView() { dropZone.style.display = 'none'; queueShell.style.display = 'flex'; queueActions.style.display = 'flex'; - if (!uploading && selectedFiles.length > 0) { + if (options.rebuildPreview !== false && !uploading && selectedFiles.length > 0) { buildQueuePreview(); } } @@ -3342,6 +3606,7 @@ function serializeUploadJob(job) { async function startUpload(opts) { if (uploading) return; + if (await isAutomationPaused()) return false; if (!(opts && opts._restoredAutoStart)) cancelStartupQueueAutoStart(); if (!(opts && opts._autoRetry)) _cancelAutoRetry(true); else _cancelAutoRetry(false); @@ -3420,6 +3685,7 @@ function _markSkippedJobs(result) { } async function startSelectedUpload(explicitJobs) { + if (await isAutomationPaused()) return false; const scopedJobs = Array.isArray(explicitJobs) ? explicitJobs : _getVisibleSelectedQueueJobs(); if (uploading) { _hydrateMissingJobSizes(); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index f618238..6ce72ba 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -64,6 +64,16 @@ test('Windows compositor paints the full hidden surface with an RDP session envi const { contextBridge } = require('electron'); const managedOnlineBackupProbeCalls = []; const folderMonitorProbeCalls = []; +let automationProbe = { + history: [], + uploadLog: [], + paused: false, + dryScan: { files: [], reachable: true, trigger: 'test' }, + readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, + mutationCalls: [], + logs: [], + savedSettings: [] +}; const managedOnlineBackupIds = { a: 'AAAAAAAAAAAAAAAAAAAAAA', b: 'AQEBAQEBAQEBAQEBAQEBAQ', @@ -144,14 +154,80 @@ contextBridge.exposeInMainWorld('api', { pending.resolve({ ok: true, removedId: pending.id, notFound: false }); }, getManagedOnlineBackupProbeCalls() { return managedOnlineBackupProbeCalls; }, - debugLog() {}, + configureAutomationProbe(value = {}) { + automationProbe = { + history: Array.isArray(value.history) ? value.history : [], + uploadLog: Array.isArray(value.uploadLog) ? value.uploadLog : [], + paused: value.paused === true, + dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' }, + readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, + mutationCalls: [], + logs: [], + savedSettings: [] + }; + }, + getAutomationProbeState() { + return { + readCalls: { ...automationProbe.readCalls }, + mutationCalls: automationProbe.mutationCalls.map(value => [...value]), + logs: [...automationProbe.logs], + savedSettings: automationProbe.savedSettings.map(value => JSON.parse(JSON.stringify(value))) + }; + }, + inspectImportFiles(entries) { + automationProbe.readCalls.inspect++; + const candidates = Array.isArray(entries) ? entries : []; + const unavailable = candidates.filter(entry => entry?.unavailable).map(entry => ({ ...entry, reason: 'unreadable' })); + const accepted = candidates.filter(entry => !entry?.unavailable).map(entry => ({ ...entry })); + return Promise.resolve({ + candidateCount: candidates.length, + duplicateCount: 0, + unavailableCount: unavailable.length, + acceptedCount: accepted.length, + accepted, + duplicates: [], + unavailable + }); + }, + getHistory() { + automationProbe.readCalls.history++; + return Promise.resolve(automationProbe.history); + }, + readOwnUploadLog() { + automationProbe.readCalls.uploadLog++; + return Promise.resolve(automationProbe.uploadLog); + }, + automationGetStatus() { + automationProbe.readCalls.status++; + return Promise.resolve({ paused: automationProbe.paused }); + }, + folderMonitorTestScan() { + automationProbe.readCalls.testScan++; + return Promise.resolve(automationProbe.dryScan); + }, + folderMonitorReconcile() { + automationProbe.readCalls.reconcile++; + return Promise.resolve(automationProbe.dryScan); + }, + debugLog(value) { automationProbe.logs.push(String(value)); }, + saveGlobalSettings(value) { + automationProbe.savedSettings.push(value); + automationProbe.mutationCalls.push(['settings']); + return Promise.resolve(true); + }, savePendingQueue(payload) { folderMonitorProbeCalls.push(['save', payload?.queueJobs?.length || 0]); + automationProbe.mutationCalls.push(['save', payload?.queueJobs?.length || 0]); return Promise.resolve(true); }, addJobsToBatch(payload) { folderMonitorProbeCalls.push(['inject', payload?.jobs?.length || 0]); - return Promise.resolve({}); + automationProbe.mutationCalls.push(['inject', payload?.jobs?.length || 0]); + return Promise.resolve({ added: payload?.jobs?.length || 0 }); + }, + startUpload(payload) { + automationProbe.mutationCalls.push(['start', payload?.jobs?.length || 0]); + return Promise.resolve({ started: true }); }, getFolderMonitorProbeCalls() { return folderMonitorProbeCalls; } }); @@ -310,14 +386,14 @@ contextBridge.exposeInMainWorld('api', { rebuildJobIndex(); }; resetQueue(false); - handleFolderMonitorFiles(['C:\\\\folder-monitor-queue-only.mkv']); + await handleFolderMonitorFiles(['C:\\\\folder-monitor-queue-only.mkv']); await new Promise(resolve => setTimeout(resolve, 0)); const queueOnly = { statuses: queueJobs.map(job => job.status), injectCalls: window.api.getFolderMonitorProbeCalls().filter(call => call[0] === 'inject').length }; resetQueue(true); - handleFolderMonitorFiles(['C:\\\\folder-monitor-inject.mkv']); + await handleFolderMonitorFiles(['C:\\\\folder-monitor-inject.mkv']); await new Promise(resolve => setTimeout(resolve, 0)); const autoStart = { statuses: queueJobs.map(job => job.status), @@ -371,6 +447,212 @@ contextBridge.exposeInMainWorld('api', { const sameBasenameResult = Object.fromEntries(queueJobs.map(job => [job.id, { status: job.status, code: job.result?.file_code || null }])); return { germanTooltips, englishTooltips, gridScope, actions, queueOnly, autoStart, manualQueueOnly, manualAutoStartRunning, manualAutoStartIdle, sameBasenameResult }; })()`; + const automationPipelineScript = `(async () => { + const clone = value => JSON.parse(JSON.stringify(value)); + const captureMutationFingerprint = async () => { + const api = await window.api.getAutomationProbeState(); + return { + queueJobs: clone(queueJobs), + selectedFiles: clone(selectedFiles), + counters: { sessionDone: _sessionDoneCount, sessionError: _sessionErrorCount }, + pendingFiles: clone(_pendingFiles), + pendingInspection: clone(_pendingImportInspection), + pendingInspections: _pendingImportInspections, + pendingAutoStart: clone([..._pendingFolderMonitorAutoStart]), + config: clone(config), + monitorSettings: clone(config.globalSettings?.folderMonitor || {}), + api: { mutationCalls: api.mutationCalls, logs: api.logs, savedSettings: api.savedSettings } + }; + }; + const hosters = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; + const candidates = Array.from({ length: 500 }, (_, index) => ({ + path: 'C:\\\\watch\\\\candidate-' + String(index).padStart(3, '0') + '.mkv', + name: 'candidate-' + String(index).padStart(3, '0') + '.mkv', + size: index >= 55 && index < 75 ? 2 * 1024 * 1024 : 512 * 1024, + mtimeMs: index, + filterMatched: index < 430, + unavailable: index >= 50 && index < 55 + })); + const history = [{ + id: 'history', + files: candidates.slice(0, 25).map(file => ({ path: file.path, name: file.name, results: [{ hoster: hosters[0], status: 'done' }] })) + }]; + const uploadLog = candidates.slice(25, 50).map(file => ({ fileName: file.name, hoster: hosters[0] })); + config = { + hosters: Object.fromEntries(HOSTERS.map(hoster => [hoster, []])), + hosterSettings: {}, + globalSettings: { + folderMonitor: { + enabled: true, + folderPath: 'C:\\\\watch', + hosters, + autoStart: false, + queueLimitJobs: 1500, + paused: false, + telemetry: { dateKey: '2026-08-26', detected: 7, queued: 3, skipped: 2, deferred: 1 } + } + } + }; + hosterSettings = { 'doodstream.com': { maxSizeMb: 1 } }; + selectedUploadHosters = []; + selectedFiles = [{ path: 'C:\\\\manual\\\\selected.mkv', name: 'selected.mkv', size: 1 }]; + queueJobs = Array.from({ length: 300 }, (_, index) => ({ + id: 'existing-' + index, + file: 'C:\\\\queue\\\\existing-' + index + '.mkv', + fileName: 'existing-' + index + '.mkv', + hoster: hosters[index % hosters.length], + status: 'queued', + bytesTotal: 1 + })); + _sessionDoneCount = 4; + _sessionErrorCount = 5; + _pendingFiles = [{ path: 'C:\\\\pending\\\\pending.mkv', name: 'pending.mkv', size: 1 }]; + _pendingImportInspection = { candidateCount: 1, accepted: clone(_pendingFiles) }; + _pendingImportInspections = 1; + _pendingFolderMonitorAutoStart.clear(); + _pendingFolderMonitorAutoStart.set('C:\\\\pending\\\\pending.mkv', true); + rebuildJobIndex(); + window.api.configureAutomationProbe({ history, uploadLog, paused: false }); + const before = await captureMutationFingerprint(); + const preview = await evaluateAutomationCandidates(candidates, { dryRun: true, trigger: 'test' }); + const after = await captureMutationFingerprint(); + const dryReads = (await window.api.getAutomationProbeState()).readCalls; + const dry = { + fingerprintEqual: JSON.stringify(after) === JSON.stringify(before), + summary: preview.summary, + frozen: Object.isFrozen(preview) && Object.isFrozen(preview.summary) && Object.isFrozen(preview.admittedFiles) && Object.isFrozen(preview.deferredFiles), + reads: dryReads + }; + window.api.configureAutomationProbe({ + dryScan: { files: [candidates[55]], reachable: true, trigger: 'test' }, + paused: false + }); + const manualTestBefore = await captureMutationFingerprint(); + const manualTestPreview = await runFolderMonitorTestScan(); + const manualTestAfter = await captureMutationFingerprint(); + const manualTestProbe = await window.api.getAutomationProbeState(); + const manualTest = { + fingerprintEqual: JSON.stringify(manualTestAfter) === JSON.stringify(manualTestBefore), + summary: manualTestPreview.summary, + reads: manualTestProbe.readCalls + }; + + const configureAtomicState = currentCount => { + config.globalSettings.folderMonitor = { + enabled: true, + folderPath: 'C:\\\\watch', + hosters, + autoStart: false, + queueLimitJobs: 15000, + paused: false, + telemetry: { dateKey: new Date().toLocaleDateString('en-CA'), detected: 0, queued: 0, skipped: 0, deferred: 0 } + }; + hosterSettings = { + 'doodstream.com': { maxSizeMb: 2 }, + 'voe.sx': { maxSizeMb: 2 } + }; + selectedUploadHosters = []; + selectedFiles = [{ path: 'C:\\\\manual\\\\unplanned.mkv', name: 'unplanned.mkv', size: 1 }]; + queueJobs = Array.from({ length: currentCount }, (_, index) => ({ + id: 'capacity-' + index, + file: 'C:\\\\capacity\\\\existing-' + index + '.mkv', + fileName: 'existing-' + index + '.mkv', + hoster: hosters[index % hosters.length], + status: 'queued', + bytesTotal: 1 + })); + _pendingFiles = []; + _pendingImportInspection = null; + _pendingImportInspections = 0; + _pendingFolderMonitorAutoStart.clear(); + uploading = false; + rebuildJobIndex(); + window.api.configureAutomationProbe({ paused: false }); + }; + const atomicCandidates = [ + { path: 'C:\\\\watch\\\\a.mkv', name: 'a.mkv', size: 1024 * 1024, mtimeMs: 1, filterMatched: true }, + { path: 'C:\\\\watch\\\\b.mkv', name: 'b.mkv', size: 3 * 1024 * 1024, mtimeMs: 2, filterMatched: true } + ]; + configureAtomicState(14998); + const atomicEvaluation = await evaluateAutomationCandidates(atomicCandidates, { dryRun: false, trigger: 'watcher' }); + const atomicResult = await applyAutomationEvaluation(atomicEvaluation); + const atomic = { + newQueueFiles: [...new Set(queueJobs.filter(job => job.file === atomicCandidates[0].path || job.file === atomicCandidates[1].path).map(job => job.fileName))], + admittedFiles: atomicResult.admittedFiles.map(file => file.name), + deferred: config.globalSettings.folderMonitor.telemetry.deferred, + queued: config.globalSettings.folderMonitor.telemetry.queued, + currentJobCount: window.AutomationControl.countAutomaticQueueJobs(queueJobs), + unplannedJobs: queueJobs.filter(job => job.fileName === 'unplanned.mkv').length + }; + const statusSnapshot = createAutomationStatusSnapshot(); + const status = { + state: statusSnapshot.state, + currentJobCount: statusSnapshot.currentJobCount, + availableSlots: statusSnapshot.availableSlots, + queueLimited: statusSnapshot.queueLimited, + frozen: Object.isFrozen(statusSnapshot) && Object.isFrozen(statusSnapshot.telemetry) + }; + + configureAtomicState(14994); + const staleEvaluation = await evaluateAutomationCandidates(atomicCandidates, { dryRun: false, trigger: 'watcher' }); + queueJobs.push(...Array.from({ length: 4 }, (_, index) => ({ + id: 'stale-' + index, + file: 'C:\\\\capacity\\\\stale-' + index + '.mkv', + fileName: 'stale-' + index + '.mkv', + hoster: hosters[index], + status: 'queued', + bytesTotal: 1 + }))); + rebuildJobIndex(); + const staleResult = await applyAutomationEvaluation(staleEvaluation); + const stale = { + plannedBeforeApply: staleEvaluation.admittedFiles.map(file => file.name), + admittedAfterApply: staleResult.admittedFiles.map(file => file.name), + newQueueFiles: [...new Set(queueJobs.filter(job => job.file === atomicCandidates[0].path || job.file === atomicCandidates[1].path).map(job => job.fileName))] + }; + + configureAtomicState(0); + config.globalSettings.folderMonitor.paused = true; + window.api.configureAutomationProbe({ paused: true }); + const pausedJob = { + id: 'paused-preview', + file: 'C:\\\\manual\\\\paused-preview.mkv', + fileName: 'paused-preview.mkv', + hoster: hosters[0], + status: 'preview', + bytesTotal: 1 + }; + queueJobs = [pausedJob]; + selectedFiles = [{ path: pausedJob.file, name: pausedJob.fileName, size: 1 }]; + selectedUploadHosters = [hosters[0]]; + rebuildJobIndex(); + await startUpload(); + uploading = true; + await startSelectedUpload([pausedJob]); + uploading = false; + const pausedAutomaticEvaluation = await evaluateAutomationCandidates([ + { path: 'C:\\\\watch\\\\paused-auto.mkv', name: 'paused-auto.mkv', size: 1, mtimeMs: 1, filterMatched: true } + ], { dryRun: false, trigger: 'watcher' }); + const pausedAutomaticResult = await applyAutomationEvaluation(pausedAutomaticEvaluation); + await coordinateImportEntries([ + { path: 'C:\\\\manual\\\\allowed-preview.mkv', name: 'allowed-preview.mkv', size: 1 } + ]); + const input = document.createElement('input'); + input.type = 'checkbox'; + input.dataset.hosterModal = hosters[0]; + input.checked = true; + document.getElementById('hosterModalList').replaceChildren(input); + await applyHosterSelection(); + const pausedProbe = await window.api.getAutomationProbeState(); + const paused = { + uploading, + statuses: Object.fromEntries(queueJobs.map(job => [job.fileName, job.status])), + automaticApplied: pausedAutomaticResult.admittedFiles.length, + startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, + injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length + }; + return { dry, manualTest, atomic, status, stale, paused }; + })()`; const onlineBackupBehaviorScript = `(async () => { const ids = { a: 'AAAAAAAAAAAAAAAAAAAAAA', @@ -577,6 +859,7 @@ app.whenReady().then(async () => { const onlineBackupBehavior = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupBehaviorScript)}); const settingsSearchBehavior = await window.webContents.executeJavaScript(${JSON.stringify(settingsSearchBehaviorScript)}); const folderMonitorBehavior = await window.webContents.executeJavaScript(${JSON.stringify(folderMonitorBehaviorScript)}); + const automationPipeline = await window.webContents.executeJavaScript(${JSON.stringify(automationPipelineScript)}); const onlineBackupLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupLayoutScript)}); window.setContentSize(760, Math.min(900, display.workAreaSize.height)); await new Promise(resolve => setTimeout(resolve, 50)); @@ -595,6 +878,7 @@ app.whenReady().then(async () => { appDialogBehavior, settingsSearchBehavior, folderMonitorBehavior, + automationPipeline, onlineBackupBehavior, onlineBackupLayout, onlineBackupNarrowLayout @@ -734,6 +1018,69 @@ app.whenReady().then(async () => { 'waiting-same-name': { status: 'preview', code: null } } }); + assert.deepEqual(result.automationPipeline.dry, { + fingerprintEqual: true, + summary: { + found: 500, + filterMatched: 430, + alreadyProcessed: 50, + unavailable: 5, + sizeLimitedJobs: 20, + acceptedFiles: 375, + selectedTargets: 4, + resultingJobs: 1480, + availableSlots: 1200, + deferredFiles: 70 + }, + frozen: true, + reads: { history: 1, uploadLog: 1, inspect: 1, status: 0, testScan: 0, reconcile: 0 } + }); + assert.deepEqual(result.automationPipeline.manualTest, { + fingerprintEqual: true, + summary: { + found: 1, + filterMatched: 1, + alreadyProcessed: 0, + unavailable: 0, + sizeLimitedJobs: 1, + acceptedFiles: 1, + selectedTargets: 4, + resultingJobs: 3, + availableSlots: 1200, + deferredFiles: 0 + }, + reads: { history: 1, uploadLog: 1, inspect: 1, status: 0, testScan: 1, reconcile: 0 } + }); + assert.deepEqual(result.automationPipeline.atomic, { + newQueueFiles: ['b.mkv'], + admittedFiles: ['b.mkv'], + deferred: 1, + queued: 1, + currentJobCount: 15000, + unplannedJobs: 0 + }); + assert.deepEqual(result.automationPipeline.status, { + state: 'queue-limited', + currentJobCount: 15000, + availableSlots: 0, + queueLimited: true, + frozen: true + }); + assert.deepEqual(result.automationPipeline.stale, { + plannedBeforeApply: ['a.mkv', 'b.mkv'], + admittedAfterApply: ['b.mkv'], + newQueueFiles: ['b.mkv'] + }); + assert.deepEqual(result.automationPipeline.paused, { + uploading: false, + statuses: { + 'paused-preview.mkv': 'preview', + 'allowed-preview.mkv': 'preview' + }, + automaticApplied: 0, + startCalls: 0, + injectCalls: 0 + }); assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']); assert.deepEqual(result.onlineBackupBehavior.initialWarning, { hidden: false,