diff --git a/lib/upload-recovery.js b/lib/upload-recovery.js index be3643b..e73e590 100644 --- a/lib/upload-recovery.js +++ b/lib/upload-recovery.js @@ -31,6 +31,39 @@ return Array.from(snapshots.values()); } + function buildFailedUploadSummary(tasks, message, now = Date.now()) { + const files = new Map(); + for (const [index, task] of (Array.isArray(tasks) ? tasks : []).entries()) { + if (!task || typeof task !== 'object') continue; + const filePath = typeof task.file === 'string' ? task.file : ''; + const fileName = filePath.split(/[\\/]/).pop() || `upload-${index + 1}`; + const key = filePath || `${fileName}\0${index}`; + if (!files.has(key)) files.set(key, { name: fileName, size: 0, results: [] }); + files.get(key).results.push({ + jobId: typeof task.jobId === 'string' ? task.jobId : '', + hoster: typeof task.hoster === 'string' ? task.hoster : '', + status: 'error', + error: message, + failureDetails: null, + download_url: null, + embed_url: null, + file_code: null + }); + } + const grouped = Array.from(files.values()); + const failed = grouped.reduce((count, file) => count + file.results.length, 0); + return { + id: `start-error-${now}`, + timestamp: new Date(now).toISOString(), + total: failed, + succeeded: 0, + failed, + skipped: 0, + files: grouped, + error: message + }; + } + function getRecoveryOutcome(job, recovery) { const status = typeof job?.status === 'string' ? job.status : 'preview'; const jobId = typeof job?.id === 'string' ? job.id : ''; @@ -50,5 +83,5 @@ return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) }; } - return { buildTerminalJobSnapshots, getRecoveryOutcome }; + return { buildFailedUploadSummary, buildTerminalJobSnapshots, getRecoveryOutcome }; }); diff --git a/main.js b/main.js index d7e1e36..1d07fa4 100644 --- a/main.js +++ b/main.js @@ -45,7 +45,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 { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/upload-recovery'); const { selectPublicUploadUrl } = require('./lib/upload-confirmation'); const { createBatchMutationGate } = require('./lib/batch-mutation-gate'); const { createUploadStartReservation } = require('./lib/upload-start-reservation'); @@ -2348,19 +2348,31 @@ async function executeReservedUploadStart(payload, startLease) { _thisManager.startBatch(tasks, { primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()), primeOverrides: Array.from(_sessionAccountOverrides.entries()) - }).catch((err) => { + }).catch(async (err) => { debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); - const errorSummary = { - id: 'error', - timestamp: new Date().toISOString(), - total: tasks.length, - succeeded: 0, - failed: tasks.length, - files: [], - error: err ? err.message : 'Unbekannter Fehler' + await batchMutationGate.sealAndDrain(); + const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden'); + let historyPersisted = true; + try { await configStore.appendHistory(errorSummary); } catch (historyError) { + historyPersisted = false; + debugLog(`appendHistory after start failure failed: ${historyError.message}`); + } + const terminalRecovery = { + ...recovery, + settledAt: new Date().toISOString(), + terminalJobs: buildTerminalJobSnapshots(errorSummary) }; - safeSend('upload-batch-done', errorSummary); - configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`)); + let terminalRecoveryPersisted = true; + try { await configStore.saveUploadRecovery(terminalRecovery); } catch (recoveryError) { + terminalRecoveryPersisted = false; + debugLog(`upload recovery outcomes could not be saved after start failure: ${recoveryError.message}`); + } + const queuePersisted = await requestUploadFinalization(errorSummary, historyPersisted); + if (queuePersisted && terminalRecoveryPersisted) { + try { await configStore.saveUploadRecovery(null); } catch (clearError) { + debugLog(`upload recovery state could not be cleared after start failure: ${clearError.message}`); + } + } _producerTracker.finish(); if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } diff --git a/tests/upload-recovery.test.js b/tests/upload-recovery.test.js index 937244d..7cc983d 100644 --- a/tests/upload-recovery.test.js +++ b/tests/upload-recovery.test.js @@ -3,6 +3,20 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +test('catastrophic batch starts retain exact terminal outcomes for every job', () => { + const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('../lib/upload-recovery'); + const summary = buildFailedUploadSummary([ + { jobId: 'job-a', file: 'C:\\one\\same.mkv', hoster: 'doodstream.com' }, + { jobId: 'job-b', file: 'D:\\two\\same.mkv', hoster: 'voe.sx' } + ], 'Upload konnte nicht gestartet werden', Date.UTC(2026, 7, 13)); + assert.equal(summary.total, 2); + assert.equal(summary.failed, 2); + assert.deepEqual(buildTerminalJobSnapshots(summary).map(entry => [entry.jobId, entry.status]), [ + ['job-a', 'error'], + ['job-b', 'error'] + ]); +}); + test('terminal recovery snapshots retain exact job outcomes and canonical links', () => { const { buildTerminalJobSnapshots } = require('../lib/upload-recovery'); const snapshots = buildTerminalJobSnapshots({ @@ -67,8 +81,13 @@ test('main and renderer keep recovery evidence until final queue persistence suc 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.match(batchDone, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/); assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)')); + const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks')); + assert.match(startFailure, /buildFailedUploadSummary\(tasks/); + assert.match(startFailure, /saveUploadRecovery\(terminalRecovery\)/); + assert.match(startFailure, /requestUploadFinalization\(errorSummary, historyPersisted\)/); + assert.match(startFailure, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/); assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/); assert.match(rendererSource, /data\.historyPersisted !== true/); assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));