From 0a607adb29985b4d7fd548dea7ddcd1501a0273e Mon Sep 17 00:00:00 2001 From: Administrator Date: Fri, 19 Jun 2026 14:53:04 +0200 Subject: [PATCH] fix(queue): stop finished uploads from re-appearing as pending ghosts across restart The completed-upload dedup guard (_completedUploadKeys, "file|hoster") is the single source of truth that keeps a finished job from being re-materialised as a "Bereit" preview by buildQueuePreview(). Three independent paths leaked ghosts back into the queue: A) removeJobFromIndex() unconditionally DELETED the dedup key. The removeFromQueueOnDone auto-remove path (handleProgress), the batch-done sweep, and the terminal-job prune all call removeJobFromIndex on a *finished* job, so the guard handleProgress had just added was immediately wiped and buildQueuePreview re-created the job as a preview both mid-session and after restart. removeJobFromIndex now takes keepCompletedKey; the three auto-removal call sites pass true (keep the guard), while the manual-delete sites keep the old behaviour (drop the guard so the user can re-add the file). D/E) The dedup guard lived only in memory, so every restart began with an empty set and buildQueuePreview rebuilt ghosts from the persisted selectedFiles. buildPersistedQueueState() now serialises the keys whose file is still in the snapshot (completedKeys) and restoreQueueStateFromConfig() re-seeds them. To keep deliberate re-uploads working, applyHosterSelection() clears the persisted key for every freshly (re-)added file: re-adding through the hoster modal is the explicit "upload this again" signal. Both retry paths (retrySelectedJobs, _retryFailedFromBuckets) mutate the existing job in place instead of relying on buildQueuePreview, so they are unaffected. H) buildQueuePreview() excluded error jobs from its existingKeys set, so a file+hoster that already had an 'error' row got a second 'preview' row stacked beside it. Error jobs now count as existing. B/C) parseUploadLogLine() took the filename from a fixed field index and trimmed it. A pipe inside the link shifted the field (entry lost from the log-based dedup) and trimming broke matching against the untrimmed OS basename used as the queue-job key. The filename is now the last non-empty field and is no longer trimmed, so log lines with pipes in the URL and leading-space filenames both match end-to-end. 362 tests pass, lint clean on all touched files. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/upload-log.js | 5 ++++- renderer/app.js | 30 ++++++++++++++++++++++++------ tests/upload-log.test.js | 28 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/lib/upload-log.js b/lib/upload-log.js index 0f04630..6025791 100644 --- a/lib/upload-log.js +++ b/lib/upload-log.js @@ -17,7 +17,10 @@ const parts = trimmed.split('|'); if (parts.length < 5) return null; const hoster = (parts[1] || '').trim(); - const fileName = (parts[4] || '').trim(); + let fileName = ''; + for (let i = parts.length - 1; i >= 4; i--) { + if (parts[i].trim() !== '') { fileName = parts[i]; break; } + } if (!hoster || !fileName) return null; const tsStr = (parts[0] || '').trim(); const tsParsed = tsStr ? Date.parse(tsStr.replace(' ', 'T')) : NaN; diff --git a/renderer/app.js b/renderer/app.js index d0b5572..74ad8fa 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -671,6 +671,12 @@ function applyHosterSelection() { selectedFiles.push(..._pendingFiles); _pendingFiles = []; } + if (pendingPaths.size > 0) { + for (const key of [..._completedUploadKeys]) { + const sep = key.lastIndexOf('|'); + if (sep > 0 && pendingPaths.has(key.slice(0, sep))) _completedUploadKeys.delete(key); + } + } renderHosterSummary(); // During an active upload, build preview jobs for the new files and inject @@ -713,6 +719,12 @@ function restoreQueueStateFromConfig() { ? pending.savedAt : null; + if (Array.isArray(pending.completedKeys)) { + for (const k of pending.completedKeys) { + if (typeof k === 'string' && k) _completedUploadKeys.add(k); + } + } + selectedUploadHosters = Array.isArray(pending.selectedUploadHosters) ? pending.selectedUploadHosters.filter(Boolean) : selectedUploadHosters; @@ -785,10 +797,16 @@ function buildPersistedQueueState() { // consistent "Bereit" for everything that didn't actually terminate. // Only true terminal states (done / error / skipped) survive as-is. const TERMINAL = new Set(['done', 'error', 'skipped']); + const completedKeys = []; + for (const k of _completedUploadKeys) { + const sep = k.lastIndexOf('|'); + if (sep > 0 && selectedFileMap.has(k.slice(0, sep))) completedKeys.push(k); + } return { savedAt: Date.now(), selectedUploadHosters: getSelectedHosters(), selectedFiles: Array.from(selectedFileMap.values()), + completedKeys, queueJobs: queueJobs.map(job => { const isTerminal = TERMINAL.has(job.status); return { @@ -1060,7 +1078,7 @@ function buildQueuePreview() { // Build a Set for fast existence checks const existingKeys = new Set(); for (const j of queueJobs) { - if (j.status !== 'error') existingKeys.add(`${j.file}|${j.hoster}`); + existingKeys.add(`${j.file}|${j.hoster}`); } for (const file of selectedFiles) { @@ -1099,14 +1117,14 @@ function indexJob(job) { if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job); } -function removeJobFromIndex(job) { +function removeJobFromIndex(job, keepCompletedKey) { _jobIndexById.delete(job.id); if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId); // Track deletion so handleProgress() won't re-create this job from stale callbacks _deletedJobIds.add(job.id); if (job.uploadId) _deletedJobIds.add(job.uploadId); // Allow re-uploading same file+hoster after deletion - if (job.file && job.hoster) _completedUploadKeys.delete(`${job.file}|${job.hoster}`); + if (!keepCompletedKey && job.file && job.hoster) _completedUploadKeys.delete(`${job.file}|${job.hoster}`); } // --- Queue Table Rendering (debounced with virtual scrolling) --- @@ -2249,7 +2267,7 @@ function _handleProgressImpl(data) { // updated synchronously so subsequent lookups see the right state — only // the array rewrite is deferred. if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) { - removeJobFromIndex(job); + removeJobFromIndex(job, true); selectedJobIds.delete(job.id); if (_doneRemovalCoalescer) { _doneRemovalCoalescer.add(job.id); @@ -2315,7 +2333,7 @@ function handleBatchDone(summary) { const nextJobs = []; for (const job of queueJobs) { if (job.status === 'done') { - removeJobFromIndex(job); + removeJobFromIndex(job, true); selectedJobIds.delete(job.id); } else { nextJobs.push(job); @@ -2337,7 +2355,7 @@ function handleBatchDone(summary) { const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs, TERMINAL_KEEP_LIMIT); if (result) { for (const j of result.dropped) { - removeJobFromIndex(j); + removeJobFromIndex(j, true); selectedJobIds.delete(j.id); } queueJobs = result.kept; diff --git a/tests/upload-log.test.js b/tests/upload-log.test.js index ce21225..45da127 100644 --- a/tests/upload-log.test.js +++ b/tests/upload-log.test.js @@ -50,3 +50,31 @@ test('parseUploadLogLine: missing/garbage timestamp yields ts=undefined (legacy assert.equal(parsed.fileName, 'a.mkv'); assert.equal(parsed.ts, undefined); }); + +test('parseUploadLogLine: a pipe in the link does NOT shift the filename field (entry not lost)', () => { + const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'byse.sx', 'https://h.io/a|b', 'movie.mkv'); + const parsed = parseUploadLogLine(line); + assert.equal(parsed.hoster, 'byse.sx'); + assert.equal(parsed.fileName, 'movie.mkv', 'filename is taken as the last non-empty field, robust to link pipes'); +}); + +test('parseUploadLogLine: two pipes in the link still parse the correct filename', () => { + const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'byse.sx', 'https://h.io/a|b|c', 'movie.mkv'); + const parsed = parseUploadLogLine(line); + assert.equal(parsed.fileName, 'movie.mkv'); +}); + +test('parseUploadLogLine: a leading-space filename is preserved (matches the untrimmed queue-job key)', () => { + const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'voe.sx', 'https://h.io/a', ' movie.mkv'); + const parsed = parseUploadLogLine(line); + assert.equal(parsed.fileName, ' movie.mkv', 'filename is NOT trimmed, so it matches the OS basename verbatim'); +}); + +test('SEAM: a leading-space filename round-trips and the gate still drops its ghost', () => { + const completion = new Date(2026, 5, 19, 12, 0, 30); + const parsed = parseUploadLogLine(formatUploadLogLine(completion, 'voe.sx', 'l', ' spaced.mp4')); + const savedAt = completion.getTime() - 5000; + const job = { status: 'preview', fileName: ' spaced.mp4', hoster: 'voe.sx', file: 'C:/dl/ spaced.mp4' }; + const { removed } = partitionRestoredJobsByLog([job], [parsed], savedAt); + assert.equal(removed.length, 1, 'leading-space filename now matches end-to-end (was a mismatch before)'); +});