diff --git a/lib/automation-control.js b/lib/automation-control.js index a9f8e49..996372e 100644 --- a/lib/automation-control.js +++ b/lib/automation-control.js @@ -25,13 +25,22 @@ return Math.max(1, Math.floor(number)); } + function normalizeReconcileInterval(value) { + return typeof value === 'number' && Number.isFinite(value) && allowedIntervals.has(value) ? value : 5; + } + + function normalizeCounter(value) { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) return 0; + return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(number)); + } + function normalizeAutomationSettings(value = {}) { const settings = asObject(value); const queueLimitJobs = normalizeQueueLimit(settings.queueLimitJobs); - const rawInterval = Number(settings.reconcileIntervalMinutes); return { queueLimitJobs, - reconcileIntervalMinutes: allowedIntervals.has(rawInterval) ? rawInterval : 5, + reconcileIntervalMinutes: normalizeReconcileInterval(settings.reconcileIntervalMinutes), paused: settings.paused === true, pausedAt: settings.paused === true && Number.isFinite(Number(settings.pausedAt)) ? Number(settings.pausedAt) : null }; @@ -100,10 +109,10 @@ ...emptyTelemetry(dateKey), ...telemetry, dateKey, - detected: Math.max(0, Number(telemetry.detected) || 0), - queued: Math.max(0, Number(telemetry.queued) || 0), - skipped: Math.max(0, Number(telemetry.skipped) || 0), - deferred: Math.max(0, Number(telemetry.deferred) || 0) + detected: normalizeCounter(telemetry.detected), + queued: normalizeCounter(telemetry.queued), + skipped: normalizeCounter(telemetry.skipped), + deferred: normalizeCounter(telemetry.deferred) }; } @@ -111,7 +120,7 @@ const changes = asObject(delta); const next = rollDailyTelemetry(value, nowMs); for (const key of ['detected', 'queued', 'skipped', 'deferred']) { - next[key] += Math.max(0, Number(changes[key]) || 0); + next[key] = Math.min(Number.MAX_SAFE_INTEGER, next[key] + normalizeCounter(changes[key])); } if (changes.lastDetectedName) { next.lastDetectedName = String(changes.lastDetectedName); diff --git a/lib/folder-monitor.js b/lib/folder-monitor.js index d88eeee..9b3a5a1 100644 --- a/lib/folder-monitor.js +++ b/lib/folder-monitor.js @@ -3,6 +3,7 @@ const fs = require('fs'); const path = require('path'); const chokidar = require('chokidar'); const { walkFolderAsync } = require('./file-discovery'); +const { normalizeAutomationSettings } = require('./automation-control'); class FolderMonitor extends EventEmitter { constructor({ @@ -44,6 +45,9 @@ class FolderMonitor extends EventEmitter { this._lastScanAt = null; this._lastScanTrigger = ''; this._lastError = ''; + this._startedAt = null; + this._nextReconcileAt = null; + this._reconcileIntervalMs = 5 * 60 * 1000; this._generation = 0; } @@ -55,6 +59,24 @@ class FolderMonitor extends EventEmitter { return this._start(settings, false); } + configure(settings) { + const folderPath = String(settings?.folderPath || '').trim(); + if (!folderPath) throw new Error('Kein Ordnerpfad angegeben'); + const reconcileIntervalMinutes = normalizeAutomationSettings(settings).reconcileIntervalMinutes; + const watcher = this._invalidateLifecycle(true); + if (watcher) { + try { + Promise.resolve(watcher.close()).catch(() => {}); + } catch {} + } + this._seenFiles = new Set(); + this._settings = { ...settings, folderPath, reconcileIntervalMinutes }; + this._reachable = null; + this._lastError = ''; + this._emitStatus(this._generation); + return { includesExisting: false, paused: true }; + } + stop() { this._deactivate({ clearSeen: true, paused: false }); } @@ -67,7 +89,9 @@ class FolderMonitor extends EventEmitter { scanning: this._scanning, folderPath: this._settings ? this._settings.folderPath : '', seenCount: this._seenFiles.size, + startedAt: this._startedAt, lastScanAt: this._lastScanAt, + nextReconcileAt: this._nextReconcileAt, lastScanTrigger: this._lastScanTrigger, error: this._lastError }); @@ -106,19 +130,22 @@ class FolderMonitor extends EventEmitter { if (changed) this._emitStatus(this._generation); } - async resume(settings = this._settings) { + async resume(settings = this._settings, options = {}) { if (!settings) throw new Error('Keine Ordnerkonfiguration vorhanden'); this._start(settings, true); + if (options.reconcile === false) return { reconciled: false }; return this.scan({ emitFiles: true, trigger: 'resume' }); } _start(settings, preserveSeen) { this._deactivate({ clearSeen: !preserveSeen, paused: false }); - this._settings = settings; + const reconcileIntervalMinutes = normalizeAutomationSettings(settings).reconcileIntervalMinutes; + this._settings = { ...settings, reconcileIntervalMinutes }; this._paused = false; this._reachable = null; this._lastError = ''; + settings = this._settings; const folderPath = String(settings.folderPath || '').trim(); if (!folderPath) throw new Error('Kein Ordnerpfad angegeben'); @@ -158,10 +185,12 @@ class FolderMonitor extends EventEmitter { this._emitStatus(generation); this._emitEvent('error', [error], generation); }); - const intervalMinutes = Number(settings.reconcileIntervalMinutes) || 5; + this._reconcileIntervalMs = reconcileIntervalMinutes * 60 * 1000; + this._startedAt = this._now(); + this._nextReconcileAt = this._startedAt + this._reconcileIntervalMs; this._reconcileTimer = this._setInterval( () => this._reconcile(generation).catch((error) => this._publishBackgroundError(error, generation)), - intervalMinutes * 60 * 1000 + this._reconcileIntervalMs ); this._emitStatus(generation); return { includesExisting: includeInitial }; @@ -194,6 +223,8 @@ class FolderMonitor extends EventEmitter { } this._batchSeenReservations.clear(); this._paused = paused; + this._startedAt = null; + this._nextReconcileAt = null; this._scanning = false; this._followUpRequested = false; this._followUpOptions = null; @@ -280,9 +311,8 @@ class FolderMonitor extends EventEmitter { try { const files = await this._discoverFiles(settings, generation); if (!this._isCurrent(generation)) return this._cancelledResult(trigger); - const emittedFiles = files.filter((file) => this._acceptPath(file.path)); - if (emittedFiles.length > 0) { - const listenerError = this._emitEvent('new-files', [emittedFiles.map((file) => file.path)], generation); + if (files.length > 0) { + const listenerError = this._emitEvent('new-files', [files], generation); if (listenerError) return this._finishProductiveError(listenerError, generation, trigger, true); } if (!this._isCurrent(generation)) return this._cancelledResult(trigger); @@ -325,7 +355,7 @@ class FolderMonitor extends EventEmitter { for (const descriptor of discovered) { if (!this._isCurrent(generation)) return []; if (!settings.recursive && this._isNestedPath(descriptor.path, settings.folderPath)) continue; - if (!this._classifyPath(descriptor.path, settings).allowed) continue; + const classification = this._classifyPath(descriptor.path, settings); let mtimeMs = 0; try { mtimeMs = Number((await this._stat(descriptor.path)).mtimeMs) || 0; @@ -335,7 +365,9 @@ class FolderMonitor extends EventEmitter { path: descriptor.path, name: descriptor.name || path.basename(descriptor.path), size: Number(descriptor.size) || 0, - mtimeMs + mtimeMs, + filterMatched: classification.allowed, + filterReason: classification.reason })); } return files; @@ -353,6 +385,7 @@ class FolderMonitor extends EventEmitter { async _reconcile(generation) { if (!this._acceptCallback(generation)) return; + this._nextReconcileAt = this._now() + this._reconcileIntervalMs; const trigger = this._reachable === false ? 'reconnect' : 'interval'; await this.scan({ emitFiles: true, trigger }); } diff --git a/lib/upload-audit.js b/lib/upload-audit.js index 7234d42..24a293f 100644 --- a/lib/upload-audit.js +++ b/lib/upload-audit.js @@ -105,8 +105,61 @@ function createUploadAuditWriter(options) { return createInternalLogWriter({ ...options, fileName: 'upload-audit.log' }); } +function createBufferedInternalLogFlusher(options) { + const source = options && typeof options === 'object' ? options : {}; + const buffer = source.buffer; + const writer = source.writer; + const schedule = typeof source.schedule === 'function' ? source.schedule : setImmediate; + const reportError = typeof source.reportError === 'function' ? source.reportError : () => {}; + let writing = false; + + if (!Array.isArray(buffer) || !writer || typeof writer.append !== 'function' || typeof writer.flushSync !== 'function') { + throw new TypeError('createBufferedInternalLogFlusher requires buffer and writer'); + } + + function restoreChunk(chunk) { + for (let end = chunk.length; end > 0;) { + const start = Math.max(0, end - 1024); + buffer.splice(0, 0, ...chunk.slice(start, end)); + end = start; + } + } + + async function flush(label) { + if (writing || buffer.length === 0) return null; + const chunk = buffer.splice(0); + writing = true; + let written = false; + try { + written = await writer.append(chunk.join(''), label); + } catch (error) { + reportError(label, error); + } finally { + writing = false; + } + if (!written) { + restoreChunk(chunk); + return false; + } + if (buffer.length > 0) { + try { + schedule(() => { void flush(label); }); + } catch (error) { + reportError(label, error); + } + } + return true; + } + + return { + flush, + flushSync: label => writer.flushSync(buffer, label), + isWriting: () => writing + }; +} + function getLogOpenDirectory(targetPath, fallbackDirectory, pathApi = nodePath) { return typeof targetPath === 'string' && targetPath ? pathApi.dirname(targetPath) : fallbackDirectory; } -module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, getLogOpenDirectory }; +module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, createBufferedInternalLogFlusher, getLogOpenDirectory }; diff --git a/main.js b/main.js index 6911d2e..a9e8a07 100644 --- a/main.js +++ b/main.js @@ -30,7 +30,7 @@ const RemoteServer = require('./lib/remote-server'); const { maybeRotateLogFile } = require('./lib/log-rotation'); const { hosterLogToFileEnabled } = require('./lib/log-policy'); const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log'); -const { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, getLogOpenDirectory } = require('./lib/upload-audit'); +const { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, createBufferedInternalLogFlusher, getLogOpenDirectory } = require('./lib/upload-audit'); const { selectOrphanTmps } = require('./lib/orphan-tmp'); const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle'); const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); @@ -39,6 +39,7 @@ const { createCollectors } = require('./lib/diagnostics-collectors'); const { createAgent } = require('./lib/diagnostics-agent'); const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report'); const { inspectImportEntries, inspectReadableImportPath } = require('./lib/import-preflight'); +const { normalizeAutomationSettings } = require('./lib/automation-control'); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); _eventLoopDelay.enable(); @@ -289,7 +290,9 @@ function restoreClosePreparation(attempt, clearRestart = true) { if (closeFolderMonitorWasRunning) { closeFolderMonitorWasRunning = false; const settings = configStore.load().globalSettings?.folderMonitor; - if (settings?.enabled && settings.folderPath) startFolderMonitor(settings); + if (settings?.enabled && settings.folderPath) { + void startFolderMonitor(settings).catch(error => debugLog(`folder-monitor close recovery failed: ${error.message}`)); + } } rejectPendingUpdate(new Error('Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten')); return true; @@ -558,21 +561,16 @@ function getRotLogPath() { } const _rotLogBuffer = []; let _rotLogFlushTimer = null; -let _rotLogWriting = false; +const _rotLogFlusher = createBufferedInternalLogFlusher({ + buffer: _rotLogBuffer, + writer: _rotLogWriter, + schedule: setImmediate, + reportError: (label, error) => debugLog(`${label} append failed: ${error.message}`) +}); function _flushRotLog() { - if (_rotLogWriting || _rotLogBuffer.length === 0) return; - const chunk = _rotLogBuffer.join(''); - _rotLogBuffer.length = 0; - _rotLogWriting = true; - _rotLogWriter.append(chunk, 'rot-log').then(written => { - _rotLogWriting = false; - if (!written) debugLog('rot-log append failed: no writable target'); - if (_rotLogBuffer.length) setImmediate(_flushRotLog); - }, error => { - _rotLogWriting = false; - debugLog(`rot-log append failed: ${error.message}`); - if (_rotLogBuffer.length) setImmediate(_flushRotLog); + void _rotLogFlusher.flush('rot-log').then(written => { + if (written === false) debugLog('rot-log append failed: no writable target'); }); } @@ -1686,13 +1684,8 @@ app.whenReady().then(async () => { try { const launchConfig = configStore.load(); const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor; - if (fm && fm.enabled && fm.folderPath && fm.paused !== true) { - startFolderMonitor(fm); - if (!fs.existsSync(fm.folderPath)) { - void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => { - debugLog(`folder-monitor startup scan failed: ${error.message}`); - }); - } + if (fm && fm.enabled && fm.folderPath) { + await startFolderMonitor(fm); } } catch (err) { debugLog(`folder-monitor auto-start failed: ${err.message}`); @@ -1785,7 +1778,7 @@ app.on('will-quit', () => { } catch {} try { if (_rotLogBuffer.length) { - _rotLogWriter.flushSync(_rotLogBuffer, 'rot-log'); + _rotLogFlusher.flushSync('rot-log'); } } catch {} }); @@ -2802,7 +2795,7 @@ async function syncImportedRuntime(config) { try { folderMonitor.stop(); const folderSettings = config.globalSettings.folderMonitor; - if (folderSettings && folderSettings.enabled && folderSettings.folderPath) startFolderMonitor(folderSettings); + if (folderSettings && folderSettings.enabled && folderSettings.folderPath) await startFolderMonitor(folderSettings); } catch (error) { debugLog(`backup folder monitor sync failed: ${error.message}`); warnings.push('Ordnerüberwachung'); @@ -3273,14 +3266,15 @@ async function withAutomationStatusSuppressed(operation) { function automationStatusSnapshot() { const settings = configStore.load().globalSettings?.folderMonitor || {}; + const normalized = normalizeAutomationSettings(settings); return Object.freeze({ ...folderMonitor.status(), enabled: settings.enabled === true, configured: String(settings.folderPath || '').trim().length > 0, paused: settings.paused === true, pausedAt: settings.pausedAt ?? null, - queueLimitJobs: settings.queueLimitJobs, - reconcileIntervalMinutes: settings.reconcileIntervalMinutes + queueLimitJobs: normalized.queueLimitJobs, + reconcileIntervalMinutes: normalized.reconcileIntervalMinutes }); } @@ -3318,11 +3312,25 @@ function bindFolderMonitorEvents(settings) { }); } -function startFolderMonitor(settings) { +async function startFolderMonitor(settings) { try { - folderMonitor.stop(); - bindFolderMonitorEvents(settings); - const result = folderMonitor.start(settings); + const persisted = configStore.load().globalSettings?.folderMonitor || {}; + const normalized = normalizeAutomationSettings(settings); + const effectiveSettings = { + ...settings, + queueLimitJobs: normalized.queueLimitJobs, + reconcileIntervalMinutes: normalized.reconcileIntervalMinutes, + paused: persisted.paused === true, + pausedAt: persisted.paused === true ? (persisted.pausedAt ?? null) : null + }; + bindFolderMonitorEvents(effectiveSettings); + if (persisted.paused === true) { + folderMonitor.configure(effectiveSettings); + debugLog(`folder-monitor configured while paused: ${effectiveSettings.folderPath}`); + return { includesExisting: false, paused: true }; + } + const result = folderMonitor.start(effectiveSettings); + await folderMonitor.scan({ emitFiles: true, trigger: 'startup' }); debugLog(`folder-monitor started: ${settings.folderPath}`); return result; } catch (err) { @@ -3333,16 +3341,14 @@ function startFolderMonitor(settings) { async function resumeFolderMonitor(settings) { bindFolderMonitorEvents(settings); - const result = await folderMonitor.resume(settings); + const result = await folderMonitor.resume(settings, { reconcile: false }); debugLog(`folder-monitor resumed: ${settings.folderPath}`); return result; } -ipcMain.handle('folder-monitor:start', (_event, settings) => { - if (configStore.load().globalSettings?.folderMonitor?.paused === true) { - return { error: 'Automatik ist pausiert' }; - } - const result = startFolderMonitor(settings); +ipcMain.handle('folder-monitor:start', async (_event, settings) => { + const result = await startFolderMonitor(settings); + if (result?.paused === true) return { error: 'Automatik ist pausiert' }; return { ok: true, includesExisting: result?.includesExisting === true }; }); @@ -3383,21 +3389,50 @@ ipcMain.handle('automation:pause-after-active', () => enqueueAutomationLifecycle })); ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async generation => { + let result; await withAutomationStatusSuppressed(async () => { const latest = configStore.load(); const settings = latest.globalSettings?.folderMonitor || {}; - const resumedSettings = { ...settings, paused: false, pausedAt: null }; - await configStore.save({ - globalSettings: { - ...latest.globalSettings, - folderMonitor: resumedSettings + const normalized = normalizeAutomationSettings(settings); + const pausedSettings = { + ...settings, + queueLimitJobs: normalized.queueLimitJobs, + reconcileIntervalMinutes: normalized.reconcileIntervalMinutes, + paused: settings.paused === true, + pausedAt: settings.pausedAt ?? null + }; + const resumedSettings = { ...pausedSettings, paused: false, pausedAt: null }; + try { + if (resumedSettings.enabled && resumedSettings.folderPath) { + await resumeFolderMonitor(resumedSettings); } - }); - if (resumedSettings.enabled && resumedSettings.folderPath) { - await resumeFolderMonitor(resumedSettings); + await configStore.save({ + globalSettings: { + ...latest.globalSettings, + folderMonitor: resumedSettings + } + }); + if (resumedSettings.enabled && resumedSettings.folderPath) { + await folderMonitor.scan({ emitFiles: true, trigger: 'resume' }); + } + } catch { + folderMonitor.stop(); + if (pausedSettings.enabled && pausedSettings.folderPath) { + bindFolderMonitorEvents(pausedSettings); + folderMonitor.configure(pausedSettings); + } + try { + await configStore.save({ + globalSettings: { + ...latest.globalSettings, + folderMonitor: pausedSettings + } + }); + } catch {} + result = { error: 'Automatik konnte nicht fortgesetzt werden' }; } }); - return publishAutomationStatus(null, generation); + return publishAutomationStatus(result, generation); })); ipcMain.handle('folder-monitor:test-scan', () => { @@ -3405,6 +3440,9 @@ ipcMain.handle('folder-monitor:test-scan', () => { }); ipcMain.handle('folder-monitor:reconcile', () => { + if (configStore.load().globalSettings?.folderMonitor?.paused === true) { + return { error: 'Automatik ist pausiert' }; + } return folderMonitor.scan({ emitFiles: true, trigger: 'manual' }); }); diff --git a/renderer/app.js b/renderer/app.js index b140e04..c6d566c 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -67,12 +67,15 @@ let uploading = false; let healthCheckRunning = false; let automationRuntimeStatus = Object.freeze({}); let automationRuntimeStatusAvailable = false; -let automationRuntimeStartedAt = null; let automationPauseResumeBusy = false; let automationTestGeneration = 0; let automationTestReturnFocus = null; let automationTestInertState = []; let automationTestViewState = Object.freeze({ loading: false, summary: null, error: '' }); +const automationEventBatchSize = 8; +const automationEventQueue = new Map(); +const automationEventInFlight = new Set(); +let automationEventDrainPromise = null; let managedOnlineBackups = []; let managedOnlineBackupsAuthoritative = false; let managedOnlineBackupMutationGeneration = 0; @@ -482,14 +485,6 @@ function automationSettings() { function applyAutomationRuntimeStatus(value) { const next = { ...(value || {}) }; - const incomingStartedAt = automationTimestamp(next.startedAt); - const wasRunning = automationRuntimeStatus.running === true && automationRuntimeStatus.paused !== true; - if (next.running === true && next.paused !== true) { - automationRuntimeStartedAt = incomingStartedAt || (wasRunning ? automationRuntimeStartedAt : null) || Date.now(); - } else if (incomingStartedAt) { - automationRuntimeStartedAt = incomingStartedAt; - } - if (automationRuntimeStartedAt) next.startedAt = automationRuntimeStartedAt; if (typeof next.paused === 'boolean' && config.globalSettings) { const folderMonitor = config.globalSettings.folderMonitor || {}; config.globalSettings.folderMonitor = { @@ -532,11 +527,9 @@ function createAutomationStatusSnapshot() { && availableSlots < configuredTargetCount; const telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry); const error = String(automationRuntimeStatus.error || automationRuntimeStatus.monitorError || telemetry.lastError || ''); - const startedAt = automationTimestamp(automationRuntimeStatus.startedAt) || automationRuntimeStartedAt; + const startedAt = automationTimestamp(automationRuntimeStatus.startedAt); const lastReconcileAt = automationTimestamp(automationRuntimeStatus.lastScanAt); - const nextReconcileAt = automationRuntimeStatus.running === true && automationRuntimeStatus.paused !== true && lastReconcileAt - ? lastReconcileAt + normalized.reconcileIntervalMinutes * 60000 - : null; + const nextReconcileAt = automationTimestamp(automationRuntimeStatus.nextReconcileAt); const snapshot = { ...automationRuntimeStatus, enabled: folderSettings.enabled === true, @@ -569,6 +562,9 @@ async function evaluateAutomationCandidates(files, options = {}) { } const candidates = [...candidateMap.values()]; const matched = candidates.filter(candidate => candidate.path && candidate.filterMatched); + const reasons = new Map(candidates + .filter(candidate => !candidate.path || !candidate.filterMatched) + .map(candidate => [normalizeAutomationPath(candidate.path), 'filter-rejected'])); const folderSettings = config.globalSettings?.folderMonitor || {}; const ownedPendingPaths = new Set((Array.isArray(options.ownedPendingPaths) ? options.ownedPendingPaths : []).map(normalizeAutomationPath)); const selectedHosters = Array.from(new Set((Array.isArray(options.selectedHosters) ? options.selectedHosters : folderSettings.hosters || []) @@ -585,12 +581,17 @@ async function evaluateAutomationCandidates(files, options = {}) { uploadLogRows: uploadLog }); const processedPaths = new Set(processed.processedPaths.map(normalizeAutomationPath)); + for (const path of processedPaths) reasons.set(path, 'processed'); const unprocessed = matched.filter(candidate => !processedPaths.has(normalizeAutomationPath(candidate.path))); const inspection = await window.api.inspectImportFiles( unprocessed, _pendingFiles.filter(file => !ownedPendingPaths.has(normalizeAutomationPath(file.path))).map(file => file.path) ); const metadata = new Map(unprocessed.map(candidate => [normalizeAutomationPath(candidate.path), candidate])); + const inspectionDuplicatePaths = new Set((Array.isArray(inspection?.duplicates) ? inspection.duplicates : []).map(file => normalizeAutomationPath(file?.path)).filter(Boolean)); + const unavailablePaths = new Set((Array.isArray(inspection?.unavailable) ? inspection.unavailable : []).map(file => normalizeAutomationPath(file?.path)).filter(Boolean)); + for (const path of inspectionDuplicatePaths) reasons.set(path, 'inspection-duplicate'); + for (const path of unavailablePaths) reasons.set(path, 'unavailable'); const accepted = (Array.isArray(inspection?.accepted) ? inspection.accepted : []).map(file => ({ ...(metadata.get(normalizeAutomationPath(file?.path)) || {}), ...file @@ -610,7 +611,7 @@ async function evaluateAutomationCandidates(files, options = {}) { 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, + candidates: plannedCandidates.filter(candidate => candidate.eligibleJobCount > 0), currentJobCount, queueLimitJobs: normalizedSettings.queueLimitJobs }); @@ -618,14 +619,28 @@ async function evaluateAutomationCandidates(files, options = {}) { 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); + for (const candidate of plannedCandidates) { + const key = normalizeAutomationPath(candidate.path); + if (selectedHosters.length === 0) reasons.set(key, 'awaiting-host-selection'); + else if (candidate.eligibleJobCount === 0) reasons.set(key, 'size-limited'); + else if (admittedPaths.has(key)) reasons.set(key, 'admitted'); + else if (deferredPaths.has(key)) reasons.set(key, 'deferred'); + } + const actionableCandidates = plannedCandidates.filter(candidate => candidate.eligibleJobCount > 0); + const resultingJobs = actionableCandidates.reduce((total, candidate) => total + candidate.eligibleJobCount, 0); + const classifications = candidates.map(candidate => ({ + path: candidate.path, + name: candidate.name, + reason: reasons.get(normalizeAutomationPath(candidate.path)) || 'unavailable' + })); + const skippedReasons = new Set(['filter-rejected', 'processed', 'inspection-duplicate', 'unavailable', 'size-limited']); const summary = { found: candidates.length, filterMatched: matched.length, - alreadyProcessed: processed.processedPaths.length, - unavailable: Number(inspection?.unavailableCount) || 0, + alreadyProcessed: processed.processedPaths.length + inspectionDuplicatePaths.size, + unavailable: unavailablePaths.size, sizeLimitedJobs: plannedCandidates.length * selectedHosters.length - resultingJobs, - acceptedFiles: plannedCandidates.length, + acceptedFiles: selectedHosters.length === 0 ? plannedCandidates.length : actionableCandidates.length, selectedTargets: selectedHosters.length, resultingJobs, availableSlots, @@ -639,15 +654,16 @@ async function evaluateAutomationCandidates(files, options = {}) { selectedHosters, ownedPendingPaths: [...ownedPendingPaths], candidates: plannedCandidates, + classifications, admittedFiles, deferredFiles, summary, telemetryDelta: { detected: summary.found, queued: admittedFiles.length, - skipped: summary.alreadyProcessed + summary.unavailable, + skipped: classifications.filter(entry => skippedReasons.has(entry.reason)).length, deferred: deferredFiles.length, - lastDetectedName: plannedCandidates.at(-1)?.name || '' + lastDetectedName: candidates.at(-1)?.name || '' } }); } @@ -740,7 +756,7 @@ async function applyAutomationEvaluation(evaluation) { const normalizedSettings = automationSettings(); const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs); const admission = window.AutomationControl.planAtomicAdmissions({ - candidates, + candidates: candidates.filter(candidate => candidate.eligibleJobCount > 0), currentJobCount, queueLimitJobs: normalizedSettings.queueLimitJobs }); @@ -748,12 +764,25 @@ async function applyAutomationEvaluation(evaluation) { 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))); + const dynamicPaths = new Set(replannedCandidates.map(candidate => normalizeAutomationPath(candidate.path))); + const dynamicReasons = new Map(); + for (const candidate of replannedCandidates) { + const key = normalizeAutomationPath(candidate.path); + if (currentPaths.has(key)) dynamicReasons.set(key, 'inspection-duplicate'); + else if (candidate.eligibleJobCount === 0) dynamicReasons.set(key, 'size-limited'); + else if (admittedPaths.has(key)) dynamicReasons.set(key, 'admitted'); + else dynamicReasons.set(key, 'deferred'); + } + const classifications = (evaluation.classifications || []).map(entry => dynamicPaths.has(normalizeAutomationPath(entry.path)) + ? { ...entry, reason: dynamicReasons.get(normalizeAutomationPath(entry.path)) } + : entry); + const skippedReasons = new Set(['filter-rejected', 'processed', 'inspection-duplicate', 'unavailable', 'size-limited']); const telemetryDelta = { - detected: evaluation.summary.found, + detected: classifications.length, queued: admittedFiles.length, - skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable, + skipped: classifications.filter(entry => skippedReasons.has(entry.reason)).length, deferred: deferredFiles.length, - lastDetectedName: admittedFiles.at(-1)?.name || '' + lastDetectedName: evaluation.telemetryDelta?.lastDetectedName || '' }; if (admittedFiles.length === 0) { const telemetryResult = await persistAutomationTelemetry(telemetryDelta); @@ -907,9 +936,13 @@ function renderAutomationStatusSnapshot(snapshot) { } const queueTrack = document.getElementById('automationQueueMeterTrack'); if (queueTrack) { - queueTrack.setAttribute('aria-valuenow', String(snapshot.currentJobCount)); - if (snapshot.queueLimitJobs === 0) queueTrack.removeAttribute('aria-valuemax'); - else queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs)); + if (snapshot.queueLimitJobs === 0) { + queueTrack.removeAttribute('aria-valuenow'); + queueTrack.removeAttribute('aria-valuemax'); + } else { + queueTrack.setAttribute('aria-valuenow', String(snapshot.currentJobCount)); + queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs)); + } queueTrack.setAttribute('aria-valuetext', queueText); } const telemetry = snapshot.telemetry || {}; @@ -925,7 +958,7 @@ function renderAutomationStatusSnapshot(snapshot) { const errorRow = document.getElementById('automationLastErrorRow'); const errorText = String(snapshot.error || telemetry.lastError || ''); if (errorRow) errorRow.hidden = errorText.length === 0; - setAutomationText('automationLastError', errorText); + setAutomationText('automationLastError', errorText ? localizeUiText(errorText) : ''); return snapshot; } @@ -1151,7 +1184,7 @@ async function toggleAutomationPauseResume() { updateStatusBar(); } } catch { - showCopyToast(resume ? 'Automatik konnte nicht fortgesetzt werden.' : 'Automatik konnte nicht pausiert werden.'); + showCopyToast(localizeUiText(resume ? 'Automatik konnte nicht fortgesetzt werden.' : 'Automatik konnte nicht pausiert werden.')); } finally { automationPauseResumeBusy = false; refreshAutomationControlCenter(); @@ -1166,11 +1199,12 @@ window.runFolderMonitorTestScan = runFolderMonitorTestScan; function surfaceAutomationOutcome(result) { if (result?.ok !== false) return ''; const message = result.warning || result.error || 'Automatische Aufnahme konnte nicht abgeschlossen werden.'; - showCopyToast(message, 6500); - return message; + const localized = localizeUiText(message); + showCopyToast(localized, 6500); + return localized; } -async function handleFolderMonitorFiles(files) { +async function processFolderMonitorFiles(files) { window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); const evaluation = await evaluateAutomationCandidates(files, { dryRun: false, trigger: 'watcher' }); const result = await applyAutomationEvaluation(evaluation); @@ -1178,6 +1212,38 @@ async function handleFolderMonitorFiles(files) { return result; } +async function drainFolderMonitorFiles() { + let result = freezeAutomationValue({ admittedFiles: [], deferredFiles: [], paused: false, dryRun: false }); + while (automationEventQueue.size > 0) { + const entries = [...automationEventQueue.entries()].slice(0, automationEventBatchSize); + for (const [key] of entries) { + automationEventQueue.delete(key); + automationEventInFlight.add(key); + } + try { + result = await processFolderMonitorFiles(entries.map(([, file]) => file)); + } finally { + for (const [key] of entries) automationEventInFlight.delete(key); + } + } + return result; +} + +function handleFolderMonitorFiles(files) { + for (const file of Array.isArray(files) ? files : []) { + const candidate = normalizeAutomationCandidate(file, automationEventQueue.size); + const key = normalizeAutomationPath(candidate.path); + if (!key || automationEventQueue.has(key) || automationEventInFlight.has(key)) continue; + automationEventQueue.set(key, candidate); + } + if (!automationEventDrainPromise) { + automationEventDrainPromise = Promise.resolve() + .then(drainFolderMonitorFiles) + .finally(() => { automationEventDrainPromise = null; }); + } + return automationEventDrainPromise; +} + // --- Init --- async function init() { try { @@ -2515,10 +2581,6 @@ function suppressPreviewKeysStillSelected(keys) { function buildQueuePreview() { const hosters = getSelectedHosters(); queueJobs = queueJobs.filter(j => j.status !== 'preview' || j.automationAdmission === true); - const normalizedSettings = automationSettings(); - let availableSlots = normalizedSettings.queueLimitJobs === 0 - ? null - : Math.max(0, normalizedSettings.queueLimitJobs - window.AutomationControl.countAutomaticQueueJobs(queueJobs)); if (hosters.length > 0) { const existingKeys = new Set(); @@ -2532,7 +2594,6 @@ function buildQueuePreview() { const key = `${file.path}|${hoster}`; return !existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key); }); - if (availableSlots !== null && eligibleHosters.length > availableSlots) continue; for (const hoster of eligibleHosters) { const key = `${file.path}|${hoster}`; const job = { @@ -2545,7 +2606,6 @@ function buildQueuePreview() { queueJobs.push(job); existingKeys.add(key); } - if (availableSlots !== null) availableSlots -= eligibleHosters.length; } } @@ -6973,7 +7033,7 @@ async function performSaveSettings(options = {}) { const normalizedAutomationInputs = window.AutomationControl.normalizeAutomationSettings({ ...curFm, queueLimitJobs: document.getElementById('fmQueueLimitInput')?.value ?? curFm.queueLimitJobs, - reconcileIntervalMinutes: document.getElementById('fmReconcileIntervalInput')?.value ?? curFm.reconcileIntervalMinutes + reconcileIntervalMinutes: Number(document.getElementById('fmReconcileIntervalInput')?.value ?? curFm.reconcileIntervalMinutes) }); const globalSettings = { diff --git a/renderer/i18n.js b/renderer/i18n.js index 29853ab..d3c06b8 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -296,6 +296,13 @@ ['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'], ['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'], ['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'], + ['Automatik ist pausiert', 'Automation is paused'], + ['Telemetrie konnte nicht gespeichert werden.', 'Telemetry could not be saved.'], + ['Automatik konnte nicht fortgesetzt werden', 'Automation could not be resumed'], + ['Upload konnte nicht gestartet werden.', 'Upload could not be started.'], + ['Upload wurde nicht bestätigt.', 'Upload was not confirmed.'], + ['Upload-Wiederherstellung konnte nicht vorbereitet werden', 'Upload recovery could not be prepared'], + ['Upload-Start wurde verworfen', 'Upload start was discarded'], ['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'], ['Ordner nicht erreichbar', 'Folder unavailable'], ['Ordnerscan fehlgeschlagen', 'Folder scan failed'], diff --git a/tests/automation-control.test.js b/tests/automation-control.test.js index d89ba2d..67b52c8 100644 --- a/tests/automation-control.test.js +++ b/tests/automation-control.test.js @@ -37,7 +37,7 @@ test('automation settings normalize invalid limits intervals and pause timestamp pausedAt: 1700 }), { queueLimitJobs: 42, - reconcileIntervalMinutes: 15, + reconcileIntervalMinutes: 5, paused: false, pausedAt: null }); @@ -47,6 +47,7 @@ test('automation settings normalize invalid limits intervals and pause timestamp paused: false, pausedAt: null }); + assert.equal(normalizeAutomationSettings({ reconcileIntervalMinutes: 15 }).reconcileIntervalMinutes, 15); }); test('automation settings allow only numeric and trimmed string zero to disable the queue limit', () => { @@ -238,6 +239,37 @@ test('telemetry deltas increment counters and update event details immutably', ( assert.equal(telemetry.lastError, 'old'); }); +test('telemetry and deltas normalize every counter to a finite nonnegative integer', () => { + const now = new Date(2026, 7, 26, 13, 14, 15).getTime(); + const telemetry = rollDailyTelemetry({ + dateKey: '2026-08-26', + detected: Number.POSITIVE_INFINITY, + queued: Number.NaN, + skipped: -4, + deferred: 3.9 + }, now); + assert.deepEqual({ + detected: telemetry.detected, + queued: telemetry.queued, + skipped: telemetry.skipped, + deferred: telemetry.deferred + }, { detected: 0, queued: 0, skipped: 0, deferred: 3 }); + + const changed = applyTelemetryDelta(telemetry, { + detected: Number.POSITIVE_INFINITY, + queued: Number.NaN, + skipped: -2, + deferred: 2.8 + }, now); + assert.deepEqual({ + detected: changed.detected, + queued: changed.queued, + skipped: changed.skipped, + deferred: changed.deferred + }, { detected: 0, queued: 0, skipped: 0, deferred: 5 }); + assert.equal(Object.values(changed).filter(value => typeof value === 'number').every(value => Number.isFinite(value)), true); +}); + test('pause has higher display priority than disconnect error and queue limit', () => { assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused'); }); diff --git a/tests/folder-monitor.test.js b/tests/folder-monitor.test.js index 2f56547..d164e37 100644 --- a/tests/folder-monitor.test.js +++ b/tests/folder-monitor.test.js @@ -22,9 +22,11 @@ function createWatcherHarness() { function createManualTimers() { const intervals = new Set(); const timeouts = new Set(); + const intervalDelays = []; return { - setIntervalFn(callback) { + setIntervalFn(callback, delay) { intervals.add(callback); + intervalDelays.push(delay); return callback; }, clearIntervalFn(callback) { @@ -45,7 +47,8 @@ function createManualTimers() { timeouts.delete(callback); await callback(); } - } + }, + intervalDelays }; } @@ -156,7 +159,7 @@ test('initial scan completion is exposed so the one-time option can be persisted assert.equal(completed, 1); }); -test('dry scan returns matching descriptors without emitting new files', async () => { +test('dry scan returns every descriptor with a disjoint filter classification without emitting files', async () => { const { monitor, events } = createScanHarness({ files: [ { path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 }, @@ -165,10 +168,118 @@ test('dry scan returns matching descriptors without emitting new files', async ( }); monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, reconcileIntervalMinutes: 5 }); const result = await monitor.scan({ emitFiles: false, trigger: 'test' }); - assert.deepEqual(result.files, [{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 }]); + assert.deepEqual(result.files, [ + { path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1, filterMatched: true, filterReason: 'matched' }, + { path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2, filterMatched: false, filterReason: 'extension' } + ]); + assert.equal(result.files.length, 2); + assert.equal(result.files.filter(file => file.filterMatched).length, 1); assert.equal(events.newFiles.length, 0); }); +test('productive full scans emit every classified descriptor without consuming watcher duplicate reservations', async () => { + const { monitor, events } = createScanHarness({ + files: [ + { path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 }, + { path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 } + ] + }); + monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, skipDuplicates: true, reconcileIntervalMinutes: 5 }); + + await monitor.scan({ emitFiles: true, trigger: 'startup' }); + await monitor.scan({ emitFiles: true, trigger: 'interval' }); + + assert.deepEqual(events.newFiles.map(files => files.map(file => ({ name: file.name, filterMatched: file.filterMatched }))), [ + [{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }], + [{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }] + ]); + assert.equal(monitor.status().seenCount, 0); +}); + +test('reconciliation intervals accept only finite numeric positive-list values', () => { + const cases = [ + [1, 60000], + [5, 300000], + [15, 900000], + [30, 1800000], + [60, 3600000], + [-1, 300000], + [Number.POSITIVE_INFINITY, 300000], + ['1', 300000], + ['15', 300000], + [2, 300000], + [undefined, 300000] + ]; + + for (const [reconcileIntervalMinutes, expectedDelay] of cases) { + const timers = createManualTimers(); + const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers }); + monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes }); + assert.equal(timers.intervalDelays[0], expectedDelay, String(reconcileIntervalMinutes)); + monitor.stop(); + } +}); + +test('status owns monitoring start and next reconciliation timestamps across pause resume and intervals', async () => { + let now = 1000; + const timers = createManualTimers(); + const monitor = new FolderMonitor({ + watch: createSilentWatch(), + access: async () => {}, + walkFolder: async () => [], + stat: async () => ({ mtimeMs: 1 }), + now: () => now, + ...timers + }); + + monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); + assert.equal(monitor.status().startedAt, 1000); + assert.equal(monitor.status().nextReconcileAt, 301000); + now = 2000; + await monitor.scan({ emitFiles: true, trigger: 'startup' }); + assert.equal(monitor.status().startedAt, 1000); + assert.equal(monitor.status().nextReconcileAt, 301000); + await monitor.pause(); + assert.equal(monitor.status().startedAt, null); + assert.equal(monitor.status().nextReconcileAt, null); + now = 4000; + await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); + assert.equal(monitor.status().startedAt, 4000); + assert.equal(monitor.status().nextReconcileAt, 304000); + now = 304000; + await timers.runInterval(); + assert.equal(monitor.status().startedAt, 4000); + assert.equal(monitor.status().lastScanAt, 304000); + assert.equal(monitor.status().nextReconcileAt, 604000); +}); + +test('paused configuration keeps watcher and interval closed while allowing a read-only scan', async () => { + const timers = createManualTimers(); + let watcherStarts = 0; + const monitor = new FolderMonitor({ + watch: () => { + watcherStarts++; + return createSilentWatch()(); + }, + access: async () => {}, + walkFolder: async () => [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1 }], + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + + const result = monitor.configure({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 }); + const scan = await monitor.scan({ emitFiles: false, trigger: 'test' }); + const productive = await monitor.scan({ emitFiles: true, trigger: 'manual' }); + + assert.deepEqual(result, { includesExisting: false, paused: true }); + assert.equal(watcherStarts, 0); + assert.equal(timers.intervalDelays.length, 0); + assert.equal(monitor.status().paused, true); + assert.equal(monitor.status().folderPath, 'C:\\watch'); + assert.deepEqual(scan.files.map(file => file.name), ['a.mkv']); + assert.equal(productive.cancelled, true); +}); + test('overlapping reconcile requests serialize and collapse to one follow-up scan', async () => { const { monitor, releaseFirstScan, scanCalls } = createDeferredScanHarness(); const first = monitor.scan({ emitFiles: true, trigger: 'interval' }); @@ -211,6 +322,20 @@ test('pause stops watcher and reconciliation until explicit resume', async () => assert.equal(monitor.status().paused, false); }); +test('resume can activate watcher and interval before a separately gated reconciliation', async () => { + const { monitor, scanCalls } = createReachabilityHarness(true); + const settings = { folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }; + monitor.start(settings); + await monitor.pause(); + + const result = await monitor.resume(settings, { reconcile: false }); + + assert.equal(scanCalls(), 0); + assert.equal(result.reconciled, false); + assert.equal(monitor.status().running, true); + assert.equal(monitor.status().paused, false); +}); + test('repeated pause emits one status change and keeps reconciliation stopped', async () => { const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true); monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); @@ -331,7 +456,7 @@ test('late watcher add callbacks are ignored after pause', async () => { assert.deepEqual(newFiles, []); }); -test('resume preserves session duplicate history', async () => { +test('resume full scan redelivers candidates while watcher duplicate history remains reserved', async () => { const timers = createManualTimers(); const watchers = []; const newFiles = []; @@ -356,7 +481,10 @@ test('resume preserves session duplicate history', async () => { await monitor.resume(settings); watchers[1].emit('add', 'C:\\watch\\same.mkv'); await timers.runTimeouts(); - assert.deepEqual(newFiles, [['C:\\watch\\same.mkv']]); + assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [ + ['C:\\watch\\same.mkv'], + ['C:\\watch\\same.mkv'] + ]); assert.equal(monitor.status().seenCount, 1); }); @@ -385,11 +513,11 @@ test('watcher add paused before batch timeout is emitted exactly once by resume await monitor.pause(); assert.deepEqual(newFiles, []); await monitor.resume(settings); - assert.deepEqual(newFiles, [[filePath]]); - assert.equal(monitor.status().seenCount, 1); + assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath]]); + assert.equal(monitor.status().seenCount, 0); }); -test('synchronous pause during successful batch emission does not duplicate on resume', async () => { +test('synchronous pause during watcher delivery keeps its reservation while resume scan redelivers the candidate', async () => { const timers = createManualTimers(); const watchers = []; const newFiles = []; @@ -418,7 +546,7 @@ test('synchronous pause during successful batch emission does not duplicate on r await pausePromise; assert.deepEqual(newFiles, [[filePath]]); await monitor.resume(settings); - assert.deepEqual(newFiles, [[filePath]]); + assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath], [filePath]]); assert.equal(monitor.status().seenCount, 1); }); @@ -452,8 +580,9 @@ test('pause rollback never deletes historical seen state from a dedupe-off batch watchers[1].emit('add', filePath); assert.deepEqual(newFiles, [[filePath]]); await monitor.pause(); - discoverExisting = true; await monitor.resume(dedupeOn); + watchers[2].emit('add', filePath); + await timers.runTimeouts(); assert.deepEqual(newFiles, [[filePath]]); assert.equal(monitor.status().seenCount, 1); }); @@ -551,7 +680,7 @@ test('interval callback contains unexpected scan rejection', async () => { assert.equal(statuses.at(-1).error.includes('interval-secret'), false); }); -test('real temporary folder converges through startup interval capacity recovery and one reconnect', async () => { +test('real temporary folder recovers a file-atomic deferral with default duplicate protection', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-')); const detached = `${root}-detached`; const timers = createManualTimers(); @@ -569,12 +698,12 @@ test('real temporary folder converges through startup interval capacity recovery fs.utimesSync(fourJobPath, new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z')); fs.utimesSync(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z')); const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers }); - monitor.on('new-files', (paths) => { - const descriptors = paths.map(filePath => ({ - path: filePath, - name: path.basename(filePath), - mtimeMs: fs.statSync(filePath).mtimeMs - })); + monitor.on('new-files', (files) => { + const descriptors = files.map(file => typeof file === 'string' ? { + path: file, + name: path.basename(file), + mtimeMs: fs.statSync(file).mtimeMs + } : file).filter(file => file.filterMatched !== false); const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] }); const unprocessed = new Set(processed.unprocessedPaths); const candidates = descriptors @@ -595,12 +724,12 @@ test('real temporary folder converges through startup interval capacity recovery recursive: true, extensions: 'mkv', filterMode: 'include', - skipDuplicates: false, + skipDuplicates: true, reconcileIntervalMinutes: 5 }); const first = await monitor.scan({ emitFiles: true, trigger: 'startup' }); - assert.deepEqual(first.files.map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']); + assert.deepEqual(first.files.filter(file => file.filterMatched).map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']); assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true); assert.deepEqual(admissions[0], { trigger: 'startup', diff --git a/tests/i18n.test.js b/tests/i18n.test.js index c3972f5..8f6f96b 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -107,6 +107,13 @@ test('translates every automation control center label in both directions', () = ['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'], ['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'], ['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'], + ['Automatik ist pausiert', 'Automation is paused'], + ['Telemetrie konnte nicht gespeichert werden.', 'Telemetry could not be saved.'], + ['Automatik konnte nicht fortgesetzt werden', 'Automation could not be resumed'], + ['Upload konnte nicht gestartet werden.', 'Upload could not be started.'], + ['Upload wurde nicht bestätigt.', 'Upload was not confirmed.'], + ['Upload-Wiederherstellung konnte nicht vorbereitet werden', 'Upload recovery could not be prepared'], + ['Upload-Start wurde verworfen', 'Upload start was discarded'], ['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'], ['Ordner nicht erreichbar', 'Folder unavailable'], ['Ordnerscan fehlgeschlagen', 'Folder scan failed'], diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index 316c785..b02cdff 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -45,6 +45,8 @@ function createAutomationLifecycleHarness(mainSource) { const saves = []; const pauseDeferred = createDeferred(); const resumeDeferred = createDeferred(); + const configuredSettings = []; + const startedSettings = []; let publishStatus = () => {}; let state = { globalSettings: { @@ -60,9 +62,22 @@ function createAutomationLifecycleHarness(mainSource) { }; const folderMonitor = new (require('node:events').EventEmitter)(); folderMonitor.running = false; - folderMonitor.status = () => ({ running: folderMonitor.running, reachable: true }); + folderMonitor.status = () => ({ running: folderMonitor.running, reachable: true, startedAt: 100, nextReconcileAt: 200 }); folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); }; - folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); publishStatus(); return {}; }; + folderMonitor.configure = settings => { + configuredSettings.push(structuredClone(settings)); + folderMonitor.running = false; + order.push('configure'); + publishStatus(); + return { includesExisting: false, paused: true }; + }; + folderMonitor.start = settings => { + startedSettings.push(structuredClone(settings)); + folderMonitor.running = true; + order.push('start'); + publishStatus(); + return {}; + }; folderMonitor.pause = () => { order.push('pause'); publishStatus(); @@ -80,7 +95,10 @@ function createAutomationLifecycleHarness(mainSource) { return { reachable: true }; }); }; - folderMonitor.scan = async () => ({ reachable: true }); + folderMonitor.scan = async options => { + order.push(`scan:${options.trigger}:${options.emitFiles}`); + return { reachable: true, trigger: options.trigger }; + }; const configStore = { load: () => structuredClone(state), save: config => { @@ -101,6 +119,7 @@ function createAutomationLifecycleHarness(mainSource) { dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }, folderMonitor, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings, path, safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; }, uploadManager @@ -109,11 +128,17 @@ function createAutomationLifecycleHarness(mainSource) { publishStatus = () => context.publishAutomationStatus(); return { handlers, + configuredSettings, + context, order, pauseDeferred, resumeDeferred, saves, sent, + setFolderMonitorState(value) { + state.globalSettings.folderMonitor = { ...state.globalSettings.folderMonitor, ...value }; + }, + startedSettings, state: () => structuredClone(state), publishStatus }; @@ -422,6 +447,7 @@ test('automation pause save commits before lifecycle effects and save failure is folderMonitor.running = true; folderMonitor.status = () => ({ running: folderMonitor.running, paused: !folderMonitor.running, reachable: true }); folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); }; + folderMonitor.configure = () => { folderMonitor.running = false; order.push('configure'); return { paused: true }; }; folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); folderMonitor.emit('status'); return {}; }; folderMonitor.pause = async () => { folderMonitor.running = false; order.push('pause'); folderMonitor.emit('status'); }; folderMonitor.resume = async () => { folderMonitor.running = true; order.push('resume'); folderMonitor.emit('status'); return { reachable: true }; }; @@ -458,6 +484,7 @@ test('automation pause save commits before lifecycle effects and save failure is dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }, folderMonitor, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings, path, safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; }, uploadManager @@ -486,7 +513,7 @@ test('automation pause save commits before lifecycle effects and save failure is state.globalSettings.folderMonitor.pausedAt = 1; folderMonitor.running = false; await handlers.get('automation:resume')(); - assert.deepEqual(order, ['save:false', 'resume']); + assert.deepEqual(order, ['resume', 'save:false', 'scan:resume:true']); assert.equal(order.includes('startBatch'), false); assert.equal(sent.length, 1); assert.equal(sent[0][1].paused, false); @@ -504,15 +531,15 @@ test('automation lifecycle serializes pause then resume so the newer intent wins harness.saves[0].deferred.resolve(); await flushMicrotasks(); harness.pauseDeferred.resolve(); + await waitForCondition(() => harness.order.includes('resume')); + harness.resumeDeferred.resolve(); await waitForCondition(() => harness.saves.length === 2); assert.equal(harness.saves.length, 2); assert.equal(harness.saves[1].paused, false); harness.saves[1].deferred.resolve(); - await flushMicrotasks(); - harness.resumeDeferred.resolve(); await Promise.all([pause, resume]); - assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'save:false', 'resume']); + assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'resume', 'save:false', 'scan:resume:true']); assert.equal(harness.state().globalSettings.folderMonitor.paused, false); assert.equal(harness.sent.length, 1); assert.equal(harness.sent[0][1].paused, false); @@ -525,11 +552,12 @@ test('automation lifecycle serializes resume then pause so the newer intent wins const resume = harness.handlers.get('automation:resume')(); const pause = harness.handlers.get('automation:pause-after-active')(); - assert.equal(harness.saves.length, 1); + assert.equal(harness.saves.length, 0); + assert.deepEqual(harness.order, ['resume']); + harness.resumeDeferred.resolve(); + await waitForCondition(() => harness.saves.length === 1); assert.equal(harness.saves[0].paused, false); harness.saves[0].deferred.resolve(); - await flushMicrotasks(); - harness.resumeDeferred.resolve(); await waitForCondition(() => harness.saves.length === 2); assert.equal(harness.saves.length, 2); assert.equal(harness.saves[1].paused, true); @@ -538,7 +566,7 @@ test('automation lifecycle serializes resume then pause so the newer intent wins harness.pauseDeferred.resolve(); await Promise.all([resume, pause]); - assert.deepEqual(harness.order, ['save:false', 'resume', 'save:true', 'pause', 'finish']); + assert.deepEqual(harness.order, ['resume', 'save:false', 'scan:resume:true', 'save:true', 'pause', 'finish']); assert.equal(harness.state().globalSettings.folderMonitor.paused, true); assert.equal(harness.sent.length, 1); assert.equal(harness.sent[0][1].paused, true); @@ -553,14 +581,14 @@ test('automation status suppression remains active until the serialized operatio harness.saves[0].deferred.resolve(); await waitForCondition(() => harness.order.includes('pause')); harness.pauseDeferred.resolve(); - await waitForCondition(() => harness.saves.length === 2); + await waitForCondition(() => harness.order.includes('resume')); harness.publishStatus(); assert.equal(harness.sent.length, 0); - harness.saves[1].deferred.resolve(); - await waitForCondition(() => harness.order.includes('resume')); harness.resumeDeferred.resolve(); + await waitForCondition(() => harness.saves.length === 2); + harness.saves[1].deferred.resolve(); await Promise.all([pause, resume]); assert.equal(harness.sent.length, 1); @@ -585,6 +613,62 @@ test('automation pause rejection still finishes active uploads and returns a san assert.equal(JSON.stringify(harness.sent[0][1]).includes('secret-value'), false); }); +test('every folder monitor start obeys current persisted pause and active activation reconciles exactly once', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const harness = createAutomationLifecycleHarness(mainSource); + const settings = { folderPath: 'C:\\watch', enabled: true, paused: false, reconcileIntervalMinutes: '1' }; + + const pausedResult = await harness.handlers.get('folder-monitor:start')(null, settings); + assert.deepEqual({ ...pausedResult }, { error: 'Automatik ist pausiert' }); + assert.deepEqual(harness.order, ['configure']); + assert.equal(harness.configuredSettings[0].reconcileIntervalMinutes, 5); + + harness.order.length = 0; + harness.setFolderMonitorState({ paused: false, pausedAt: null, reconcileIntervalMinutes: '1' }); + const activeResult = await harness.handlers.get('folder-monitor:start')(null, settings); + assert.deepEqual({ ...activeResult }, { ok: true, includesExisting: false }); + assert.deepEqual(harness.order, ['start', 'scan:startup:true']); + assert.equal(harness.startedSettings[0].reconcileIntervalMinutes, 5); + const status = harness.handlers.get('automation:get-status')(); + assert.equal(status.reconcileIntervalMinutes, 5); + assert.equal(status.startedAt, 100); + assert.equal(status.nextReconcileAt, 200); +}); + +test('resume keeps pause authoritative until monitor success and restores the previous pause after rejection', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const harness = createAutomationLifecycleHarness(mainSource); + let settled = false; + const resume = harness.handlers.get('automation:resume')(); + const outcome = resume.then(value => ({ value }), error => ({ error })).finally(() => { settled = true; }); + await flushMicrotasks(); + const pendingState = harness.state().globalSettings.folderMonitor; + const savesBeforeResolution = harness.saves.length; + const orderBeforeResolution = [...harness.order]; + if (harness.saves[0]) harness.saves[0].deferred.resolve(); + await waitForCondition(() => harness.order.includes('resume')); + harness.resumeDeferred.reject(new Error('token=resume-secret')); + for (let attempt = 0; attempt < 50 && !settled; attempt++) { + for (const save of harness.saves) save.deferred.resolve(); + await Promise.resolve(); + } + const result = await outcome; + + assert.equal(savesBeforeResolution, 0); + assert.deepEqual(orderBeforeResolution, ['resume']); + assert.equal(pendingState.paused, true); + assert.equal(pendingState.pausedAt, 1); + assert.equal(result.error, undefined); + assert.equal(result.value.error, 'Automatik konnte nicht fortgesetzt werden'); + assert.equal(result.value.paused, true); + assert.equal(result.value.pausedAt, 1); + assert.deepEqual(harness.saves.map(save => save.paused), [true]); + assert.deepEqual(harness.order, ['resume', 'stop', 'configure', 'save:true']); + assert.equal(harness.state().globalSettings.folderMonitor.paused, true); + assert.equal(harness.state().globalSettings.folderMonitor.pausedAt, 1); + assert.equal(JSON.stringify(result.value).includes('resume-secret'), false); +}); + test('prepared upload start waits for the final tick and clears recovery when pause wins', async () => { const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); const blockStart = mainSource.indexOf('async function rejectPreparedUploadStart'); @@ -728,7 +812,8 @@ test('startup keeps a missing configured folder disconnected without disabling a assert.notEqual(startupEnd, -1); const startup = mainSource.slice(startupStart, startupEnd); - assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath\s*&&\s*fm\.paused\s*!==\s*true[\s\S]*?startFolderMonitor\(fm\)/u); - assert.match(startup, /startFolderMonitor\(fm\);[\s\S]*?!fs\.existsSync\(fm\.folderPath\)[\s\S]*?folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u); + assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath[\s\S]*?await startFolderMonitor\(fm\)/u); + assert.doesNotMatch(startup, /fm\.paused\s*!==\s*true/u); + assert.doesNotMatch(startup, /folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u); assert.doesNotMatch(startup, /folderMonitor:\s*\{\s*\.\.\.fm,\s*enabled:\s*false\s*\}/u); }); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 76aa8ed..c57b872 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -81,6 +81,9 @@ let automationProbe = { saveSettingsError: '', testScanError: '', deferTestScan: false, + deferInspect: false, + activeInspections: 0, + maxConcurrentInspections: 0, dryScan: { files: [], reachable: true, trigger: 'test' }, readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, mutationCalls: [], @@ -184,6 +187,9 @@ contextBridge.exposeInMainWorld('api', { saveSettingsError: String(value.saveSettingsError || ''), testScanError: String(value.testScanError || ''), deferTestScan: value.deferTestScan === true, + deferInspect: value.deferInspect === true, + activeInspections: 0, + maxConcurrentInspections: 0, dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' }, readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, mutationCalls: [], @@ -194,13 +200,17 @@ contextBridge.exposeInMainWorld('api', { getAutomationProbeState() { return { readCalls: { ...automationProbe.readCalls }, + activeInspections: automationProbe.activeInspections, + maxConcurrentInspections: automationProbe.maxConcurrentInspections, mutationCalls: automationProbe.mutationCalls.map(value => [...value]), logs: [...automationProbe.logs], savedSettings: automationProbe.savedSettings.map(value => JSON.parse(JSON.stringify(value))) }; }, - inspectImportFiles(entries, existingPaths) { + async inspectImportFiles(entries, existingPaths) { automationProbe.readCalls.inspect++; + automationProbe.activeInspections++; + automationProbe.maxConcurrentInspections = Math.max(automationProbe.maxConcurrentInspections, automationProbe.activeInspections); const candidates = Array.isArray(entries) ? entries : []; const normalize = value => String(value || '').replace(/\\\\/g, '/').toLowerCase(); const seen = new Set((Array.isArray(existingPaths) ? existingPaths : []).map(normalize)); @@ -216,7 +226,9 @@ contextBridge.exposeInMainWorld('api', { } const unavailable = unique.filter(entry => entry?.unavailable).map(entry => ({ ...entry, reason: 'unreadable' })); const accepted = unique.filter(entry => !entry?.unavailable).map(entry => ({ ...entry })); - return Promise.resolve({ + if (automationProbe.deferInspect) await new Promise(resolve => setTimeout(resolve, 5)); + automationProbe.activeInspections--; + return { candidateCount: candidates.length, duplicateCount: duplicates.length, unavailableCount: unavailable.length, @@ -224,7 +236,7 @@ contextBridge.exposeInMainWorld('api', { accepted, duplicates, unavailable - }); + }; }, getHistory() { automationProbe.readCalls.history++; @@ -805,13 +817,76 @@ contextBridge.exposeInMainWorld('api', { handleFolderMonitorFiles([{ ...parallelFile, path: 'C:\\\\watch\\\\PARALLEL.mkv' }]) ]); const parallelAdmission = { - admittedFiles: parallelResults.flatMap(result => result.admittedFiles.map(file => file.name)), + admittedFiles: [...new Set(parallelResults.flatMap(result => result.admittedFiles.map(file => file.name)))], matchingJobs: queueJobs.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path)).length, matchingPaths: [...new Set(queueJobs .filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path)) .map(job => normalizeAutomationPath(job.file)))], queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued }; + configureAtomicState(18); + config.globalSettings.folderMonitor.queueLimitJobs = 20; + config.globalSettings.folderMonitor.hosters = ['doodstream.com']; + config.globalSettings.folderMonitor.autoStart = false; + hosterSettings = {}; + const distinctFiles = Array.from({ length: 20 }, (_, index) => ({ + path: 'C:\\\\distinct\\\\distinct-' + String(index).padStart(3, '0') + '.mkv', + name: 'distinct-' + String(index).padStart(3, '0') + '.mkv', + size: 1, + mtimeMs: index + })); + window.api.configureAutomationProbe({ paused: false, deferInspect: true }); + await Promise.all(distinctFiles.map(file => handleFolderMonitorFiles([file]))); + const distinctProbe = await window.api.getAutomationProbeState(); + const distinctTelemetry = config.globalSettings.folderMonitor.telemetry; + const distinctParallel = { + inspectCalls: distinctProbe.readCalls.inspect, + maxConcurrentInspections: distinctProbe.maxConcurrentInspections, + capacityJobs: window.AutomationControl.countAutomaticQueueJobs(queueJobs), + distinctJobs: queueJobs.filter(job => job.file.startsWith('C:\\\\distinct\\\\')).length, + detected: distinctTelemetry.detected, + queued: distinctTelemetry.queued, + deferred: distinctTelemetry.deferred, + lastDetectedName: distinctTelemetry.lastDetectedName + }; + configureAtomicState(14999); + config.globalSettings.folderMonitor.hosters = ['doodstream.com']; + config.globalSettings.folderMonitor.autoStart = false; + hosterSettings = { 'doodstream.com': { maxSizeMb: 2 } }; + const reasonCandidates = [ + { path: 'C:\\\\reasons\\\\admitted.mkv', name: 'admitted.mkv', size: 1, mtimeMs: 1, filterMatched: true }, + { path: 'C:\\\\reasons\\\\deferred.mkv', name: 'deferred.mkv', size: 1, mtimeMs: 2, filterMatched: true }, + { path: 'C:\\\\reasons\\\\filtered.txt', name: 'filtered.txt', size: 1, mtimeMs: 3, filterMatched: false }, + { path: 'C:\\\\reasons\\\\processed.mkv', name: 'processed.mkv', size: 1, mtimeMs: 4, filterMatched: true }, + { path: 'C:\\\\reasons\\\\inspection-duplicate.mkv', name: 'inspection-duplicate.mkv', size: 1, mtimeMs: 5, filterMatched: true }, + { path: 'C:\\\\reasons\\\\unavailable.mkv', name: 'unavailable.mkv', size: 1, mtimeMs: 6, filterMatched: true, unavailable: true }, + { path: 'C:\\\\reasons\\\\size-limited.mkv', name: 'size-limited.mkv', size: 3 * 1024 * 1024, mtimeMs: 7, filterMatched: true } + ]; + _pendingFiles = [reasonCandidates[4]]; + window.api.configureAutomationProbe({ + paused: false, + history: [{ files: [{ path: reasonCandidates[3].path, name: reasonCandidates[3].name, results: [{ hoster: 'doodstream.com', status: 'done' }] }] }] + }); + const reasonEvaluation = await evaluateAutomationCandidates(reasonCandidates, { dryRun: false, trigger: 'watcher' }); + const reasonResult = await applyAutomationEvaluation(reasonEvaluation); + const reasonCounts = {}; + for (const entry of reasonEvaluation.classifications || []) reasonCounts[entry.reason] = (reasonCounts[entry.reason] || 0) + 1; + const disjointClassification = { + summary: reasonEvaluation.summary, + reasonCounts, + classificationCount: reasonEvaluation.classifications?.length || 0, + telemetryDelta: reasonEvaluation.telemetryDelta, + applied: { + admitted: reasonResult.admittedFiles.map(file => file.name), + deferred: reasonResult.deferredFiles.map(file => file.name) + }, + telemetry: { + detected: config.globalSettings.folderMonitor.telemetry.detected, + queued: config.globalSettings.folderMonitor.telemetry.queued, + skipped: config.globalSettings.folderMonitor.telemetry.skipped, + deferred: config.globalSettings.folderMonitor.telemetry.deferred + } + }; 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 } @@ -833,6 +908,8 @@ contextBridge.exposeInMainWorld('api', { queued: config.globalSettings.folderMonitor.telemetry.queued, currentJobCount: window.AutomationControl.countAutomaticQueueJobs(queueJobs), unplannedJobs: queueJobs.filter(job => job.fileName === 'unplanned.mkv').length, + manualJobHosters: queueJobs.filter(job => job.fileName === 'unplanned.mkv').map(job => job.hoster), + automationJobHosters: queueJobs.filter(job => job.file === atomicCandidates[1].path).map(job => job.hoster).sort(), selectedHostersAfterApply, manualSelectionFilesAfterApply, plannedHostsBeforeRebuild, @@ -1802,7 +1879,7 @@ contextBridge.exposeInMainWorld('api', { startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length }; - return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; + return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; })()`; const automationControlCenterScript = `(async () => { const waitFor = async predicate => { @@ -1820,6 +1897,7 @@ contextBridge.exposeInMainWorld('api', { folderPath: 'C:\\\\watch', lastScanAt: fixedNow - 60000, startedAt: fixedNow - 3600000, + nextReconcileAt: fixedNow + 123456, error: '' }; setUiLanguage('de'); @@ -1882,8 +1960,17 @@ contextBridge.exposeInMainWorld('api', { queueLimitMin: queueLimitInput?.min || null, intervalDefault: intervalInput?.value || null, intervalOptions: [...(intervalInput?.options || [])].map(option => option.value), - snapshotFrozen: Object.isFrozen(createAutomationStatusSnapshot()) && Object.isFrozen(createAutomationStatusSnapshot().telemetry) + snapshotFrozen: Object.isFrozen(createAutomationStatusSnapshot()) && Object.isFrozen(createAutomationStatusSnapshot().telemetry), + startedAt: createAutomationStatusSnapshot().startedAt, + nextReconcileAt: createAutomationStatusSnapshot().nextReconcileAt }; + applyAutomationRuntimeStatus({ ...runtimeStatus, startedAt: null, nextReconcileAt: null }); + const missingMainTimes = createAutomationStatusSnapshot(); + const noRendererTimeEstimate = { + startedAt: missingMainTimes.startedAt, + nextReconcileAt: missingMainTimes.nextReconcileAt + }; + applyAutomationRuntimeStatus(runtimeStatus); if (queueLimitInput) { queueLimitInput.value = '0'; queueLimitInput.dispatchEvent(new Event('input', { bubbles: true })); @@ -1908,6 +1995,24 @@ contextBridge.exposeInMainWorld('api', { renderAutomationStatusSnapshot(baseSnapshot); } const originalSnapshotFactory = createAutomationStatusSnapshot; + const finiteQueueSnapshot = originalSnapshotFactory(); + renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, queueLimitJobs: 0, availableSlots: null })); + const unlimitedQueueAria = { + now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'), + max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'), + text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext') + }; + renderAutomationStatusSnapshot(finiteQueueSnapshot); + const finiteQueueAria = { + now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'), + max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'), + text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext') + }; + setUiLanguage('en'); + renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, state: 'error', error: 'Ordnerscan fehlgeschlagen' })); + const localizedStatusError = document.getElementById('automationLastError')?.textContent.trim() || ''; + setUiLanguage('de'); + renderAutomationStatusSnapshot(finiteQueueSnapshot); let snapshotCalls = 0; const pausedSnapshot = Object.freeze({ ...originalSnapshotFactory(), @@ -2089,7 +2194,7 @@ contextBridge.exposeInMainWorld('api', { enabledAfterCancel, lateResultStayedClosed: document.getElementById('automationTestOverlay')?.style.display === 'none' }; - return { initial, states, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading }; + return { initial, noRendererTimeEstimate, states, unlimitedQueueAria, finiteQueueAria, localizedStatusError, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading }; })()`; const automationControlCenterLayoutScript = `(() => { const card = document.getElementById('automationStatusCard'); @@ -2545,6 +2650,49 @@ app.whenReady().then(async () => { matchingPaths: ['c:/watch/parallel.mkv'], queuedTelemetry: 1 }); + assert.deepEqual(result.automationPipeline.distinctParallel, { + inspectCalls: 3, + maxConcurrentInspections: 1, + capacityJobs: 20, + distinctJobs: 2, + detected: 20, + queued: 2, + deferred: 18, + lastDetectedName: 'distinct-019.mkv' + }); + assert.deepEqual(result.automationPipeline.disjointClassification, { + summary: { + found: 7, + filterMatched: 6, + alreadyProcessed: 2, + unavailable: 1, + sizeLimitedJobs: 1, + acceptedFiles: 2, + selectedTargets: 1, + resultingJobs: 2, + availableSlots: 1, + deferredFiles: 1 + }, + reasonCounts: { + admitted: 1, + deferred: 1, + 'filter-rejected': 1, + processed: 1, + 'inspection-duplicate': 1, + unavailable: 1, + 'size-limited': 1 + }, + classificationCount: 7, + telemetryDelta: { + detected: 7, + queued: 1, + skipped: 5, + deferred: 1, + lastDetectedName: 'size-limited.mkv' + }, + applied: { admitted: ['admitted.mkv'], deferred: ['deferred.mkv'] }, + telemetry: { detected: 7, queued: 1, skipped: 5, deferred: 1 } + }); assert.deepEqual(result.automationPipeline.manualHostTransactional, { readFailure: { result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' }, @@ -2569,8 +2717,10 @@ app.whenReady().then(async () => { admittedFiles: ['b.mkv'], deferred: 1, queued: 1, - currentJobCount: 15000, - unplannedJobs: 0, + currentJobCount: 15001, + unplannedJobs: 1, + manualJobHosters: ['clouddrop.cc'], + automationJobHosters: ['byse.sx', 'vidmoly.me'], selectedHostersAfterApply: ['clouddrop.cc'], manualSelectionFilesAfterApply: ['unplanned.mkv'], plannedHostsBeforeRebuild: ['byse.sx', 'vidmoly.me'], @@ -2578,7 +2728,7 @@ app.whenReady().then(async () => { }); assert.deepEqual(result.automationPipeline.status, { state: 'queue-limited', - currentJobCount: 15000, + currentJobCount: 15001, availableSlots: 0, queueLimited: true, frozen: true @@ -2842,15 +2992,15 @@ app.whenReady().then(async () => { assert.deepEqual(result.automationPipeline.fulfilledFeedback, { watcherWarning: { result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null }, - feedback: ['Telemetrie konnte nicht gespeichert werden.'] + feedback: ['Telemetry could not be saved.'] }, watcherError: { result: { ok: false, warning: null, error: 'Jobs konnten nicht hinzugefügt werden.' }, - feedback: ['Jobs konnten nicht hinzugefügt werden.'] + feedback: ['Jobs could not be added.'] }, modalWarning: { result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null }, - feedback: ['Telemetrie konnte nicht gespeichert werden.'], + feedback: ['Telemetry could not be saved.'], pending: 0, markers: 0, modalOpen: false, @@ -2919,6 +3069,9 @@ app.whenReady().then(async () => { assert.equal(result.automationControlCenter.initial.intervalDefault, '5'); assert.deepEqual(result.automationControlCenter.initial.intervalOptions, ['1', '5', '15', '30', '60']); assert.equal(result.automationControlCenter.initial.snapshotFrozen, true); + assert.equal(result.automationControlCenter.initial.startedAt, 1787709000000); + assert.equal(result.automationControlCenter.initial.nextReconcileAt, 1787712723456); + assert.deepEqual(result.automationControlCenter.noRendererTimeEstimate, { startedAt: null, nextReconcileAt: null }); assert.deepEqual(result.automationControlCenter.states, [ { state: 'inactive', expectedLabel: 'Inaktiv', text: 'Inaktiv', classApplied: true }, { state: 'active', expectedLabel: 'Aktiv', text: 'Aktiv', classApplied: true }, @@ -2927,6 +3080,9 @@ app.whenReady().then(async () => { { state: 'disconnected', expectedLabel: 'Ordner getrennt', text: 'Ordner getrennt', classApplied: true }, { state: 'error', expectedLabel: 'Fehler', text: 'Fehler', classApplied: true } ]); + assert.deepEqual(result.automationControlCenter.unlimitedQueueAria, { now: null, max: null, text: '8.420 / Unbegrenzt' }); + assert.deepEqual(result.automationControlCenter.finiteQueueAria, { now: '8420', max: '15000', text: '8.420 / 15.000' }); + assert.equal(result.automationControlCenter.localizedStatusError, 'Folder scan failed'); assert.deepEqual(result.automationControlCenter.pausedControls, { snapshotCalls: 1, pauseButtonDisabled: false, @@ -3393,6 +3549,7 @@ test('persisted automation pause survives runtime restart and resumes one reconc const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('restartProbe', { status: () => ipcRenderer.invoke('automation:get-status'), + testScan: () => ipcRenderer.invoke('folder-monitor:test-scan'), startMonitor: settings => ipcRenderer.invoke('folder-monitor:start', settings), reconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'), start: job => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [job] }), @@ -3421,6 +3578,7 @@ contextBridge.exposeInMainWorld('restartProbe', { status: 'preview' }; const initial = await window.restartProbe.status(); + const testScan = await window.restartProbe.testScan(); const monitorStart = await window.restartProbe.startMonitor({ folderPath: 'C:\\\\blocked' }); const reconcile = await captureFailure(() => window.restartProbe.reconcile()); const start = await window.restartProbe.start(preview); @@ -3443,6 +3601,7 @@ contextBridge.exposeInMainWorld('restartProbe', { const final = await window.restartProbe.counters(); window.__automationRestartResult = { initial, + testScan, monitorStart, reconcile, start, @@ -3467,6 +3626,7 @@ const fs = require('node:fs'); const path = require('node:path'); const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))}); const FolderMonitor = require(${JSON.stringify(path.join(projectRoot, 'lib', 'folder-monitor.js'))}); +const { normalizeAutomationSettings } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'automation-control.js'))}); const outputPath = process.env.MHU_AUTOMATION_OUTPUT; const rendererPath = process.env.MHU_AUTOMATION_RENDERER; const preloadPath = process.env.MHU_AUTOMATION_PRELOAD; @@ -3650,18 +3810,23 @@ ${startupAutomation} }); assert.equal(outcome.result.error, undefined); assert.equal(outcome.result.initial.paused, true); + assert.equal(outcome.result.testScan.reachable, true); + assert.equal(outcome.result.testScan.trigger, 'test'); + assert.equal(outcome.result.testScan.files.length, 1); + assert.equal(outcome.result.testScan.files[0].name, 'manual-preview.mkv'); assert.deepEqual(outcome.result.monitorStart, { error: 'Automatik ist pausiert' }); - assert.equal(outcome.result.reconcile.ok, false); + assert.equal(outcome.result.reconcile.ok, true); + assert.deepEqual(outcome.result.reconcile.value, { error: 'Automatik ist pausiert' }); assert.deepEqual(outcome.result.start, { error: 'Automatik ist pausiert' }); assert.deepEqual(outcome.result.extend, { error: 'Automatik ist pausiert' }); assert.equal(outcome.result.previewStatus, 'preview'); assert.equal(outcome.result.beforeResume.watcherStarts, 0); assert.equal(outcome.result.beforeResume.intervalCount, 0); - assert.equal(outcome.result.beforeResume.walkCalls, 0); + assert.equal(outcome.result.beforeResume.walkCalls, 1); assert.equal(outcome.result.resume.paused, false); assert.equal(outcome.result.afterResume.watcherStarts, 1); assert.equal(outcome.result.afterResume.intervalCount, 1); - assert.equal(outcome.result.afterResume.walkCalls, 1); + assert.equal(outcome.result.afterResume.walkCalls, 2); assert.equal(outcome.result.afterResume.newFileEvents, 1); assert.equal(outcome.result.afterResume.addJobsCalls, 0); assert.equal(outcome.result.afterResume.startBatchCalls, 0); @@ -3673,7 +3838,7 @@ ${startupAutomation} assert.equal(outcome.result.final.finishCalls, 1); assert.equal(outcome.result.final.watcherCloseCalls, 1); assert.equal(outcome.result.final.intervalCount, 0); - assert.equal(outcome.result.final.walkCalls, 1); + assert.equal(outcome.result.final.walkCalls, 2); assert.equal(outcome.result.final.configPaused, true); assert.equal(outcome.result.final.monitor.paused, true); assert.equal(outcome.result.final.monitor.running, false); @@ -3685,6 +3850,7 @@ ${startupAutomation} test('real app resume keeps the ConfigStore-restored manual preview byte-identical without starting work', { skip: process.platform !== 'win32' }, () => { const projectRoot = path.join(__dirname, '..'); const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-real-resume-e2e-')); + try { const appRoot = path.join(probeRoot, 'app'); const userDataPath = path.join(probeRoot, 'user-data'); const outputPath = path.join(probeRoot, 'result.json'); @@ -3820,7 +3986,6 @@ async function waitFor(read, timeoutMs = 20000) { }); `; fs.writeFileSync(probePath, probeSource, 'utf8'); - try { const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe'); const probeEnvironment = { ...process.env, diff --git a/tests/upload-audit.test.js b/tests/upload-audit.test.js index 84266c9..6081b2e 100644 --- a/tests/upload-audit.test.js +++ b/tests/upload-audit.test.js @@ -7,6 +7,7 @@ const path = require('path'); const { createInternalLogPathResolver, createInternalLogWriter, + createBufferedInternalLogFlusher, createUploadAuditWriter, getLogOpenDirectory } = require('../lib/upload-audit'); @@ -128,10 +129,46 @@ test('synchronous rotation flush falls back completely and retains buffered line test('quit flush delegates the rotation buffer to the synchronous internal writer', () => { const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); - assert.match(mainSource, /_rotLogWriter\.flushSync\(_rotLogBuffer, 'rot-log'\);/); + assert.match(mainSource, /_rotLogFlusher\.flushSync\('rot-log'\);/); assert.doesNotMatch(mainSource, /appendFileSync\(getRotLogPath\(\), _rotLogBuffer\.join\(''\)/); }); +test('asynchronous rotation flush restores a failed chunk ahead of newer lines without a retry loop', async () => { + const buffer = ['first\n', 'second\n']; + const scheduled = []; + let resolveAppend; + const appendCalls = []; + const syncCalls = []; + const writer = { + append(value) { + appendCalls.push(value); + return new Promise(resolve => { resolveAppend = resolve; }); + }, + flushSync(lines) { + syncCalls.push([...lines]); + lines.length = 0; + return true; + } + }; + const flusher = createBufferedInternalLogFlusher({ + buffer, + writer, + schedule: callback => scheduled.push(callback) + }); + + const failed = flusher.flush('rot-log'); + assert.deepEqual(buffer, []); + buffer.push('third\n'); + resolveAppend(false); + assert.equal(await failed, false); + assert.deepEqual(buffer, ['first\n', 'second\n', 'third\n']); + assert.deepEqual(appendCalls, ['first\nsecond\n']); + assert.deepEqual(scheduled, []); + assert.equal(flusher.flushSync('rot-log'), true); + assert.deepEqual(syncCalls, [['first\n', 'second\n', 'third\n']]); + assert.deepEqual(buffer, []); +}); + test('upload audit writer leaves the configured fileuploader log contract unchanged', async (t) => { const directory = createTempDirectory(t, 'mhu-upload-log-contract-'); const userDataPath = path.join(directory, 'user-data');