Compare commits
2 Commits
96d6dfe880
...
f7c8d308fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7c8d308fc | ||
|
|
eeec1d150c |
@ -7,18 +7,21 @@
|
|||||||
// runtime and tests — no drift.
|
// runtime and tests — no drift.
|
||||||
//
|
//
|
||||||
// Behaviour: on launch the restored queue is compared against the lifetime
|
// Behaviour: on launch the restored queue is compared against the lifetime
|
||||||
// upload log. ONLY genuinely-completed ('done') jobs that also appear in the
|
// upload log. Two rules drop a job:
|
||||||
// log are dropped — that's pure decluttering of work that already finished.
|
// 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
|
// Rule 2 only fires when a savedAt is passed AND the log carries timestamps;
|
||||||
// dropped here, even if a same-name+hoster line exists in the log. Those are
|
// without them this falls back to rule 1 alone. That fallback is the invariant
|
||||||
// work the user intentionally has queued (often a deliberate re-upload of a
|
// the canary tests pin: a pending job matching an OLDER log line (ts < savedAt,
|
||||||
// file that was uploaded before). The old code filtered on log-presence alone,
|
// or no ts at all) is KEPT — it's an intentional re-upload of a file uploaded
|
||||||
// regardless of status, so the ENTIRE restored queue vanished on the next
|
// before, not a ghost. The old code filtered on log-presence alone, regardless
|
||||||
// restart/update whenever the files had been uploaded previously — surfacing as
|
// of status, so the ENTIRE restored queue vanished on the next restart/update
|
||||||
// an empty "Dateien hierhin ziehen oder klicken" queue. Manual log import
|
// whenever the files had been uploaded previously. Manual log import
|
||||||
// (importUploadLog) stays separate and explicit for users who do want bulk
|
// (importUploadLog) stays separate and explicit for bulk dedup.
|
||||||
// dedup of pending jobs.
|
|
||||||
|
|
||||||
(function (root) {
|
(function (root) {
|
||||||
'use strict';
|
'use strict';
|
||||||
@ -34,19 +37,35 @@
|
|||||||
* @param {Array<{fileName:string,hoster:string}>} logEntries
|
* @param {Array<{fileName:string,hoster:string}>} logEntries
|
||||||
* @returns {{ kept: Array, removed: Array }}
|
* @returns {{ kept: Array, removed: Array }}
|
||||||
*/
|
*/
|
||||||
function partitionRestoredJobsByLog(jobs, logEntries) {
|
function partitionRestoredJobsByLog(jobs, logEntries, savedAt) {
|
||||||
const kept = [];
|
const kept = [];
|
||||||
const removed = [];
|
const removed = [];
|
||||||
if (!Array.isArray(jobs) || jobs.length === 0) return { kept, removed };
|
if (!Array.isArray(jobs) || jobs.length === 0) return { kept, removed };
|
||||||
|
|
||||||
const logKeys = new Set();
|
const logKeys = new Set();
|
||||||
|
const logMaxTs = new Map();
|
||||||
for (const e of (Array.isArray(logEntries) ? logEntries : [])) {
|
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) {
|
for (const job of jobs) {
|
||||||
const isDone = job && job.status === 'done' && job.fileName && job.hoster;
|
const hasIds = job && job.fileName && job.hoster;
|
||||||
if (isDone && logKeys.has(_key(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);
|
removed.push(job);
|
||||||
} else {
|
} else {
|
||||||
kept.push(job);
|
kept.push(job);
|
||||||
|
|||||||
59
lib/throttle-timer.js
Normal file
59
lib/throttle-timer.js
Normal file
@ -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);
|
||||||
31
lib/upload-log.js
Normal file
31
lib/upload-log.js
Normal file
@ -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);
|
||||||
66
main.js
66
main.js
@ -17,6 +17,7 @@ const FolderMonitor = require('./lib/folder-monitor');
|
|||||||
const RemoteServer = require('./lib/remote-server');
|
const RemoteServer = require('./lib/remote-server');
|
||||||
const { maybeRotateLogFile } = require('./lib/log-rotation');
|
const { maybeRotateLogFile } = require('./lib/log-rotation');
|
||||||
const { hosterLogToFileEnabled } = require('./lib/log-policy');
|
const { hosterLogToFileEnabled } = require('./lib/log-policy');
|
||||||
|
const { formatUploadLogLine, parseUploadLogLine } = require('./lib/upload-log');
|
||||||
const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle');
|
const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle');
|
||||||
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
|
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
|
||||||
|
|
||||||
@ -597,10 +598,7 @@ function shouldLogHosterToFile(hoster) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendUploadLog(hoster, link, fileName) {
|
function appendUploadLog(hoster, link, fileName) {
|
||||||
const now = new Date();
|
_uploadLogBuffer.push(formatUploadLogLine(new Date(), hoster, link, fileName));
|
||||||
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`);
|
|
||||||
if (!_uploadLogFlushTimer) {
|
if (!_uploadLogFlushTimer) {
|
||||||
_uploadLogFlushTimer = setTimeout(() => {
|
_uploadLogFlushTimer = setTimeout(() => {
|
||||||
_uploadLogFlushTimer = null;
|
_uploadLogFlushTimer = null;
|
||||||
@ -1174,6 +1172,7 @@ app.whenReady().then(() => {
|
|||||||
verbose: _logVerbose,
|
verbose: _logVerbose,
|
||||||
pid: process.pid
|
pid: process.pid
|
||||||
});
|
});
|
||||||
|
_sweepOrphanConfigTmps();
|
||||||
createWindow();
|
createWindow();
|
||||||
createTray();
|
createTray();
|
||||||
|
|
||||||
@ -2154,14 +2153,8 @@ ipcMain.handle('read-own-upload-log', () => {
|
|||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(logPath, 'utf-8');
|
const content = fs.readFileSync(logPath, 'utf-8');
|
||||||
for (const line of content.split('\n')) {
|
for (const line of content.split('\n')) {
|
||||||
const trimmed = line.trim();
|
const parsed = parseUploadLogLine(line);
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
if (parsed) entries.push(parsed);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@ -2258,17 +2251,45 @@ ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
|
|||||||
return true;
|
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
|
// Synchronous save for beforeunload — blocks renderer until write completes
|
||||||
// Uses atomic write pattern (tmp + backup + rename) to prevent corruption.
|
// Uses atomic write pattern (tmp + backup + rename) to prevent corruption.
|
||||||
// Returns false on any failure so the renderer (which surfaces this via the
|
// Returns false on any failure so the renderer (which surfaces this via the
|
||||||
// beforeunload chain) doesn't quietly think queue + settings persisted when
|
// beforeunload chain) doesn't quietly think queue + settings persisted when
|
||||||
// they didn't. Errors are logged for diagnostics regardless.
|
// they didn't. Errors are logged for diagnostics regardless.
|
||||||
ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
|
ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
|
||||||
|
const tmpPath = configStore.filePath + '.' + process.pid + '.tmp';
|
||||||
try {
|
try {
|
||||||
const current = configStore.load();
|
const current = configStore.load();
|
||||||
current.globalSettings = globalSettings;
|
current.globalSettings = globalSettings;
|
||||||
const data = configStore._serializeForDisk(current);
|
const data = configStore._serializeForDisk(current);
|
||||||
const tmpPath = configStore.filePath + '.tmp';
|
|
||||||
const backupPath = configStore.filePath + '.bak';
|
const backupPath = configStore.filePath + '.bak';
|
||||||
fs.writeFileSync(tmpPath, data, 'utf-8');
|
fs.writeFileSync(tmpPath, data, 'utf-8');
|
||||||
if (fs.existsSync(configStore.filePath)) {
|
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}`);
|
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;
|
event.returnValue = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}
|
||||||
debugLog(`save-global-settings-sync FAILED: ${err && err.message ? err.message : err}`);
|
debugLog(`save-global-settings-sync FAILED: ${err && err.message ? err.message : err}`);
|
||||||
event.returnValue = false;
|
event.returnValue = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.79",
|
"version": "3.3.80",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -21,7 +21,32 @@ let healthCheckRunning = false;
|
|||||||
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
|
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
|
||||||
let editingAccountId = null; // null = adding, string = editing account by ID
|
let editingAccountId = null; // null = adding, string = editing account by ID
|
||||||
let autoHealthCheckEnabled = true;
|
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 settingsSaveTimer = null;
|
||||||
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
|
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
|
||||||
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
|
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
|
||||||
@ -684,6 +709,10 @@ function restoreQueueStateFromConfig() {
|
|||||||
const pending = config?.globalSettings?.pendingQueue;
|
const pending = config?.globalSettings?.pendingQueue;
|
||||||
if (!pending || typeof pending !== 'object') return;
|
if (!pending || typeof pending !== 'object') return;
|
||||||
|
|
||||||
|
_restoredSnapshotSavedAt = (typeof pending.savedAt === 'number' && isFinite(pending.savedAt))
|
||||||
|
? pending.savedAt
|
||||||
|
: null;
|
||||||
|
|
||||||
selectedUploadHosters = Array.isArray(pending.selectedUploadHosters)
|
selectedUploadHosters = Array.isArray(pending.selectedUploadHosters)
|
||||||
? pending.selectedUploadHosters.filter(Boolean)
|
? pending.selectedUploadHosters.filter(Boolean)
|
||||||
: selectedUploadHosters;
|
: selectedUploadHosters;
|
||||||
@ -757,6 +786,7 @@ function buildPersistedQueueState() {
|
|||||||
// Only true terminal states (done / error / skipped) survive as-is.
|
// Only true terminal states (done / error / skipped) survive as-is.
|
||||||
const TERMINAL = new Set(['done', 'error', 'skipped']);
|
const TERMINAL = new Set(['done', 'error', 'skipped']);
|
||||||
return {
|
return {
|
||||||
|
savedAt: Date.now(),
|
||||||
selectedUploadHosters: getSelectedHosters(),
|
selectedUploadHosters: getSelectedHosters(),
|
||||||
selectedFiles: Array.from(selectedFileMap.values()),
|
selectedFiles: Array.from(selectedFileMap.values()),
|
||||||
queueJobs: queueJobs.map(job => {
|
queueJobs: queueJobs.map(job => {
|
||||||
@ -786,21 +816,19 @@ async function persistQueueStateNow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function persistQueueStateSoon(immediate) {
|
function persistQueueStateSoon(immediate) {
|
||||||
clearTimeout(queuePersistTimer);
|
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
|
queuePersistThrottle.cancel();
|
||||||
persistQueueStateNow().catch(() => {});
|
persistQueueStateNow().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Use longer debounce during uploads to reduce disk I/O
|
const maxWait = uploading ? 20000 : undefined;
|
||||||
const delay = uploading ? 10000 : 500;
|
queuePersistThrottle.request(() => {
|
||||||
queuePersistTimer = setTimeout(() => {
|
|
||||||
persistQueueStateNow().catch(() => {});
|
persistQueueStateNow().catch(() => {});
|
||||||
}, delay);
|
}, 500, maxWait);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPersistedQueueStateSoon() {
|
function clearPersistedQueueStateSoon() {
|
||||||
clearTimeout(queuePersistTimer);
|
queuePersistThrottle.request(() => {
|
||||||
queuePersistTimer = setTimeout(() => {
|
|
||||||
const globalSettings = {
|
const globalSettings = {
|
||||||
...(config.globalSettings || {}),
|
...(config.globalSettings || {}),
|
||||||
pendingQueue: null
|
pendingQueue: null
|
||||||
@ -4609,8 +4637,7 @@ window.addEventListener('beforeunload', () => {
|
|||||||
settingsSaveTimer = null;
|
settingsSaveTimer = null;
|
||||||
try { saveSettings(); } catch {}
|
try { saveSettings(); } catch {}
|
||||||
}
|
}
|
||||||
clearTimeout(queuePersistTimer);
|
queuePersistThrottle.cancel();
|
||||||
queuePersistTimer = null;
|
|
||||||
// Drain pending done-removals synchronously before persisting so jobs the
|
// Drain pending done-removals synchronously before persisting so jobs the
|
||||||
// user expected to disappear (removeFromQueueOnDone=true) don't reappear
|
// user expected to disappear (removeFromQueueOnDone=true) don't reappear
|
||||||
// on next launch. Microtask wouldn't run before the sync IPC below.
|
// on next launch. Microtask wouldn't run before the sync IPC below.
|
||||||
@ -4912,12 +4939,12 @@ async function _autoDeduplicateFromLog() {
|
|||||||
try {
|
try {
|
||||||
const entries = await window.api.readOwnUploadLog();
|
const entries = await window.api.readOwnUploadLog();
|
||||||
if (!entries || entries.length === 0) return;
|
if (!entries || entries.length === 0) return;
|
||||||
// Only 'done' jobs are dropped here (declutter completed uploads). Pending
|
// Drops 'done' jobs present in the log (declutter) AND any job that the log
|
||||||
// and failed jobs survive even if their name+hoster is in the log — they're
|
// shows completed at/after the snapshot's savedAt (a stale 'preview' ghost).
|
||||||
// intentional queued work. Decision lives in lib/queue-dedup.js (Node-tested,
|
// Pending jobs matching only OLDER log lines survive — intentional re-uploads.
|
||||||
// see tests/queue-dedup.test.js) so it can't silently regress to nuking the
|
// Decision lives in lib/queue-dedup.js (Node-tested, see tests/queue-dedup.test.js)
|
||||||
// whole restored queue on restart/update.
|
// so it can't silently regress to nuking the whole restored queue on restart.
|
||||||
const { kept, removed } = window.QueueDedup.partitionRestoredJobsByLog(queueJobs, entries);
|
const { kept, removed } = window.QueueDedup.partitionRestoredJobsByLog(queueJobs, entries, _restoredSnapshotSavedAt);
|
||||||
if (removed.length > 0) {
|
if (removed.length > 0) {
|
||||||
queueJobs = kept;
|
queueJobs = kept;
|
||||||
for (const job of removed) {
|
for (const job of removed) {
|
||||||
|
|||||||
@ -420,6 +420,7 @@
|
|||||||
<script src="../lib/stats.js"></script>
|
<script src="../lib/stats.js"></script>
|
||||||
<script src="../lib/throttled-cache.js"></script>
|
<script src="../lib/throttled-cache.js"></script>
|
||||||
<script src="../lib/coalesced-set.js"></script>
|
<script src="../lib/coalesced-set.js"></script>
|
||||||
|
<script src="../lib/throttle-timer.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,22 +1,58 @@
|
|||||||
# Feature: Per-Hoster Toggle "Links in fileuploader.log schreiben"
|
# Queue-Persistenz Bug: fertige Dateien tauchen nach Neustart wieder auf
|
||||||
|
|
||||||
## Goal
|
## Symptom
|
||||||
Pro Hoster ein-/ausschaltbar machen ob dessen erfolgreiche Upload-Links in die fileuploader.log geschrieben werden.
|
User: 300 Dateien, 100 übrig, Programm schließen + öffnen → manchmal sind bereits
|
||||||
|
fertig hochgeladene Dateien wieder in der Liste.
|
||||||
|
|
||||||
## Plan
|
## Root Cause (verifiziert im Code)
|
||||||
- [x] `lib/config-store.js` — `logToFile: true` zu `HOSTER_SETTINGS_DEFAULTS` (default an).
|
- **RC-1 (Persist-Starvation, code-confirmed):** `persistQueueStateSoon()` setzt bei
|
||||||
- [x] `renderer/app.js renderSettings` — Checkbox "Links in Log schreiben" pro Hoster-Panel (`data-hs="logToFile"`, type=checkbox).
|
jedem Progress-Event den Timer per `clearTimeout` zurück; Delay während Upload war
|
||||||
- [x] `renderer/app.js saveSettings` — collection-loop erweitert: checkbox → boolean.
|
10000ms. Progress-Events feuern öfter als alle 10s → Timer feuert NIE während eines
|
||||||
- [x] `lib/log-policy.js` (neu, testbar) — `hosterLogToFileEnabled(hosterSettings, hoster)`, opt-out semantics.
|
aktiven Uploads. Der Disk-Snapshot bleibt auf dem Stand VOR Upload-Start stehen
|
||||||
- [x] `main.js` — `shouldLogHosterToFile(hoster)` liest live uploadManager.hosterSettings, fallback configStore, dann default true. Guard vor appendUploadLog im done-handler.
|
(alle Jobs `preview`).
|
||||||
- [x] Tests: 8 log-policy + 2 config-store (default true, persist false). 147/147 grün.
|
- **RC-2 (unzuverlässiger Close-Flush):** beforeunload-Sync-Flush existiert
|
||||||
- [x] ESLint clean. Backup-Import robust (default-true bei fehlendem key).
|
(app.js:4605) und fängt den sauberen Close ab. Bei hartem Kill / Crash / OS-Kill
|
||||||
|
läuft er nicht → der stale Snapshot bleibt liegen.
|
||||||
|
- **RC-3 (Dedup-Asymmetrie):** `_autoDeduplicateFromLog` droppt beim Start nur Jobs
|
||||||
|
mit Status `done`. Die Ghosts aus dem stale Snapshot stehen aber als `preview` da
|
||||||
|
→ werden NICHT gedroppt → fertige Dateien erscheinen erneut.
|
||||||
|
|
||||||
## Verifikation
|
## Fix (mechanismus-unabhängig, vom Kern auf)
|
||||||
- logToFile default true → bestehendes Verhalten unverändert für alle die's nicht togglen.
|
- [x] **FIX A — Timestamp-gated Dedup (Kern-Fix, durable):** Beim Start jeden restored
|
||||||
- Toggle off für Hoster X → uploads von X werden NICHT geloggt, andere Hoster weiter schon.
|
Job droppen, dessen file+hoster im Log mit `ts >= floor(savedAt)` steht — egal ob
|
||||||
- Live-Wirkung: `uploadManager.hosterSettings` wird via updateSettings aktualisiert → greift auch mid-batch nach save.
|
`preview` oder `done`. Fängt Ghosts auch nach hartem Kill (hängt vom Log ab, nicht
|
||||||
|
vom Snapshot). `lib/queue-dedup.js` additiver 3. Param `savedAt`; `buildPersistedQueueState`
|
||||||
|
stempelt `savedAt`; `restoreQueueStateFromConfig` merkt `_restoredSnapshotSavedAt`;
|
||||||
|
Log-Zeile → `ts` geparst; `_autoDeduplicateFromLog` reicht savedAt durch.
|
||||||
|
- [x] **FIX B — Throttle mit max-wait:** `lib/throttle-timer.js` (neu). Upload: delay 500
|
||||||
|
+ maxWait 20000 → Snapshot alle ~20s statt nie. Idle: reine Debounce. Fallback-Shim
|
||||||
|
honoriert maxWait (kein stilles Starvation-Reintro).
|
||||||
|
- [x] **FIX C — Close-Write-Härtung:** `save-global-settings-sync` renameSync-Retry bei
|
||||||
|
EBUSY/EPERM/EACCES + pid-unique tmp + tmp-cleanup. Startup-Sweep `_sweepOrphanConfigTmps`
|
||||||
|
räumt verwaiste `<config>.<pid>.tmp` toter PIDs (gegen Orphan-Akkumulation).
|
||||||
|
- [x] **Seam-Extraktion (Advisor #2):** `lib/upload-log.js` (neu) — `formatUploadLogLine`
|
||||||
|
+ `parseUploadLogLine` aus main.js gezogen; Test fährt den ECHTEN Writer→Reader→Gate-
|
||||||
|
Vertrag (kein Mirror) → fängt künftige Format-/Epoch-Brüche.
|
||||||
|
|
||||||
## Seiteneffekte zu prüfen
|
## Tests
|
||||||
- Backup-Import/Export: hosterSettings inkl. logToFile mitnehmen (sollte automatisch da generisches Objekt).
|
- [x] `tests/throttle-timer.test.js`: Starvation ohne maxWait → 0 Fires; mit maxWait →
|
||||||
- Settings-autosave (checkbox change-event ist bereits gehandhabt in der bind-loop).
|
periodische Fires; last-write-wins (distinct fn); flushSync/cancel.
|
||||||
|
- [x] `tests/queue-dedup.test.js`: ts>=savedAt→DROP; ts<savedAt→KEEP; same-second→DROP;
|
||||||
|
max-ts; Multi-Hoster Teilabschluss (reale Bug-Form); ohne savedAt/ohne ts→Legacy.
|
||||||
|
- [x] `tests/upload-log.test.js`: realer Writer→Reader-Roundtrip + Seam-Drop/Keep.
|
||||||
|
- [x] 334/334 grün, ESLint clean, Smoke-Boot identisch zu Baseline (kein Regress).
|
||||||
|
|
||||||
|
## Review
|
||||||
|
- **Adversariale Multi-Agent-Review (4 Dimensionen, 15 Findings):** 14 refuted (meist
|
||||||
|
"ist korrekt"-Bestätigungen, Kommentar-Drift, Test-Härtungs-Vorschläge). 1 confirmed
|
||||||
|
(LOW): pid-unique tmp konnte bei Hard-Kill zwischen write und rename verwaisen → mit
|
||||||
|
Startup-Sweep behoben. Stale-Kommentare (queue-dedup Header + _autoDeduplicateFromLog)
|
||||||
|
auf die Zwei-Regel-Logik korrigiert.
|
||||||
|
- **Was bewiesen ist:** Komponenten-Logik (Unit-Tests inkl. realer Format-Seam),
|
||||||
|
Code-getraceter Wiring-Pfad, adversariale Gegenprüfung. Der Fix ist
|
||||||
|
MECHANISMUS-UNABHÄNGIG: greift egal ob der stale Snapshot von Starvation, einem
|
||||||
|
Mid-Upload-Close-Race ODER einem Hard-Kill kommt.
|
||||||
|
- **Ehrliche Einschränkung:** KEIN Live-Repro mit echtem byse-Key (Key unter anderem
|
||||||
|
Windows-Profil verschlüsselt, nicht entschlüsselbar). Symptom tritt nur auf bei
|
||||||
|
Close WÄHREND aktivem Upload oder Hard-Kill — ein sauberer Idle-Close war schon
|
||||||
|
vorher korrekt.
|
||||||
|
|||||||
@ -70,3 +70,92 @@ test('empty/missing inputs do not throw', () => {
|
|||||||
const jobs = [job('done', 'x.mkv', 'voe.sx')];
|
const jobs = [job('done', 'x.mkv', 'voe.sx')];
|
||||||
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
|
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const T = (s) => Date.parse(s.replace(' ', 'T'));
|
||||||
|
|
||||||
|
test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'completed-after-snapshot preview is a ghost → drop');
|
||||||
|
assert.equal(kept.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: preview job whose only log entry PREDATES the snapshot is kept (intentional re-upload)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 0, 'old upload + freshly-queued re-upload must survive');
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: same-second completion is dropped (savedAt floored to the second)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:00') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00') + 800;
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'log second-granularity must not let same-second ghosts slip through');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: uses the MAX log ts per key (re-upload after a stale earlier entry)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [
|
||||||
|
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') },
|
||||||
|
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }
|
||||||
|
];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'newest matching log entry decides');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate inactive without savedAt → legacy behavior (preview kept even if ts newer)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.equal(removed.length, 0);
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate inactive when log entry lacks ts → legacy behavior', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx' }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 0);
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: done job uploaded after snapshot is dropped via either rule', () => {
|
||||||
|
const jobs = [job('done', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: multi-hoster partial completion — the reported bug shape (drop only the completed hosters)', () => {
|
||||||
|
// One file queued to 4 hosters; close mid-upload. After the snapshot, 2 hosters
|
||||||
|
// completed (logged), 2 never started. On restart all 4 restore as 'preview'.
|
||||||
|
// Must drop EXACTLY the 2 that completed and keep the 2 still-pending. This also
|
||||||
|
// pins per-hoster keying: a fileName-only gate would wrongly drop all 4.
|
||||||
|
const f = 'Einfach mal die Fresse halten!!!.mp4';
|
||||||
|
const jobs = [
|
||||||
|
job('preview', f, 'doodstream.com'),
|
||||||
|
job('preview', f, 'voe.sx'),
|
||||||
|
job('preview', f, 'vidmoly.me'),
|
||||||
|
job('preview', f, 'byse.sx')
|
||||||
|
];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const log = [
|
||||||
|
{ fileName: f, hoster: 'doodstream.com', ts: T('2026-06-19 12:00:08') },
|
||||||
|
{ fileName: f, hoster: 'voe.sx', ts: T('2026-06-19 12:00:11') }
|
||||||
|
];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 2, 'only the 2 completed-after-snapshot hosters drop');
|
||||||
|
assert.ok(removed.every(j => j.hoster === 'doodstream.com' || j.hoster === 'voe.sx'));
|
||||||
|
assert.equal(kept.length, 2, 'the 2 never-started hosters survive');
|
||||||
|
assert.ok(kept.some(j => j.hoster === 'vidmoly.me'));
|
||||||
|
assert.ok(kept.some(j => j.hoster === 'byse.sx'));
|
||||||
|
});
|
||||||
|
|||||||
133
tests/throttle-timer.test.js
Normal file
133
tests/throttle-timer.test.js
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { makeThrottleTimer } = require('../lib/throttle-timer');
|
||||||
|
|
||||||
|
function fakeClock() {
|
||||||
|
let t = 0;
|
||||||
|
let timers = [];
|
||||||
|
return {
|
||||||
|
now: () => t,
|
||||||
|
schedule: (cb, ms) => {
|
||||||
|
const h = { at: t + ms, cb, dead: false };
|
||||||
|
timers.push(h);
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clear: (h) => { if (h) h.dead = true; },
|
||||||
|
advance: (ms) => {
|
||||||
|
const target = t + ms;
|
||||||
|
for (;;) {
|
||||||
|
let next = null;
|
||||||
|
for (const h of timers) {
|
||||||
|
if (!h.dead && h.at <= target && (next === null || h.at < next.at)) next = h;
|
||||||
|
}
|
||||||
|
if (!next) break;
|
||||||
|
t = next.at;
|
||||||
|
next.dead = true;
|
||||||
|
next.cb();
|
||||||
|
}
|
||||||
|
t = target;
|
||||||
|
timers = timers.filter(h => !h.dead);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('idle debounce: fires once after the delay window', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
c.advance(499);
|
||||||
|
assert.equal(fired, 0);
|
||||||
|
c.advance(1);
|
||||||
|
assert.equal(fired, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle debounce: rapid requests reset the timer (last wins)', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
c.advance(200);
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
c.advance(300);
|
||||||
|
assert.equal(fired, 0, 'should not fire at original 500 — was reset');
|
||||||
|
c.advance(200);
|
||||||
|
assert.equal(fired, 1, 'fires 500ms after the second request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STARVATION repro: continuous requests with no maxWait NEVER fire', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
c.advance(100);
|
||||||
|
}
|
||||||
|
assert.equal(fired, 0, 'this is exactly the bug the maxWait fix addresses');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxWait: continuous requests still force a fire within the window', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
const fireAtTimes = [];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
tt.request(() => { fired++; fireAtTimes.push(c.now()); }, 500, 2000);
|
||||||
|
c.advance(100);
|
||||||
|
}
|
||||||
|
assert.ok(fired >= 1, 'maxWait guarantees at least one fire under continuous load');
|
||||||
|
assert.ok(fireAtTimes.every(t => t > 0), 'fires happened, not starved');
|
||||||
|
assert.ok(fireAtTimes.some(t => t <= 2000), 'first fire no later than maxWait');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxWait: after a forced fire a fresh burst starts (periodic fires)', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
tt.request(() => fired++, 500, 2000);
|
||||||
|
c.advance(100);
|
||||||
|
}
|
||||||
|
assert.ok(fired >= 2, `~6000ms of continuous load with 2000ms maxWait should fire multiple times, got ${fired}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flushSync fires the pending fn immediately and clears it', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
tt.request(() => fired++, 5000);
|
||||||
|
assert.ok(tt.isPending());
|
||||||
|
tt.flushSync();
|
||||||
|
assert.equal(fired, 1);
|
||||||
|
assert.ok(!tt.isPending());
|
||||||
|
c.advance(10000);
|
||||||
|
assert.equal(fired, 1, 'no double fire after flushSync');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel drops the pending fn — no fire', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
tt.cancel();
|
||||||
|
assert.ok(!tt.isPending());
|
||||||
|
c.advance(10000);
|
||||||
|
assert.equal(fired, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('last-write-wins: a later request with a DIFFERENT fn replaces the earlier one', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
const fired = [];
|
||||||
|
tt.request(() => fired.push('persist'), 500, 20000);
|
||||||
|
c.advance(100);
|
||||||
|
tt.request(() => fired.push('clear'), 0);
|
||||||
|
c.advance(100);
|
||||||
|
assert.deepEqual(fired, ['clear'], 'only the latest fn fires; the persist was dropped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flushSync with nothing pending is a no-op', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
assert.doesNotThrow(() => tt.flushSync());
|
||||||
|
});
|
||||||
52
tests/upload-log.test.js
Normal file
52
tests/upload-log.test.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
|
||||||
|
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
||||||
|
|
||||||
|
function previewJob(fileName, hoster) {
|
||||||
|
return { status: 'preview', fileName, hoster, file: `C:/dl/${fileName}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('writer -> reader round trip: parsed ts is the same epoch frame as the source Date getTime', () => {
|
||||||
|
const d = new Date(2026, 5, 19, 12, 0, 30);
|
||||||
|
const line = formatUploadLogLine(d, 'voe.sx', 'https://voe.sx/x', 'a.mkv');
|
||||||
|
const parsed = parseUploadLogLine(line);
|
||||||
|
assert.equal(parsed.hoster, 'voe.sx');
|
||||||
|
assert.equal(parsed.fileName, 'a.mkv');
|
||||||
|
assert.equal(parsed.ts, d.getTime(), 'parser ts must equal the writer Date epoch (no tz shift)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEAM: a real appendUploadLog-format line drops a preview ghost vs a savedAt taken BEFORE completion', () => {
|
||||||
|
const completion = new Date(2026, 5, 19, 12, 0, 30);
|
||||||
|
const line = formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv');
|
||||||
|
const parsed = parseUploadLogLine(line);
|
||||||
|
const savedAt = completion.getTime() - 5000;
|
||||||
|
const { removed, kept } = partitionRestoredJobsByLog([previewJob('a.mkv', 'voe.sx')], [parsed], savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'a file logged after the snapshot is a ghost and must drop');
|
||||||
|
assert.equal(kept.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEAM: the same real line is KEPT vs a savedAt taken AFTER completion (intentional re-upload)', () => {
|
||||||
|
const completion = new Date(2026, 5, 19, 12, 0, 30);
|
||||||
|
const parsed = parseUploadLogLine(formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv'));
|
||||||
|
const savedAt = completion.getTime() + 5000;
|
||||||
|
const { removed, kept } = partitionRestoredJobsByLog([previewJob('a.mkv', 'voe.sx')], [parsed], savedAt);
|
||||||
|
assert.equal(removed.length, 0, 'an older upload than the snapshot is a deliberate re-queue and must survive');
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUploadLogLine skips comments, blanks and malformed lines', () => {
|
||||||
|
assert.equal(parseUploadLogLine('# fileuploader log'), null);
|
||||||
|
assert.equal(parseUploadLogLine(''), null);
|
||||||
|
assert.equal(parseUploadLogLine(' '), null);
|
||||||
|
assert.equal(parseUploadLogLine('only|three|parts|here'), null);
|
||||||
|
assert.equal(parseUploadLogLine(null), null);
|
||||||
|
assert.equal(parseUploadLogLine(42), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUploadLogLine: missing/garbage timestamp yields ts=undefined (legacy lines still match by name)', () => {
|
||||||
|
const parsed = parseUploadLogLine('|voe.sx|link||a.mkv|');
|
||||||
|
assert.equal(parsed.hoster, 'voe.sx');
|
||||||
|
assert.equal(parsed.fileName, 'a.mkv');
|
||||||
|
assert.equal(parsed.ts, undefined);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user