diff --git a/renderer/app.js b/renderer/app.js index 3db1b5e..e2a8fe8 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -734,12 +734,12 @@ async function applyAutomationEvaluation(evaluation) { const error = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); return freezeAutomationValue({ ok: false, error, warning: null, admittedFiles: [], deferredFiles, paused: /pausiert/i.test(error), dryRun: false }); } - if ((Number(result?.added) || 0) !== newJobs.length) { - restoreSourceCleanupStates(cleanupPreparation.rollbackStates); - return freezeAutomationValue({ ok: false, error: 'Jobs wurden nicht vollständig hinzugefügt.', warning: null, admittedFiles: [], deferredFiles, paused: false, dryRun: false }); + const outcome = applyAddJobsOutcome(newJobs, result, { cleanupStates: cleanupPreparation.rollbackStates }); + if (!outcome.consistent) { + renderQueueTable(); + persistQueueStateSoon(true); + return freezeAutomationValue({ ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.', warning: null, admittedFiles: [], deferredFiles, paused: false, dryRun: false }); } - newJobs.forEach(job => { job.status = 'queued'; }); - _markSkippedJobs(result); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } catch { restoreSourceCleanupStates(cleanupPreparation.rollbackStates); @@ -1537,17 +1537,16 @@ async function applyHosterSelection() { }); if (result?.error) { regularInjectionFailure = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); - } else if ((Number(result?.added) || 0) !== newJobs.length) { - regularInjectionFailure = 'Jobs wurden nicht vollständig hinzugefügt.'; + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); } else { - newJobs.forEach(job => { job.status = 'queued'; }); - _markSkippedJobs(result); - if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); + const outcome = applyAddJobsOutcome(newJobs, result, { cleanupStates: cleanupPreparation.rollbackStates }); + if (!outcome.consistent) regularInjectionFailure = 'Jobs konnten nicht eindeutig bestätigt werden.'; + else if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } } catch { regularInjectionFailure = 'Jobs konnten nicht hinzugefügt werden.'; + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); } - if (regularInjectionFailure) restoreSourceCleanupStates(cleanupPreparation.rollbackStates); renderQueueTable(); persistQueueStateSoon(true); } @@ -3733,18 +3732,17 @@ function cloneSourceCleanupValue(value) { return structuredClone(value); } -function captureSourceCleanupStates(jobs) { - const paths = new Set((Array.isArray(jobs) ? jobs : []).map(job => normalizeAutomationPath(job?.file)).filter(Boolean)); - return queueJobs - .filter(job => paths.has(normalizeAutomationPath(job?.file))) - .map(job => [job, Object.fromEntries(sourceCleanupFields.map(field => [field, { +function captureSourceCleanupStates() { + return queueJobs.map(job => [job, Object.fromEntries(sourceCleanupFields.map(field => [field, { present: Object.prototype.hasOwnProperty.call(job, field), value: cloneSourceCleanupValue(job[field]) }]))]); } -function restoreSourceCleanupStates(states) { +function restoreSourceCleanupStates(states, jobs = null) { + const selected = Array.isArray(jobs) ? new Set(jobs) : null; for (const [job, fields] of states || []) { + if (selected && !selected.has(job)) continue; for (const field of sourceCleanupFields) { if (!fields[field].present) delete job[field]; else job[field] = cloneSourceCleanupValue(fields[field].value); @@ -3753,7 +3751,7 @@ function restoreSourceCleanupStates(states) { } function prepareSourceCleanup(jobs) { - const rollbackStates = captureSourceCleanupStates(jobs); + const rollbackStates = captureSourceCleanupStates(); if (!config.globalSettings?.deleteSourceAfterSuccessfulUpload || !window.SourceCleanupPolicy) return { groups: [], rollbackStates }; return { ...window.SourceCleanupPolicy.prepareGroups(queueJobs, jobs, () => window.crypto.randomUUID(), 'win32'), rollbackStates }; } @@ -3775,8 +3773,10 @@ function captureUploadJobStates(jobs) { return jobs.map(job => [job, { ...job }]); } -function restoreUploadJobStates(states) { +function restoreUploadJobStates(states, jobs = null) { + const selected = Array.isArray(jobs) ? new Set(jobs) : null; for (const [job, state] of states) { + if (selected && !selected.has(job)) continue; for (const key of Object.keys(job)) { if (!Object.prototype.hasOwnProperty.call(state, key)) delete job[key]; } @@ -3789,6 +3789,60 @@ function sanitizeUploadControlError(error, fallback) { return /pausiert/i.test(message) ? 'Automatik ist pausiert' : fallback; } +function resolveAddJobsOutcome(jobs, result) { + const inputJobs = Array.isArray(jobs) ? jobs : []; + const jobsById = new Map(inputJobs.filter(job => job?.id).map(job => [job.id, job])); + const skippedEntries = []; + const skippedIds = new Set(); + const alreadyIds = new Set(); + let valid = jobsById.size === inputJobs.length; + for (const entry of Array.isArray(result?.skippedJobs) ? result.skippedJobs : []) { + const id = entry?.jobId; + if (!jobsById.has(id) || skippedIds.has(id)) { + valid = false; + continue; + } + skippedIds.add(id); + skippedEntries.push(entry); + } + for (const id of Array.isArray(result?.alreadyInBatchJobIds) ? result.alreadyInBatchJobIds : []) { + if (!jobsById.has(id) || alreadyIds.has(id) || skippedIds.has(id)) { + valid = false; + continue; + } + alreadyIds.add(id); + } + const remainingJobs = inputJobs.filter(job => !skippedIds.has(job.id) && !alreadyIds.has(job.id)); + const added = Number(result?.added); + const consistent = valid && Number.isInteger(added) && added >= 0 && added === remainingJobs.length; + return { + consistent, + added: Number.isInteger(added) && added >= 0 ? added : 0, + addedJobs: consistent ? remainingJobs : [], + alreadyJobs: [...alreadyIds].map(id => jobsById.get(id)), + skippedEntries, + skippedJobs: skippedEntries.map(entry => jobsById.get(entry.jobId)), + unconfirmedJobs: consistent ? [] : remainingJobs + }; +} + +function applyAddJobsOutcome(jobs, result, options = {}) { + const outcome = resolveAddJobsOutcome(jobs, result); + const confirmedJobs = new Set([...outcome.addedJobs, ...outcome.alreadyJobs, ...outcome.skippedJobs]); + const cleanupRollbackJobs = outcome.consistent + ? [] + : (options.cleanupStates || []).map(([job]) => job).filter(job => !confirmedJobs.has(job)); + restoreSourceCleanupStates(options.cleanupStates, cleanupRollbackJobs); + restoreUploadJobStates(options.uploadStates || [], outcome.unconfirmedJobs); + for (const job of [...outcome.addedJobs, ...outcome.alreadyJobs]) job.status = 'queued'; + for (let index = 0; index < outcome.skippedJobs.length; index++) { + const job = outcome.skippedJobs[index]; + job.status = 'skipped'; + job.error = outcome.skippedEntries[index]?.reason || 'Kein gültiger Account'; + } + return outcome; +} + async function startUpload(opts) { if (uploading) return { ok: false, error: 'Upload läuft bereits.' }; if (await isAutomationPaused()) return { ok: false, error: 'Automatik ist pausiert' }; @@ -3946,20 +4000,22 @@ async function startSelectedUpload(explicitJobs) { showCopyToast(error); return { ok: false, error }; } - if ((Number(result?.added) || 0) !== addable.length) { - restoreSourceCleanupStates(cleanupPreparation.rollbackStates); - restoreUploadJobStates(originalStates); - renderQueueTable(); - const error = 'Jobs wurden nicht vollständig hinzugefügt.'; + const outcome = applyAddJobsOutcome(addable, result, { + cleanupStates: cleanupPreparation.rollbackStates, + uploadStates: originalStates + }); + renderQueueTable(); + if (!outcome.consistent) { + persistQueueStateSoon(); + const error = 'Jobs konnten nicht eindeutig bestätigt werden.'; showCopyToast(error); return { ok: false, error }; } - _markSkippedJobs(result); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } persistQueueStateSoon(); - const added = Number(result && result.added) || 0; + const added = outcome.added; // Use ASCII-only toast text here to avoid encoding artifacts on some systems. const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0; const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds) @@ -4020,6 +4076,17 @@ async function startSelectedUpload(explicitJobs) { await showAppAlert(error, 'Upload-Start fehlgeschlagen'); return { ok: false, error }; } + if (result?.started !== true) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); + restoreUploadJobStates(originalStates); + const error = 'Upload wurde nicht bestätigt.'; + uploading = false; + renderQueueTable(); + updateQueueActionButtons(); + updateStatusBar(); + await showAppAlert(error, 'Upload-Start fehlgeschlagen'); + return { ok: false, error }; + } if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 20da481..c7530d9 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -71,6 +71,7 @@ let automationProbe = { automationStatusSequence: [], historyError: '', addResult: null, + addMode: '', addError: '', startResult: null, startError: '', @@ -169,6 +170,7 @@ contextBridge.exposeInMainWorld('api', { automationStatusSequence: Array.isArray(value.automationStatusSequence) ? value.automationStatusSequence.map(entry => ({ ...entry })) : [], historyError: String(value.historyError || ''), addResult: value.addResult || null, + addMode: String(value.addMode || ''), addError: String(value.addError || ''), startResult: value.startResult || null, startError: String(value.startError || ''), @@ -253,6 +255,13 @@ contextBridge.exposeInMainWorld('api', { folderMonitorProbeCalls.push(['inject', payload?.jobs?.length || 0]); automationProbe.mutationCalls.push(['inject', payload?.jobs?.length || 0]); if (automationProbe.addError) return Promise.reject(new Error(automationProbe.addError)); + if (automationProbe.addMode === 'partial-consistent' && payload?.jobs?.length >= 4) { + return Promise.resolve({ + added: payload.jobs.length - 2, + alreadyInBatchJobIds: [payload.jobs[1].id], + skippedJobs: [{ jobId: payload.jobs[2].id, reason: 'Kein gültiger Account' }] + }); + } return Promise.resolve(automationProbe.addResult || { added: payload?.jobs?.length || 0 }); }, startUpload(payload) { @@ -969,6 +978,206 @@ contextBridge.exposeInMainWorld('api', { exception: await runCleanupRollbackCase('exception', { addError: 'token=secret-value' }), unconfirmed: await runCleanupRollbackCase('unconfirmed', { addResult: { added: 0 } }) }; + const cleanupPresenceState = job => Object.fromEntries([ + 'sourceCleanupToken', + 'sourceCleanupRequiredHosters', + 'sourceCleanupCompletedHosters', + 'sourceCleanupFingerprint' + ].map(field => [field, { + present: Object.prototype.hasOwnProperty.call(job, field), + value: Object.prototype.hasOwnProperty.call(job, field) ? clone(job[field]) : null + }])); + const setupCrossPathCleanup = name => { + configureAtomicState(0); + config.globalSettings.deleteSourceAfterSuccessfulUpload = true; + selectedUploadHosters = ['doodstream.com']; + const token = 'cross-token-' + name; + const target = { + ...makePauseRaceJob(name + '.mkv'), + id: 'cross-target-' + name, + file: 'C:\\\\cleanup-target\\\\' + name + '.mkv', + sourceCleanupToken: token, + sourceCleanupRequiredHosters: ['doodstream.com'] + }; + const sibling = { + ...makePauseRaceJob(name + '-done.mkv'), + id: 'cross-sibling-' + name, + file: 'D:\\\\cleanup-sibling\\\\' + name + '.mkv', + hoster: 'voe.sx', + status: 'done', + sourceCleanupToken: token + }; + const unrelated = { + ...makePauseRaceJob(name + '-unrelated.mkv'), + id: 'cross-unrelated-' + name, + file: 'E:\\\\cleanup-unrelated\\\\' + name + '.mkv', + hoster: 'vidmoly.me', + status: 'done' + }; + queueJobs = [target, sibling, unrelated]; + rebuildJobIndex(); + return { target, sibling, unrelated, token }; + }; + const crossPathResult = (fixture, before, result) => ({ + byteIdentical: JSON.stringify(fixture.map(cleanupPresenceState)) === before, + statuses: fixture.map(job => job.status), + result + }); + const originalAlertForCrossPath = showAppAlert; + showAppAlert = async () => {}; + let fixture = setupCrossPathCleanup('start-upload'); + let fixtureJobs = [fixture.target, fixture.sibling, fixture.unrelated]; + let fixtureBefore = JSON.stringify(fixtureJobs.map(cleanupPresenceState)); + window.api.configureAutomationProbe({ paused: false, startResult: { error: 'Automatik ist pausiert' } }); + const startUploadCleanup = crossPathResult(fixtureJobs, fixtureBefore, await startUpload()); + fixture = setupCrossPathCleanup('inactive-selected'); + fixtureJobs = [fixture.target, fixture.sibling, fixture.unrelated]; + fixtureBefore = JSON.stringify(fixtureJobs.map(cleanupPresenceState)); + uploading = false; + window.api.configureAutomationProbe({ paused: false, startResult: { started: false } }); + const inactiveSelectedCleanup = crossPathResult(fixtureJobs, fixtureBefore, await startSelectedUpload([fixture.target])); + fixture = setupCrossPathCleanup('active-selected'); + fixtureJobs = [fixture.target, fixture.sibling, fixture.unrelated]; + fixtureBefore = JSON.stringify(fixtureJobs.map(cleanupPresenceState)); + uploading = true; + window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } }); + const activeSelectedCleanup = crossPathResult(fixtureJobs, fixtureBefore, await startSelectedUpload([fixture.target])); + fixture = setupCrossPathCleanup('manual-modal'); + fixture.target.automationAdmission = true; + fixtureJobs = [fixture.target, fixture.sibling, fixture.unrelated]; + fixtureBefore = JSON.stringify(fixtureJobs.map(cleanupPresenceState)); + uploading = true; + _pendingFiles = [{ path: fixture.target.file, name: fixture.target.fileName, size: 1 }]; + _pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: _pendingFiles.slice() }; + _pendingImportInspections = 0; + _pendingFolderMonitorAutoStart.clear(); + const crossManualInput = document.createElement('input'); + crossManualInput.type = 'checkbox'; + crossManualInput.dataset.hosterModal = 'doodstream.com'; + crossManualInput.checked = true; + document.getElementById('hosterModalList').replaceChildren(crossManualInput); + window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } }); + const manualModalCleanup = crossPathResult(fixtureJobs, fixtureBefore, await applyHosterSelection()); + fixture = setupCrossPathCleanup('automation'); + queueJobs = [fixture.sibling, fixture.unrelated]; + rebuildJobIndex(); + config.globalSettings.folderMonitor.hosters = ['doodstream.com']; + config.globalSettings.folderMonitor.autoStart = true; + uploading = true; + const originalCreateAutomationPreviewJob = createAutomationPreviewJob; + createAutomationPreviewJob = (file, hoster) => ({ + ...originalCreateAutomationPreviewJob(file, hoster), + sourceCleanupToken: fixture.token + }); + const automationFile = { path: fixture.target.file, name: fixture.target.fileName, size: 1, mtimeMs: 1 }; + window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } }); + const automationEvaluation = await evaluateAutomationCandidates([automationFile], { dryRun: false, trigger: 'watcher' }); + const automationBeforeJobs = [fixture.sibling, fixture.unrelated]; + const automationBefore = JSON.stringify(automationBeforeJobs.map(cleanupPresenceState)); + const automationResult = await applyAutomationEvaluation(automationEvaluation); + const automationTarget = queueJobs.find(job => job.file === automationFile.path); + createAutomationPreviewJob = originalCreateAutomationPreviewJob; + showAppAlert = originalAlertForCrossPath; + uploading = false; + const crossPathCleanupRollback = { + startUpload: startUploadCleanup, + inactiveSelected: inactiveSelectedCleanup, + activeSelected: activeSelectedCleanup, + manualModal: manualModalCleanup, + automation: { + byteIdentical: JSON.stringify(automationBeforeJobs.map(cleanupPresenceState)) === automationBefore, + targetCleanup: cleanupPresenceState(automationTarget), + statuses: [automationTarget.status, fixture.sibling.status, fixture.unrelated.status], + result: { ok: automationResult.ok, error: automationResult.error } + } + }; + const createPartialJobs = prefix => hosters.map((hoster, index) => ({ + ...makePauseRaceJob(prefix + '-' + index + '.mkv'), + id: prefix + '-' + index, + file: 'C:\\\\partial\\\\' + prefix + '-' + index + '.mkv', + hoster + })); + const runPartialSelected = async consistent => { + configureAtomicState(0); + config.globalSettings.deleteSourceAfterSuccessfulUpload = true; + const jobs = createPartialJobs(consistent ? 'consistent' : 'inconsistent'); + queueJobs = jobs; + uploading = true; + rebuildJobIndex(); + const before = jobs.map(job => JSON.stringify(cleanupPresenceState(job))); + window.api.configureAutomationProbe({ + paused: false, + addResult: { + added: consistent ? 2 : 1, + alreadyInBatchJobIds: [jobs[1].id], + skippedJobs: [{ jobId: jobs[2].id, reason: 'Kein gültiger Account' }] + } + }); + const result = await startSelectedUpload(jobs); + const after = jobs.map(job => JSON.stringify(cleanupPresenceState(job))); + uploading = false; + return { + result, + statuses: jobs.map(job => job.status), + unconfirmedRestored: [0, 3].map(index => after[index] === before[index]), + confirmedPrepared: [1, 2].map(index => after[index] !== before[index]) + }; + }; + const partialSelectedConsistent = await runPartialSelected(true); + const partialSelectedInconsistent = await runPartialSelected(false); + configureAtomicState(0); + config.globalSettings.deleteSourceAfterSuccessfulUpload = true; + config.globalSettings.folderMonitor.hosters = hosters.slice(); + config.globalSettings.folderMonitor.autoStart = true; + selectedFiles = []; + uploading = true; + window.api.configureAutomationProbe({ paused: false, addMode: 'partial-consistent' }); + const partialAutomationFile = { path: 'C:\\\\partial\\\\automation.mkv', name: 'automation.mkv', size: 1, mtimeMs: 1 }; + const partialAutomationEvaluation = await evaluateAutomationCandidates([partialAutomationFile], { dryRun: false, trigger: 'watcher' }); + const partialAutomationResult = await applyAutomationEvaluation(partialAutomationEvaluation); + const partialAutomationStatuses = Object.fromEntries(queueJobs + .filter(job => job.file === partialAutomationFile.path) + .map(job => [job.hoster, job.status])); + uploading = false; + configureAtomicState(0); + config.globalSettings.deleteSourceAfterSuccessfulUpload = true; + selectedFiles = []; + uploading = true; + const partialManualFile = { path: 'C:\\\\partial\\\\manual.mkv', name: 'manual.mkv', size: 1 }; + _pendingFiles = [partialManualFile]; + _pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [partialManualFile] }; + _pendingImportInspections = 0; + _pendingFolderMonitorAutoStart.clear(); + const partialManualInputs = hosters.map(hoster => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.dataset.hosterModal = hoster; + input.checked = true; + return input; + }); + document.getElementById('hosterModalList').replaceChildren(...partialManualInputs); + window.api.configureAutomationProbe({ paused: false, addMode: 'partial-consistent' }); + const partialManualResult = await applyHosterSelection(); + const partialManualStatuses = Object.fromEntries(queueJobs + .filter(job => job.file === partialManualFile.path) + .map(job => [job.hoster, job.status])); + uploading = false; + const partialAddOutcomes = { + selectedConsistent: partialSelectedConsistent, + selectedInconsistent: partialSelectedInconsistent, + automation: { + result: { + ok: partialAutomationResult.ok, + error: partialAutomationResult.error || null, + admitted: partialAutomationResult.admittedFiles.map(file => file.name) + }, + statuses: partialAutomationStatuses + }, + manual: { + result: partialManualResult, + statuses: partialManualStatuses + } + }; configureAtomicState(0); selectedFiles = []; @@ -1162,7 +1371,7 @@ contextBridge.exposeInMainWorld('api', { startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length }; - return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; + return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; })()`; const onlineBackupBehaviorScript = `(async () => { const ids = { @@ -1673,7 +1882,72 @@ app.whenReady().then(async () => { cleanupByteIdentical: true, targetStatus: 'preview', siblingStatus: 'done', - result: { ok: false, error: 'Jobs wurden nicht vollständig hinzugefügt.' } + result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' } + } + }); + assert.deepEqual(result.automationPipeline.crossPathCleanupRollback, { + startUpload: { + byteIdentical: true, + statuses: ['preview', 'done', 'done'], + result: { ok: false, error: 'Automatik ist pausiert' } + }, + inactiveSelected: { + byteIdentical: true, + statuses: ['preview', 'done', 'done'], + result: { ok: false, error: 'Upload wurde nicht bestätigt.' } + }, + activeSelected: { + byteIdentical: true, + statuses: ['preview', 'done', 'done'], + result: { ok: false, error: 'Automatik ist pausiert' } + }, + manualModal: { + byteIdentical: true, + statuses: ['preview', 'done', 'done'], + result: { ok: false, error: 'Automatik ist pausiert' } + }, + automation: { + byteIdentical: true, + targetCleanup: { + sourceCleanupToken: { present: true, value: 'cross-token-automation' }, + sourceCleanupRequiredHosters: { present: false, value: null }, + sourceCleanupCompletedHosters: { present: false, value: null }, + sourceCleanupFingerprint: { present: false, value: null } + }, + statuses: ['preview', 'done', 'done'], + result: { ok: false, error: 'Automatik ist pausiert' } + } + }); + assert.deepEqual(result.automationPipeline.partialAddOutcomes, { + selectedConsistent: { + result: { ok: true, added: 2 }, + statuses: ['queued', 'queued', 'skipped', 'queued'], + unconfirmedRestored: [false, false], + confirmedPrepared: [true, true] + }, + selectedInconsistent: { + result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' }, + statuses: ['preview', 'queued', 'skipped', 'preview'], + unconfirmedRestored: [true, true], + confirmedPrepared: [true, true] + }, + automation: { + result: { ok: true, error: null, admitted: ['automation.mkv'] }, + statuses: { + 'doodstream.com': 'queued', + 'voe.sx': 'queued', + 'vidmoly.me': 'skipped', + 'byse.sx': 'queued' + } + }, + manual: { + result: true, + statuses: { + 'doodstream.com': 'queued', + 'voe.sx': 'queued', + 'vidmoly.me': 'skipped', + 'byse.sx': 'queued' + } } }); assert.deepEqual(result.automationPipeline.pauseBetweenApplyAndStart, { @@ -1735,7 +2009,7 @@ app.whenReady().then(async () => { }, unconfirmedInjection: { ok: false, - error: 'Jobs wurden nicht vollständig hinzugefügt.', + error: 'Jobs konnten nicht eindeutig bestätigt werden.', warning: null, admitted: [], status: 'preview',