diff --git a/lib/folder-monitor.js b/lib/folder-monitor.js index c27da7e..128cf37 100644 --- a/lib/folder-monitor.js +++ b/lib/folder-monitor.js @@ -30,6 +30,7 @@ class FolderMonitor extends EventEmitter { this._settings = null; this._seenFiles = new Set(); this._batchBuffer = []; + this._batchSeenReservations = new Set(); this._batchTimer = null; this._initialScopes = new Set(); this._reconcileTimer = null; @@ -189,8 +190,9 @@ class FolderMonitor extends EventEmitter { _invalidateLifecycle(paused) { this._generation++; if (paused) { - for (const filePath of this._batchBuffer) this._seenFiles.delete(this._normalizePath(filePath)); + for (const normalized of this._batchSeenReservations) this._seenFiles.delete(normalized); } + this._batchSeenReservations.clear(); this._paused = paused; this._scanning = false; this._followUpRequested = false; @@ -213,7 +215,11 @@ class FolderMonitor extends EventEmitter { _onNewFile(filePath, generation) { if (!this._acceptCallback(generation)) return; - if (!this._classifyPath(filePath, this._settings).allowed || !this._acceptPath(filePath)) return; + if (!this._classifyPath(filePath, this._settings).allowed) return; + const normalized = this._normalizePath(filePath); + const ownsSeenReservation = !!this._settings?.skipDuplicates && !this._seenFiles.has(normalized); + if (!this._acceptPath(filePath)) return; + if (ownsSeenReservation) this._batchSeenReservations.add(normalized); this._batchBuffer.push(filePath); if (this._batchTimer) this._clearTimeout(this._batchTimer); this._batchTimer = this._setTimeout(() => { @@ -222,6 +228,11 @@ class FolderMonitor extends EventEmitter { this._batchTimer = null; if (files.length === 0) return; const listenerError = this._emitEvent('new-files', [files], generation); + for (const emittedPath of files) { + const emittedNormalized = this._normalizePath(emittedPath); + if (!this._batchSeenReservations.delete(emittedNormalized)) continue; + if (listenerError) this._seenFiles.delete(emittedNormalized); + } if (listenerError && this._acceptCallback(generation)) { this._lastError = 'Ordnerüberwachung fehlgeschlagen'; this._emitStatus(generation); diff --git a/tests/folder-monitor.test.js b/tests/folder-monitor.test.js index 9d77f7f..3c98058 100644 --- a/tests/folder-monitor.test.js +++ b/tests/folder-monitor.test.js @@ -374,6 +374,42 @@ test('watcher add paused before batch timeout is emitted exactly once by resume assert.equal(monitor.status().seenCount, 1); }); +test('pause rollback never deletes historical seen state from a dedupe-off batch', async () => { + const timers = createManualTimers(); + const watchers = []; + const newFiles = []; + const filePath = 'C:\\watch\\historical.mkv'; + let discoverExisting = false; + const monitor = new FolderMonitor({ + watch: () => { + const watcher = new EventEmitter(); + watcher.close = async () => {}; + watchers.push(watcher); + return watcher; + }, + access: async () => {}, + walkFolder: async () => discoverExisting ? [{ path: filePath, name: 'historical.mkv', size: 1 }] : [], + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + monitor.on('new-files', (files) => newFiles.push(files)); + const dedupeOn = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 }; + const dedupeOff = { ...dedupeOn, skipDuplicates: false }; + monitor.start(dedupeOn); + watchers[0].emit('add', filePath); + await timers.runTimeouts(); + assert.deepEqual(newFiles, [[filePath]]); + await monitor.pause(); + await monitor.resume(dedupeOff); + watchers[1].emit('add', filePath); + assert.deepEqual(newFiles, [[filePath]]); + await monitor.pause(); + discoverExisting = true; + await monitor.resume(dedupeOn); + assert.deepEqual(newFiles, [[filePath]]); + assert.equal(monitor.status().seenCount, 1); +}); + test('dry scan leaves the public status byte-identical and does not consume reconnect state', async () => { let reachable = false; const timers = createManualTimers();