diff --git a/lib/queue-dedup.js b/lib/queue-dedup.js
index 384f304..3614c5d 100644
--- a/lib/queue-dedup.js
+++ b/lib/queue-dedup.js
@@ -7,18 +7,21 @@
// runtime and tests — no drift.
//
// Behaviour: on launch the restored queue is compared against the lifetime
-// upload log. ONLY genuinely-completed ('done') jobs that also appear in the
-// log are dropped — that's pure decluttering of work that already finished.
+// upload log. Two rules drop a job:
+// 1) a 'done' job whose fileName|hoster appears in the log (declutter of
+// already-finished work), and
+// 2) ANY job (incl. preview) whose newest matching log entry is timestamped
+// at/after the snapshot's savedAt — it provably completed AFTER the queue
+// was last persisted, so a restored 'preview' row for it is a stale ghost.
//
-// Pending jobs (preview / queued) and failed ones (error / aborted) are NEVER
-// dropped here, even if a same-name+hoster line exists in the log. Those are
-// work the user intentionally has queued (often a deliberate re-upload of a
-// file that was uploaded before). The old code filtered on log-presence alone,
-// regardless of status, so the ENTIRE restored queue vanished on the next
-// restart/update whenever the files had been uploaded previously — surfacing as
-// an empty "Dateien hierhin ziehen oder klicken" queue. Manual log import
-// (importUploadLog) stays separate and explicit for users who do want bulk
-// dedup of pending jobs.
+// Rule 2 only fires when a savedAt is passed AND the log carries timestamps;
+// without them this falls back to rule 1 alone. That fallback is the invariant
+// the canary tests pin: a pending job matching an OLDER log line (ts < savedAt,
+// or no ts at all) is KEPT — it's an intentional re-upload of a file uploaded
+// before, not a ghost. The old code filtered on log-presence alone, regardless
+// of status, so the ENTIRE restored queue vanished on the next restart/update
+// whenever the files had been uploaded previously. Manual log import
+// (importUploadLog) stays separate and explicit for bulk dedup.
(function (root) {
'use strict';
@@ -34,19 +37,35 @@
* @param {Array<{fileName:string,hoster:string}>} logEntries
* @returns {{ kept: Array, removed: Array }}
*/
- function partitionRestoredJobsByLog(jobs, logEntries) {
+ function partitionRestoredJobsByLog(jobs, logEntries, savedAt) {
const kept = [];
const removed = [];
if (!Array.isArray(jobs) || jobs.length === 0) return { kept, removed };
const logKeys = new Set();
+ const logMaxTs = new Map();
for (const e of (Array.isArray(logEntries) ? logEntries : [])) {
- if (e && e.fileName && e.hoster) logKeys.add(_key(e.fileName, e.hoster));
+ if (e && e.fileName && e.hoster) {
+ const k = _key(e.fileName, e.hoster);
+ logKeys.add(k);
+ if (typeof e.ts === 'number' && isFinite(e.ts)) {
+ const prev = logMaxTs.get(k);
+ if (prev === undefined || e.ts > prev) logMaxTs.set(k, e.ts);
+ }
+ }
}
+ const savedAtFloor = (typeof savedAt === 'number' && isFinite(savedAt))
+ ? Math.floor(savedAt / 1000) * 1000
+ : null;
+
for (const job of jobs) {
- const isDone = job && job.status === 'done' && job.fileName && job.hoster;
- if (isDone && logKeys.has(_key(job.fileName, job.hoster))) {
+ const hasIds = job && job.fileName && job.hoster;
+ const k = hasIds ? _key(job.fileName, job.hoster) : null;
+ const doneInLog = job && job.status === 'done' && hasIds && logKeys.has(k);
+ const uploadedAfterSnapshot = savedAtFloor !== null && k !== null
+ && logMaxTs.has(k) && logMaxTs.get(k) >= savedAtFloor;
+ if (doneInLog || uploadedAfterSnapshot) {
removed.push(job);
} else {
kept.push(job);
diff --git a/lib/throttle-timer.js b/lib/throttle-timer.js
new file mode 100644
index 0000000..c4a9d52
--- /dev/null
+++ b/lib/throttle-timer.js
@@ -0,0 +1,59 @@
+(function (root) {
+ 'use strict';
+
+ function makeThrottleTimer(opts) {
+ const o = opts || {};
+ const now = typeof o.now === 'function' ? o.now : (() => Date.now());
+ const schedule = typeof o.schedule === 'function'
+ ? o.schedule
+ : ((cb, ms) => setTimeout(cb, ms));
+ const clear = typeof o.clear === 'function' ? o.clear : ((h) => clearTimeout(h));
+
+ let handle = null;
+ let burstStart = null;
+ let pendingFn = null;
+
+ function fire() {
+ handle = null;
+ burstStart = null;
+ const fn = pendingFn;
+ pendingFn = null;
+ if (typeof fn === 'function') fn();
+ }
+
+ function request(fn, delay, maxWait) {
+ if (typeof fn === 'function') pendingFn = fn;
+ const t = now();
+ if (burstStart === null) burstStart = t;
+ let wait = typeof delay === 'number' && delay >= 0 ? delay : 0;
+ if (typeof maxWait === 'number' && maxWait >= 0) {
+ const remaining = maxWait - (t - burstStart);
+ wait = Math.min(wait, remaining < 0 ? 0 : remaining);
+ }
+ if (handle !== null) clear(handle);
+ handle = schedule(fire, wait);
+ }
+
+ function flushSync() {
+ if (handle !== null) { clear(handle); handle = null; }
+ burstStart = null;
+ const fn = pendingFn;
+ pendingFn = null;
+ if (typeof fn === 'function') fn();
+ }
+
+ function cancel() {
+ if (handle !== null) { clear(handle); handle = null; }
+ burstStart = null;
+ pendingFn = null;
+ }
+
+ function isPending() { return handle !== null; }
+
+ return { request, flushSync, cancel, isPending };
+ }
+
+ const api = { makeThrottleTimer };
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ else if (root) root.ThrottleTimer = api;
+})(typeof window !== 'undefined' ? window : this);
diff --git a/lib/upload-log.js b/lib/upload-log.js
new file mode 100644
index 0000000..0f04630
--- /dev/null
+++ b/lib/upload-log.js
@@ -0,0 +1,31 @@
+(function (root) {
+ 'use strict';
+
+ function _pad(n) { return String(n).padStart(2, '0'); }
+
+ function formatUploadLogLine(date, hoster, link, fileName) {
+ const d = date instanceof Date ? date : new Date();
+ const dateStr = `${d.getFullYear()}-${_pad(d.getMonth() + 1)}-${_pad(d.getDate())} ` +
+ `${_pad(d.getHours())}:${_pad(d.getMinutes())}:${_pad(d.getSeconds())}`;
+ return `${dateStr}|${hoster}|${link}||${fileName}|\n`;
+ }
+
+ function parseUploadLogLine(line) {
+ if (typeof line !== 'string') return null;
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) return null;
+ const parts = trimmed.split('|');
+ if (parts.length < 5) return null;
+ const hoster = (parts[1] || '').trim();
+ const fileName = (parts[4] || '').trim();
+ if (!hoster || !fileName) return null;
+ const tsStr = (parts[0] || '').trim();
+ const tsParsed = tsStr ? Date.parse(tsStr.replace(' ', 'T')) : NaN;
+ const ts = isNaN(tsParsed) ? undefined : tsParsed;
+ return { hoster, fileName, ts };
+ }
+
+ const api = { formatUploadLogLine, parseUploadLogLine };
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ else if (root) root.UploadLog = api;
+})(typeof window !== 'undefined' ? window : this);
diff --git a/main.js b/main.js
index e3d8bcc..81a42ce 100644
--- a/main.js
+++ b/main.js
@@ -17,6 +17,7 @@ const FolderMonitor = require('./lib/folder-monitor');
const RemoteServer = require('./lib/remote-server');
const { maybeRotateLogFile } = require('./lib/log-rotation');
const { hosterLogToFileEnabled } = require('./lib/log-policy');
+const { formatUploadLogLine, parseUploadLogLine } = require('./lib/upload-log');
const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle');
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
@@ -597,10 +598,7 @@ function shouldLogHosterToFile(hoster) {
}
function appendUploadLog(hoster, link, fileName) {
- const now = new Date();
- const pad = (n) => String(n).padStart(2, '0');
- const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
- _uploadLogBuffer.push(`${dateStr}|${hoster}|${link}||${fileName}|\n`);
+ _uploadLogBuffer.push(formatUploadLogLine(new Date(), hoster, link, fileName));
if (!_uploadLogFlushTimer) {
_uploadLogFlushTimer = setTimeout(() => {
_uploadLogFlushTimer = null;
@@ -1174,6 +1172,7 @@ app.whenReady().then(() => {
verbose: _logVerbose,
pid: process.pid
});
+ _sweepOrphanConfigTmps();
createWindow();
createTray();
@@ -2154,14 +2153,8 @@ ipcMain.handle('read-own-upload-log', () => {
try {
const content = fs.readFileSync(logPath, 'utf-8');
for (const line of content.split('\n')) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('#')) continue;
- const parts = trimmed.split('|');
- if (parts.length >= 5) {
- const hoster = (parts[1] || '').trim();
- const fileName = (parts[4] || '').trim();
- if (hoster && fileName) entries.push({ hoster, fileName });
- }
+ const parsed = parseUploadLogLine(line);
+ if (parsed) entries.push(parsed);
}
} catch {}
}
@@ -2258,17 +2251,45 @@ ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
return true;
});
+function _sleepSyncMs(ms) {
+ try {
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+ } catch {
+ const end = Date.now() + ms;
+ while (Date.now() < end) { /* spin */ }
+ }
+}
+
+function _sweepOrphanConfigTmps() {
+ try {
+ const dir = path.dirname(configStore.filePath);
+ const prefix = path.basename(configStore.filePath) + '.';
+ const suffix = '.tmp';
+ for (const file of fs.readdirSync(dir)) {
+ if (!file.startsWith(prefix) || !file.endsWith(suffix)) continue;
+ const mid = file.slice(prefix.length, file.length - suffix.length);
+ if (!/^\d+$/.test(mid)) continue;
+ const pid = Number(mid);
+ if (pid === process.pid) continue;
+ let alive = false;
+ try { process.kill(pid, 0); alive = true; } catch (e) { alive = !!(e && e.code === 'EPERM'); }
+ if (alive) continue;
+ try { fs.unlinkSync(path.join(dir, file)); } catch {}
+ }
+ } catch {}
+}
+
// Synchronous save for beforeunload — blocks renderer until write completes
// Uses atomic write pattern (tmp + backup + rename) to prevent corruption.
// Returns false on any failure so the renderer (which surfaces this via the
// beforeunload chain) doesn't quietly think queue + settings persisted when
// they didn't. Errors are logged for diagnostics regardless.
ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
+ const tmpPath = configStore.filePath + '.' + process.pid + '.tmp';
try {
const current = configStore.load();
current.globalSettings = globalSettings;
const data = configStore._serializeForDisk(current);
- const tmpPath = configStore.filePath + '.tmp';
const backupPath = configStore.filePath + '.bak';
fs.writeFileSync(tmpPath, data, 'utf-8');
if (fs.existsSync(configStore.filePath)) {
@@ -2284,9 +2305,26 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
debugLog(`save-global-settings-sync: backup read/write skipped: ${bakErr.message}`);
}
}
- fs.renameSync(tmpPath, configStore.filePath);
+ let renamed = false;
+ let lastErr = null;
+ for (let attempt = 0; attempt < 5 && !renamed; attempt++) {
+ try {
+ fs.renameSync(tmpPath, configStore.filePath);
+ renamed = true;
+ } catch (renameErr) {
+ lastErr = renameErr;
+ const code = renameErr && renameErr.code;
+ if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') {
+ _sleepSyncMs(40);
+ } else {
+ throw renameErr;
+ }
+ }
+ }
+ if (!renamed) throw lastErr || new Error('renameSync failed');
event.returnValue = true;
} catch (err) {
+ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}
debugLog(`save-global-settings-sync FAILED: ${err && err.message ? err.message : err}`);
event.returnValue = false;
}
diff --git a/renderer/app.js b/renderer/app.js
index 8f13ad3..d126891 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -21,7 +21,32 @@ let healthCheckRunning = false;
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
let editingAccountId = null; // null = adding, string = editing account by ID
let autoHealthCheckEnabled = true;
-let queuePersistTimer = null;
+const queuePersistThrottle = (window.ThrottleTimer && window.ThrottleTimer.makeThrottleTimer)
+ ? window.ThrottleTimer.makeThrottleTimer()
+ : (function () {
+ let h = null;
+ let fn = null;
+ let burstStart = null;
+ function go() { h = null; burstStart = null; const g = fn; fn = null; if (g) g(); }
+ return {
+ request(f, d, maxWait) {
+ if (typeof f === 'function') fn = f;
+ const t = Date.now();
+ if (burstStart === null) burstStart = t;
+ let wait = typeof d === 'number' && d >= 0 ? d : 0;
+ if (typeof maxWait === 'number' && maxWait >= 0) {
+ const remaining = maxWait - (t - burstStart);
+ wait = Math.min(wait, remaining < 0 ? 0 : remaining);
+ }
+ clearTimeout(h);
+ h = setTimeout(go, wait);
+ },
+ flushSync() { clearTimeout(h); h = null; burstStart = null; const g = fn; fn = null; if (g) g(); },
+ cancel() { clearTimeout(h); h = null; burstStart = null; fn = null; },
+ isPending() { return h !== null; }
+ };
+ })();
+let _restoredSnapshotSavedAt = null;
let settingsSaveTimer = null;
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
@@ -684,6 +709,10 @@ function restoreQueueStateFromConfig() {
const pending = config?.globalSettings?.pendingQueue;
if (!pending || typeof pending !== 'object') return;
+ _restoredSnapshotSavedAt = (typeof pending.savedAt === 'number' && isFinite(pending.savedAt))
+ ? pending.savedAt
+ : null;
+
selectedUploadHosters = Array.isArray(pending.selectedUploadHosters)
? pending.selectedUploadHosters.filter(Boolean)
: selectedUploadHosters;
@@ -757,6 +786,7 @@ function buildPersistedQueueState() {
// Only true terminal states (done / error / skipped) survive as-is.
const TERMINAL = new Set(['done', 'error', 'skipped']);
return {
+ savedAt: Date.now(),
selectedUploadHosters: getSelectedHosters(),
selectedFiles: Array.from(selectedFileMap.values()),
queueJobs: queueJobs.map(job => {
@@ -786,21 +816,19 @@ async function persistQueueStateNow() {
}
function persistQueueStateSoon(immediate) {
- clearTimeout(queuePersistTimer);
if (immediate) {
+ queuePersistThrottle.cancel();
persistQueueStateNow().catch(() => {});
return;
}
- // Use longer debounce during uploads to reduce disk I/O
- const delay = uploading ? 10000 : 500;
- queuePersistTimer = setTimeout(() => {
+ const maxWait = uploading ? 20000 : undefined;
+ queuePersistThrottle.request(() => {
persistQueueStateNow().catch(() => {});
- }, delay);
+ }, 500, maxWait);
}
function clearPersistedQueueStateSoon() {
- clearTimeout(queuePersistTimer);
- queuePersistTimer = setTimeout(() => {
+ queuePersistThrottle.request(() => {
const globalSettings = {
...(config.globalSettings || {}),
pendingQueue: null
@@ -4609,8 +4637,7 @@ window.addEventListener('beforeunload', () => {
settingsSaveTimer = null;
try { saveSettings(); } catch {}
}
- clearTimeout(queuePersistTimer);
- queuePersistTimer = null;
+ queuePersistThrottle.cancel();
// Drain pending done-removals synchronously before persisting so jobs the
// user expected to disappear (removeFromQueueOnDone=true) don't reappear
// on next launch. Microtask wouldn't run before the sync IPC below.
@@ -4912,12 +4939,12 @@ async function _autoDeduplicateFromLog() {
try {
const entries = await window.api.readOwnUploadLog();
if (!entries || entries.length === 0) return;
- // Only 'done' jobs are dropped here (declutter completed uploads). Pending
- // and failed jobs survive even if their name+hoster is in the log — they're
- // intentional queued work. Decision lives in lib/queue-dedup.js (Node-tested,
- // see tests/queue-dedup.test.js) so it can't silently regress to nuking the
- // whole restored queue on restart/update.
- const { kept, removed } = window.QueueDedup.partitionRestoredJobsByLog(queueJobs, entries);
+ // Drops 'done' jobs present in the log (declutter) AND any job that the log
+ // shows completed at/after the snapshot's savedAt (a stale 'preview' ghost).
+ // Pending jobs matching only OLDER log lines survive — intentional re-uploads.
+ // Decision lives in lib/queue-dedup.js (Node-tested, see tests/queue-dedup.test.js)
+ // so it can't silently regress to nuking the whole restored queue on restart.
+ const { kept, removed } = window.QueueDedup.partitionRestoredJobsByLog(queueJobs, entries, _restoredSnapshotSavedAt);
if (removed.length > 0) {
queueJobs = kept;
for (const job of removed) {
diff --git a/renderer/index.html b/renderer/index.html
index e824b25..15b1a93 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -420,6 +420,7 @@
+