diff --git a/lib/upload-manager.js b/lib/upload-manager.js
index ee2b367..9520c4a 100644
--- a/lib/upload-manager.js
+++ b/lib/upload-manager.js
@@ -473,6 +473,7 @@ class UploadManager extends EventEmitter {
finalStatus = status;
const result = {
+ jobId,
hoster: task.hoster,
status,
error: payload.error || null,
diff --git a/lib/upload-recovery.js b/lib/upload-recovery.js
new file mode 100644
index 0000000..be3643b
--- /dev/null
+++ b/lib/upload-recovery.js
@@ -0,0 +1,54 @@
+(function exposeUploadRecovery(root, factory) {
+ const api = factory();
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ if (root) root.UploadRecovery = api;
+})(typeof window !== 'undefined' ? window : globalThis, () => {
+ const terminalStatuses = new Set(['done', 'error', 'skipped', 'aborted']);
+
+ function buildTerminalJobSnapshots(summary) {
+ const snapshots = new Map();
+ for (const file of Array.isArray(summary?.files) ? summary.files : []) {
+ for (const result of Array.isArray(file?.results) ? file.results : []) {
+ const jobId = typeof result?.jobId === 'string' ? result.jobId : '';
+ const status = typeof result?.status === 'string' ? result.status : '';
+ if (!jobId || !terminalStatuses.has(status)) continue;
+ const uploadResult = result.download_url || result.embed_url || result.file_code
+ ? {
+ download_url: result.download_url || null,
+ embed_url: result.embed_url || null,
+ file_code: result.file_code || null
+ }
+ : null;
+ snapshots.set(jobId, {
+ jobId,
+ status,
+ error: result.error || null,
+ failureDetails: result.failureDetails || null,
+ result: uploadResult
+ });
+ }
+ }
+ return Array.from(snapshots.values());
+ }
+
+ function getRecoveryOutcome(job, recovery) {
+ const status = typeof job?.status === 'string' ? job.status : 'preview';
+ const jobId = typeof job?.id === 'string' ? job.id : '';
+ const terminal = Array.isArray(recovery?.terminalJobs)
+ ? recovery.terminalJobs.find(entry => entry?.jobId === jobId && terminalStatuses.has(entry.status))
+ : null;
+ if (terminal) {
+ return {
+ status: terminal.status,
+ error: terminal.error || null,
+ failureDetails: terminal.failureDetails || null,
+ result: terminal.result || null,
+ interrupted: false
+ };
+ }
+ const interruptedIds = new Set(Array.isArray(recovery?.jobIds) ? recovery.jobIds.filter(Boolean) : []);
+ return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) };
+ }
+
+ return { buildTerminalJobSnapshots, getRecoveryOutcome };
+});
diff --git a/main.js b/main.js
index 3f0c1e0..0ad87bf 100644
--- a/main.js
+++ b/main.js
@@ -36,6 +36,7 @@ const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent');
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
+const { buildTerminalJobSnapshots } = require('./lib/upload-recovery');
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable();
@@ -2272,8 +2273,16 @@ ipcMain.handle('start-upload', async (_event, payload) => {
for (const value of _progressByJob.values()) finalProgressBatch.push(value);
_progressByJob.clear();
if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch);
+ const recoveryWithTerminalJobs = {
+ ...recovery,
+ settledAt: new Date().toISOString(),
+ terminalJobs: buildTerminalJobSnapshots(summary)
+ };
+ try { await configStore.saveUploadRecovery(recoveryWithTerminalJobs); } catch (error) { debugLog(`upload recovery outcomes could not be saved: ${error.message}`); }
const queuePersisted = await requestUploadFinalization(summary, historyPersisted);
- try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared: ${error.message}`); }
+ if (queuePersisted) {
+ try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared: ${error.message}`); }
+ }
if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
await sourceCleanup.finishBatch({ historyPersisted, queuePersisted });
_producerTracker.finish();
diff --git a/preload-drop-target.js b/preload-drop-target.js
index 3d11d02..8a40dac 100644
--- a/preload-drop-target.js
+++ b/preload-drop-target.js
@@ -1,5 +1,6 @@
-const { contextBridge, ipcRenderer } = require('electron');
+const { contextBridge, ipcRenderer, webUtils } = require('electron');
contextBridge.exposeInMainWorld('dropTargetApi', {
- sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths)
+ sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths),
+ getPathForFile: (file) => webUtils.getPathForFile(file)
});
diff --git a/renderer/app.js b/renderer/app.js
index 8b7eb64..8a96d69 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -540,8 +540,8 @@ async function init() {
});
// Drop target window: files dropped on the small floating window
- window.api.onDropTargetFiles((paths) => {
- addPathsToQueue(paths);
+ window.api.onDropTargetFiles((entries) => {
+ addDropTargetEntries(entries).catch(console.error);
});
// Remote client count updates (registered once, not per renderSettings call)
@@ -1198,39 +1198,44 @@ function restoreQueueStateFromConfig() {
.map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 }))
: [];
- const interruptedJobIds = new Set(Array.isArray(config?.globalSettings?.uploadRecovery?.jobIds) ? config.globalSettings.uploadRecovery.jobIds : []);
+ const uploadRecovery = config?.globalSettings?.uploadRecovery || null;
const rawJobs = Array.isArray(pending.queueJobs)
? pending.queueJobs
.filter(job => job && job.fileName && job.hoster)
- .map(job => ({
- id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- uploadId: null,
- file: job.file || '',
- fileName: job.fileName,
- hoster: job.hoster,
- status: normalizeRestoredJobStatus(job.status),
- bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0,
- bytesTotal: job.bytesTotal || 0,
- speedKbs: 0,
- elapsed: 0,
- remaining: 0,
- error: job.error || null,
- failureDetails: job.failureDetails || null,
- interrupted: interruptedJobIds.size > 0 && !['done', 'error', 'skipped'].includes(job.status),
- result: job.result || null,
- sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null,
- sourceCleanupToken: job.sourceCleanupToken || null,
- sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [],
- sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters)
- ? [...job.sourceCleanupConfirmedHosters]
- : [],
- sourceCleanupProvisionalHosters: [],
- sourceCleanupFingerprint: job.sourceCleanupFingerprint || null,
- attempt: 0,
- maxAttempts: job.maxAttempts || 0,
- link: '',
- progress: job.status === 'done' ? 1 : 0
- }))
+ .map(job => {
+ const id = job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const recoveryOutcome = window.UploadRecovery.getRecoveryOutcome({ ...job, id }, uploadRecovery);
+ const status = normalizeRestoredJobStatus(recoveryOutcome.status);
+ return {
+ id,
+ uploadId: null,
+ file: job.file || '',
+ fileName: job.fileName,
+ hoster: job.hoster,
+ status,
+ bytesUploaded: status === 'done' ? (job.bytesTotal || 0) : 0,
+ bytesTotal: job.bytesTotal || 0,
+ speedKbs: 0,
+ elapsed: 0,
+ remaining: 0,
+ error: Object.hasOwn(recoveryOutcome, 'error') ? recoveryOutcome.error : (job.error || null),
+ failureDetails: Object.hasOwn(recoveryOutcome, 'failureDetails') ? recoveryOutcome.failureDetails : (job.failureDetails || null),
+ interrupted: recoveryOutcome.interrupted === true,
+ result: Object.hasOwn(recoveryOutcome, 'result') ? recoveryOutcome.result : (job.result || null),
+ sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null,
+ sourceCleanupToken: job.sourceCleanupToken || null,
+ sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [],
+ sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters)
+ ? [...job.sourceCleanupConfirmedHosters]
+ : [],
+ sourceCleanupProvisionalHosters: [],
+ sourceCleanupFingerprint: job.sourceCleanupFingerprint || null,
+ attempt: 0,
+ maxAttempts: job.maxAttempts || 0,
+ link: '',
+ progress: status === 'done' ? 1 : 0
+ };
+ })
: [];
// Deduplicate: keep the job with the best status for each file+hoster pair
@@ -1377,6 +1382,23 @@ let _pendingFiles = []; // Files waiting for hoster modal confirmation
let _addingDropped = false;
+async function addDropTargetEntries(entries) {
+ const files = [];
+ for (const entry of Array.isArray(entries) ? entries : []) {
+ const filePath = typeof entry === 'string' ? entry : entry?.path;
+ if (!filePath) continue;
+ if (entry && typeof entry === 'object' && entry.isDirectory) {
+ try {
+ const folderFiles = await window.api.resolveFolderFiles(filePath);
+ if (Array.isArray(folderFiles)) files.push(...folderFiles);
+ } catch {}
+ continue;
+ }
+ files.push(entry);
+ }
+ addPathsToQueue(files);
+}
+
async function addDroppedFiles(fileList) {
if (_addingDropped) return;
_addingDropped = true;
@@ -2305,8 +2327,12 @@ function showContextMenu(x, y) {
const n = selectedJobIds.size;
const delItem = menu.querySelector('[data-action="delete-selected"]');
if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen';
+ const copyableLinkCount = getSelectedJobLinks().length;
const copyItem = menu.querySelector('[data-action="copy-links"]');
- if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren';
+ if (copyItem) {
+ copyItem.textContent = copyableLinkCount > 1 ? `Links kopieren (${copyableLinkCount})` : 'Link kopieren';
+ copyItem.style.display = copyableLinkCount > 0 ? '' : 'none';
+ }
menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => {
el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen';
});
@@ -2364,8 +2390,12 @@ function showRecentContextMenu(row, x, y) {
applyRecentSelectionClasses();
}
const menu = document.getElementById('recentContextMenu');
+ const copyableLinkCount = getSelectedRecentLinks().length;
const copyItem = menu.querySelector('[data-action="recent-copy-links"]');
- if (copyItem) copyItem.textContent = selectedRecentIds.size > 1 ? `Links kopieren (${selectedRecentIds.size})` : 'Link kopieren';
+ if (copyItem) {
+ copyItem.textContent = copyableLinkCount > 1 ? `Links kopieren (${copyableLinkCount})` : 'Link kopieren';
+ copyItem.style.display = copyableLinkCount > 0 ? '' : 'none';
+ }
menu.style.display = 'block';
menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px';
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px';
@@ -2438,11 +2468,15 @@ async function exportAllRecentFiles() {
}
}
-function copySelectedRecentLinks() {
- const links = sessionFilesData
+function getSelectedRecentLinks() {
+ return sessionFilesData
.filter(r => selectedRecentIds.has(r.order) && !r.isError)
.map(r => r.link)
.filter(Boolean);
+}
+
+function copySelectedRecentLinks() {
+ const links = getSelectedRecentLinks();
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
}
@@ -2923,7 +2957,7 @@ async function completeSourceCleanupFinalization(data) {
}
return window.api.completeUploadFinalization({
finalizationId: data.finalizationId,
- pendingQueue: queueJobs.some((job) => !['done', 'skipped'].includes(job.status))
+ pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status))
? buildPersistedQueueState()
: null
});
@@ -4169,8 +4203,8 @@ function updateStatusBar() {
_setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0);
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
- _setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount);
- _setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors));
+ _setRollingUploadMetric('uploadTelemetryCompleted', stats.done);
+ _setRollingUploadMetric('uploadTelemetryFailed', stats.errors);
updateUploadSpeedDisplays();
_setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--');
updateUploadSidebarSummary(stats);
diff --git a/renderer/drop-target.html b/renderer/drop-target.html
index 399dfd1..532bbd4 100644
--- a/renderer/drop-target.html
+++ b/renderer/drop-target.html
@@ -79,12 +79,20 @@
e.preventDefault();
e.stopPropagation();
target.classList.remove('drag-over');
- const paths = [];
+ const files = [];
for (const file of e.dataTransfer.files) {
- if (file.path) paths.push(file.path);
+ let filePath = '';
+ try { filePath = window.dropTargetApi.getPathForFile(file); } catch {}
+ if (!filePath) continue;
+ files.push({
+ path: filePath,
+ name: file.name || '',
+ size: Number.isFinite(file.size) ? file.size : 0,
+ isDirectory: file.type === '' && file.size === 0
+ });
}
- if (paths.length > 0) {
- window.dropTargetApi.sendFiles(paths);
+ if (files.length > 0) {
+ window.dropTargetApi.sendFiles(files);
}
});
diff --git a/renderer/index.html b/renderer/index.html
index 1213fe5..510ef29 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -682,6 +682,7 @@
+
diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs
index 1fd2b5c..387964f 100644
--- a/scripts/verify-public-release.mjs
+++ b/scripts/verify-public-release.mjs
@@ -63,6 +63,7 @@ const sourceFiles = [
'lib/upload-confirmation.js',
'lib/upload-diagnostics.js',
'lib/upload-manager.js',
+ 'lib/upload-recovery.js',
'lib/vidmoly-upload.js',
'lib/voe-upload.js',
'lib/webhook-notify.js',
@@ -152,6 +153,7 @@ const sourceFiles = [
'tests/upload-confirmation.test.js',
'tests/upload-diagnostics.test.js',
'tests/upload-manager.test.js',
+ 'tests/upload-recovery.test.js',
'tests/session-report.test.js',
'tests/validate-credentials.test.js',
'tests/webhook-notify.test.js'
diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js
index 1bd6c17..e223295 100644
--- a/tests/package-build-files.test.js
+++ b/tests/package-build-files.test.js
@@ -10,6 +10,38 @@ test('packages every Electron preload referenced by the main process', () => {
assert.equal(packageJson.build.win.signAndEditExecutable, false);
});
+test('floating drop target resolves native paths through Electron webUtils', () => {
+ let exposedApi = null;
+ const nativeFile = { name: 'fixture.mkv' };
+ const electronMock = {
+ contextBridge: {
+ exposeInMainWorld: (_name, api) => { exposedApi = api; }
+ },
+ ipcRenderer: {
+ send: () => {}
+ },
+ webUtils: {
+ getPathForFile: file => file === nativeFile ? 'C:\\fixtures\\fixture.mkv' : ''
+ }
+ };
+ const originalLoad = Module._load;
+ const preloadPath = require.resolve('../preload-drop-target');
+ delete require.cache[preloadPath];
+ Module._load = function (request, parent, isMain) {
+ if (request === 'electron') return electronMock;
+ return originalLoad.call(this, request, parent, isMain);
+ };
+ try {
+ require(preloadPath);
+ } finally {
+ Module._load = originalLoad;
+ delete require.cache[preloadPath];
+ }
+
+ assert.equal(typeof exposedApi.getPathForFile, 'function');
+ assert.equal(exposedApi.getPathForFile(nativeFile), 'C:\\fixtures\\fixture.mkv');
+});
+
test('afterPack brands the executable metadata shown by Windows', async () => {
let editCall = null;
const originalLoad = Module._load;
diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js
index 09b78fc..2c58785 100644
--- a/tests/ui-smoke.js
+++ b/tests/ui-smoke.js
@@ -228,7 +228,9 @@ setTimeout(async () => {
const liveLanguageSwitch = await wc.executeJavaScript('(() => { const input = document.getElementById("languageInput"); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); const german = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "en"; input.dispatchEvent(new Event("change", { bubbles: true })); const english = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); return [german, english, document.documentElement.lang].join("|"); })()');
check('Language changes apply immediately in both directions', liveLanguageSwitch === 'Upload,Accounts,Einstellungen,Verlauf|Upload,Accounts,Settings,History|de');
const localizedStableMetric = await wc.executeJavaScript(\`(async () => {
- _sessionDoneCount = 1234;
+ const previousJobs = queueJobs;
+ queueJobs = Array.from({ length: 1234 }, (_, index) => ({ id: 'ui-locale-done-' + index, status: 'done' }));
+ _queueStatsCache = null;
updateStatusBar();
await new Promise(resolve => setTimeout(resolve, 360));
const metric = document.getElementById('uploadTelemetryCompleted');
@@ -236,7 +238,8 @@ setTimeout(async () => {
setUiLanguage('en');
const english = [metric?.textContent.trim(), metric?.getAttribute('aria-label')];
setUiLanguage('de');
- _sessionDoneCount = 0;
+ queueJobs = previousJobs;
+ _queueStatsCache = null;
updateStatusBar();
return { german, english };
})()\`);
@@ -371,6 +374,24 @@ setTimeout(async () => {
}
check('Desktop file drop reaches the upload selection with its native path', desktopDropState?.modal === 'flex' && desktopDropState.paths.length === 1 && desktopDropState.paths[0] === desktopDropFixture);
+ const floatingDropFolder = fs.mkdtempSync(path.join(app.getPath('temp'), 'mhu-floating-folder-drop-'));
+ const floatingDropNested = path.join(floatingDropFolder, 'nested');
+ const floatingDropFirst = path.join(floatingDropFolder, 'first.mkv');
+ const floatingDropSecond = path.join(floatingDropNested, 'second.mp4');
+ fs.mkdirSync(floatingDropNested);
+ fs.writeFileSync(floatingDropFirst, Buffer.from('first floating drop fixture'));
+ fs.writeFileSync(floatingDropSecond, Buffer.from('second floating drop fixture'));
+ let floatingFolderDropState = null;
+ try {
+ wc.send('drop-target:files', [{ path: floatingDropFolder, name: path.basename(floatingDropFolder), size: 0, isDirectory: true }]);
+ await waitUntil(() => wc.executeJavaScript('document.getElementById("hosterModal")?.style.display === "flex" && _pendingFiles.length === 2'));
+ floatingFolderDropState = await wc.executeJavaScript('(() => ({ modal: document.getElementById("hosterModal")?.style.display, paths: _pendingFiles.map(file => file.path).sort() }))()');
+ await wc.executeJavaScript('cancelHosterModal()');
+ } finally {
+ fs.rmSync(floatingDropFolder, { recursive: true, force: true });
+ }
+ check('Floating drop target recursively expands folders before hoster selection', floatingFolderDropState?.modal === 'flex' && floatingFolderDropState.paths.join('|') === [floatingDropFirst, floatingDropSecond].sort().join('|'));
+
const populatedDropFixture = path.join(app.getPath('temp'), 'mhu-populated-drop-' + process.pid + '.mkv');
fs.writeFileSync(populatedDropFixture, Buffer.from('populated queue drop fixture'));
await wc.executeJavaScript('(() => { selectedFiles = [{ path: "C:/ui/existing.bin", name: "existing.bin", size: 16 }]; queueJobs = [{ id: "ui-existing-drop-row", file: "C:/ui/existing.bin", fileName: "existing.bin", hoster: "doodstream.com", status: "preview", bytesUploaded: 0, bytesTotal: 16, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); updateUploadView(); renderQueueTable(); })()');
@@ -525,7 +546,7 @@ setTimeout(async () => {
speedPair: [document.getElementById('uploadTelemetrySpeed')?.textContent, document.getElementById('uploadSpeedValue')?.textContent].join('|')
};
})()\`);
- check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|1|7|2|2 kB/s|00:03');
+ check('Upload telemetry reflects current queue activity', telemetryUpdate.values === '4|1|2|1|1|1|2 kB/s|00:03');
check('Changing integer telemetry rolls vertically', telemetryUpdate.rolling === 2 && telemetryUpdate.direction === 'up');
check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s');
const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")');
@@ -750,6 +771,33 @@ setTimeout(async () => {
const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()');
check('Remove all is visually marked as a destructive queue action', removeAllDanger === true);
+ const queueTelemetryState = await wc.executeJavaScript(\`(async () => {
+ queueJobs = [
+ { id: 'telemetry-done-a', status: 'done' },
+ { id: 'telemetry-done-b', status: 'done' },
+ { id: 'telemetry-error', status: 'error' },
+ { id: 'telemetry-queued', status: 'queued' }
+ ];
+ _sessionDoneCount = 91;
+ _sessionErrorCount = 92;
+ _queueStatsCache = null;
+ updateStatusBar();
+ await new Promise(resolve => setTimeout(resolve, 360));
+ const result = {
+ completed: document.getElementById('uploadTelemetryCompleted')?.textContent.trim(),
+ failed: document.getElementById('uploadTelemetryFailed')?.textContent.trim(),
+ sidebarDone: document.getElementById('uploadSidebarDoneCount')?.textContent.trim(),
+ sidebarFailed: document.getElementById('uploadSidebarErrorCount')?.textContent.trim()
+ };
+ queueJobs = [];
+ _sessionDoneCount = 0;
+ _sessionErrorCount = 0;
+ _queueStatsCache = null;
+ updateStatusBar();
+ return result;
+ })()\`);
+ check('Lower telemetry and sidebar badges use the same current queue state', queueTelemetryState.completed === '2' && queueTelemetryState.failed === '1' && queueTelemetryState.sidebarDone === '2' && queueTelemetryState.sidebarFailed === '1');
+
let releaseSelectedQueueCancel = null;
ipcMain.removeHandler('cancel-selected-jobs');
ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); }));
@@ -773,14 +821,14 @@ setTimeout(async () => {
});
ipcMain.removeHandler('cancel-selected-jobs');
ipcMain.handle('cancel-selected-jobs', () => { selectedQueueCancelCalls++; return true; });
- await wc.executeJavaScript('(() => { uploading = true; queueJobs = ["a", "b", "c"].map(id => ({ id: "ui-delete-all-" + id, file: "C:/ui/delete-all-" + id + ".bin", fileName: "delete-all-" + id + ".bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 })); selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllPromise = handleContextAction("delete-all"); return true; })()');
+ await wc.executeJavaScript('(() => { uploading = true; queueJobs = Array.from({ length: 100 }, (_, index) => ({ id: "ui-delete-all-" + index, file: "C:/ui/delete-all-" + index + ".bin", fileName: "delete-all-" + index + ".bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 })); selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllPromise = handleContextAction("delete-all"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
await waitUntil(() => releaseFullQueueCancel);
const fullQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
releaseFullQueueCancel();
const fullQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteAllPromise.then(() => { delete window.__uiDeleteAllPromise; return { length: queueJobs.length, uploading }; })');
- check('Remove all awaits one batch cancellation instead of issuing one cancellation per queued job', fullQueueStillPresent === 3 && fullQueueAfterCancel.length === 0 && fullQueueAfterCancel.uploading === false && fullQueueCancelCalls === 1 && selectedQueueCancelCalls === 0);
+ check('Remove all keeps a 100-job cancellation responsive and issues one batch cancellation', fullQueueStillPresent === 100 && fullQueueAfterCancel.length === 0 && fullQueueAfterCancel.uploading === false && fullQueueCancelCalls === 1 && selectedQueueCancelCalls === 0);
restoreInitialIpcHandler('cancel-upload');
restoreInitialIpcHandler('cancel-selected-jobs');
@@ -1901,6 +1949,41 @@ setTimeout(async () => {
const singleRecentLinkContextLabel = await wc.executeJavaScript('(() => { selectedRecentIds.clear(); const row = document.createElement("tr"); row.dataset.order = "1001"; showRecentContextMenu(row, 8, 8); const label = document.querySelector("#recentContextMenu [data-action=recent-copy-links]")?.textContent?.trim(); hideContextMenu(); return label; })()');
check('Recent upload context menu uses singular copy text for one link', singleRecentLinkContextLabel === 'Link kopieren');
+ const copyableLinkContextLabels = await wc.executeJavaScript(\`(() => {
+ const previousJobs = queueJobs;
+ const previousRecent = sessionFilesData;
+ queueJobs = [
+ { id: 'copyable-link', status: 'done', result: { download_url: 'https://example.invalid/copyable' } },
+ { id: 'missing-link', status: 'done', result: null }
+ ];
+ rebuildJobIndex();
+ selectedJobIds.clear();
+ selectedJobIds.add('copyable-link');
+ selectedJobIds.add('missing-link');
+ showContextMenu(8, 8);
+ const queueLabel = document.querySelector('#contextMenu [data-action=copy-links]')?.textContent?.trim();
+ hideContextMenu();
+ sessionFilesData = [
+ { order: 2001, link: 'https://example.invalid/recent', isError: false },
+ { order: 2002, link: '', isError: true }
+ ];
+ selectedRecentIds.clear();
+ selectedRecentIds.add(2001);
+ selectedRecentIds.add(2002);
+ const row = document.createElement('tr');
+ row.dataset.order = '2001';
+ showRecentContextMenu(row, 8, 8);
+ const recentLabel = document.querySelector('#recentContextMenu [data-action=recent-copy-links]')?.textContent?.trim();
+ hideContextMenu();
+ queueJobs = previousJobs;
+ sessionFilesData = previousRecent;
+ selectedJobIds.clear();
+ selectedRecentIds.clear();
+ rebuildJobIndex();
+ return { queueLabel, recentLabel };
+ })()\`);
+ check('Copy-link context labels count only links that can actually be copied', copyableLinkContextLabels.queueLabel === 'Link kopieren' && copyableLinkContextLabels.recentLabel === 'Link kopieren');
+
const historySidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#history-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#history-view .view-sidebar-section")?.getBoundingClientRect(); const retention = document.getElementById("historySidebarRetention")?.textContent?.trim(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && retention === "Alles behalten"); })()');
check('History sidebar shows the active retention in its lower area', historySidebarInformation === true);
@@ -2253,7 +2336,9 @@ setTimeout(async () => {
check('Rapid main-view switches never paint a blank, duplicate, or overflowing active view', rapidViewStability.length === 12 && invalidViewFrames.length === 0);
const languageFrameStability = await wc.executeJavaScript(\`(async () => {
- _sessionDoneCount = 1234;
+ const previousJobs = queueJobs;
+ queueJobs = Array.from({ length: 1234 }, (_, index) => ({ id: 'ui-language-frame-done-' + index, status: 'done' }));
+ _queueStatsCache = null;
const languages = ['en', 'de', 'en', 'de', 'en', 'de', 'en', 'de', 'en', 'de', 'en', 'de'];
const samples = [];
for (const language of languages) {
@@ -2270,7 +2355,8 @@ setTimeout(async () => {
metricLabel: metric?.getAttribute('aria-label')
});
}
- _sessionDoneCount = 0;
+ queueJobs = previousJobs;
+ _queueStatsCache = null;
updateStatusBar();
return samples;
})()\`);
@@ -2286,8 +2372,11 @@ setTimeout(async () => {
const metric = document.getElementById('uploadTelemetryCompleted');
const initialRect = metric.getBoundingClientRect();
const frames = [];
+ const previousJobs = queueJobs;
+ queueJobs = Array.from({ length: 999 }, (_, index) => ({ id: 'ui-rolling-done-' + index, status: 'done' }));
for (let value = 1000; value <= 1020; value++) {
- _sessionDoneCount = value;
+ queueJobs.push({ id: 'ui-rolling-done-' + value, status: 'done' });
+ _queueStatsCache = null;
updateStatusBar();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const rect = metric.getBoundingClientRect();
@@ -2301,7 +2390,8 @@ setTimeout(async () => {
}
await new Promise(resolve => setTimeout(resolve, 360));
const settled = { text: metric.textContent.trim(), label: metric.getAttribute('aria-label'), direction: metric.dataset.direction };
- _sessionDoneCount = 0;
+ queueJobs = previousJobs;
+ _queueStatsCache = null;
updateStatusBar();
return { initialRect: { width: initialRect.width, height: initialRect.height }, frames, settled };
})()\`);
diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js
index 1650d08..7348cf5 100644
--- a/tests/upload-manager.test.js
+++ b/tests/upload-manager.test.js
@@ -160,6 +160,7 @@ describe('UploadManager', () => {
assert.equal(summary.succeeded, 2);
assert.equal(summary.failed, 0);
assert.equal(summary.files.length, 2);
+ assert.ok(summary.files.flatMap(file => file.results).every(result => typeof result.jobId === 'string' && result.jobId.length > 0));
});
it('emits a final idle stats snapshot after a normal batch', async () => {
diff --git a/tests/upload-recovery.test.js b/tests/upload-recovery.test.js
new file mode 100644
index 0000000..937244d
--- /dev/null
+++ b/tests/upload-recovery.test.js
@@ -0,0 +1,75 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+test('terminal recovery snapshots retain exact job outcomes and canonical links', () => {
+ const { buildTerminalJobSnapshots } = require('../lib/upload-recovery');
+ const snapshots = buildTerminalJobSnapshots({
+ files: [{
+ name: 'episode.mkv',
+ results: [
+ { jobId: 'done-job', hoster: 'doodstream.com', status: 'done', download_url: 'https://doodstream.com/d/abc123', file_code: 'abc123' },
+ { jobId: 'error-job', hoster: 'voe.sx', status: 'error', error: 'rejected', failureDetails: { kind: 'hoster' } },
+ { hoster: 'byse.sx', status: 'done', download_url: 'https://byse.sx/d/no-id' }
+ ]
+ }]
+ });
+
+ assert.deepEqual(snapshots, [
+ {
+ jobId: 'done-job',
+ status: 'done',
+ error: null,
+ failureDetails: null,
+ result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
+ },
+ {
+ jobId: 'error-job',
+ status: 'error',
+ error: 'rejected',
+ failureDetails: { kind: 'hoster' },
+ result: null
+ }
+ ]);
+});
+
+test('recovery markers affect only their exact job IDs and never restart terminal outcomes', () => {
+ const { getRecoveryOutcome } = require('../lib/upload-recovery');
+ const recovery = {
+ jobIds: ['done-job', 'active-job'],
+ terminalJobs: [{
+ jobId: 'done-job',
+ status: 'done',
+ error: null,
+ failureDetails: null,
+ result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
+ }]
+ };
+
+ assert.deepEqual(getRecoveryOutcome({ id: 'done-job', status: 'preview' }, recovery), {
+ status: 'done',
+ error: null,
+ failureDetails: null,
+ result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' },
+ interrupted: false
+ });
+ assert.deepEqual(getRecoveryOutcome({ id: 'active-job', status: 'queued' }, recovery), { status: 'queued', interrupted: true });
+ assert.deepEqual(getRecoveryOutcome({ id: 'foreign-job', status: 'queued' }, recovery), { status: 'queued', interrupted: false });
+ assert.deepEqual(getRecoveryOutcome({ id: 'already-done', status: 'done' }, recovery), { status: 'done', interrupted: false });
+});
+
+test('main and renderer keep recovery evidence until final queue persistence succeeds', () => {
+ const root = path.join(__dirname, '..');
+ const mainSource = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
+ const rendererSource = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8');
+ const indexSource = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
+ const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
+
+ assert.match(batchDone, /buildTerminalJobSnapshots\(summary\)/);
+ assert.match(batchDone, /if \(queuePersisted\)[\s\S]*saveUploadRecovery\(null\)/);
+ assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)'));
+ assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
+ assert.match(rendererSource, /data\.historyPersisted !== true/);
+ assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
+});