diff --git a/packages/tests/src/api/transcoding/create-transcoding.ts b/packages/tests/src/api/transcoding/create-transcoding.ts index a5caaf82a..b6b91d51e 100644 --- a/packages/tests/src/api/transcoding/create-transcoding.ts +++ b/packages/tests/src/api/transcoding/create-transcoding.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { HttpStatusCode, VideoDetails } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { cleanupTests, ConfigCommand, @@ -16,7 +15,8 @@ import { waitJobs } from '@peertube/peertube-server-commands' import { expectStartWith } from '@tests/shared/checks.js' -import { checkResolutionsInMasterPlaylist } from '@tests/shared/streaming-playlists.js' +import { checkResolutionsInMasterPlaylist, completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' +import { expect } from 'chai' async function checkFilesInObjectStorage (objectStorage: ObjectStorageCommand, video: VideoDetails) { for (const file of video.files) { @@ -81,175 +81,273 @@ function runTests (options: { await servers[0].config.setTranscodingConcurrency(concurrency) }) - it('Should generate HLS', async function () { - this.timeout(60000) + describe('Common transcoding', function () { - await servers[0].videos.runTranscoding({ - videoId: videoUUID, - transcodingType: 'hls' + it('Should generate HLS', async function () { + this.timeout(60000) + + await servers[0].videos.runTranscoding({ + videoId: videoUUID, + transcodingType: 'hls' + }) + + await waitJobs(servers) + await expectNoFailedTranscodingJob(servers[0]) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) + + expect(videoDetails.files).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + } }) - await waitJobs(servers) - await expectNoFailedTranscodingJob(servers[0]) + it('Should generate Web Video', async function () { + this.timeout(60000) - for (const server of servers) { - const videoDetails = await server.videos.get({ id: videoUUID }) + await servers[0].videos.runTranscoding({ + videoId: videoUUID, + transcodingType: 'web-video' + }) - expect(videoDetails.files).to.have.lengthOf(1) - expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) - expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + await waitJobs(servers) - if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) - } - }) + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) - it('Should generate Web Video', async function () { - this.timeout(60000) + expect(videoDetails.files).to.have.lengthOf(5) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) - await servers[0].videos.runTranscoding({ - videoId: videoUUID, - transcodingType: 'web-video' + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + } }) - await waitJobs(servers) + it('Should generate Web Video from HLS only video', async function () { + this.timeout(60000) - for (const server of servers) { - const videoDetails = await server.videos.get({ id: videoUUID }) + await servers[0].videos.removeAllWebVideoFiles({ videoId: videoUUID }) + await waitJobs(servers) - expect(videoDetails.files).to.have.lengthOf(5) - expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) - expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' }) + await waitJobs(servers) - if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) - } - }) + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) - it('Should generate Web Video from HLS only video', async function () { - this.timeout(60000) + expect(videoDetails.files).to.have.lengthOf(5) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) - await servers[0].videos.removeAllWebVideoFiles({ videoId: videoUUID }) - await waitJobs(servers) + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + } + }) - await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' }) - await waitJobs(servers) + it('Should only generate Web Video', async function () { + this.timeout(60000) - for (const server of servers) { - const videoDetails = await server.videos.get({ id: videoUUID }) + await servers[0].videos.removeHLSPlaylist({ videoId: videoUUID }) + await waitJobs(servers) - expect(videoDetails.files).to.have.lengthOf(5) - expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) - expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' }) + await waitJobs(servers) - if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) - } - }) + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) - it('Should only generate Web Video', async function () { - this.timeout(60000) + expect(videoDetails.files).to.have.lengthOf(5) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(0) - await servers[0].videos.removeHLSPlaylist({ videoId: videoUUID }) - await waitJobs(servers) + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + } + }) - await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' }) - await waitJobs(servers) + it('Should correctly update HLS playlist on resolution change', async function () { + this.timeout(120000) - for (const server of servers) { - const videoDetails = await server.videos.get({ id: videoUUID }) + await servers[0].config.updateExistingConfig({ + newConfig: { + transcoding: { + enabled: true, + resolutions: ConfigCommand.getConfigResolutions(false), - expect(videoDetails.files).to.have.lengthOf(5) - expect(videoDetails.streamingPlaylists).to.have.lengthOf(0) - - if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) - } - }) - - it('Should correctly update HLS playlist on resolution change', async function () { - this.timeout(120000) - - await servers[0].config.updateExistingConfig({ - newConfig: { - transcoding: { - enabled: true, - resolutions: ConfigCommand.getConfigResolutions(false), - - webVideos: { - enabled: true - }, - hls: { - enabled: true + webVideos: { + enabled: true + }, + hls: { + enabled: true + } } } + }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'quick' }) + + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: uuid }) + + expect(videoDetails.files).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(1) + + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + + shouldBeDeleted = [ + videoDetails.streamingPlaylists[0].files[0].fileUrl, + videoDetails.streamingPlaylists[0].playlistUrl, + videoDetails.streamingPlaylists[0].segmentsSha256Url + ] + } + + await servers[0].config.updateExistingConfig({ + newConfig: { + transcoding: { + enabled: true, + resolutions: ConfigCommand.getConfigResolutions(true), + + webVideos: { + enabled: true + }, + hls: { + enabled: true + } + } + } + }) + + await servers[0].videos.runTranscoding({ videoId: uuid, transcodingType: 'hls' }) + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: uuid }) + + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + + if (enableObjectStorage) { + await checkFilesInObjectStorage(objectStorage, videoDetails) + + const hlsPlaylist = videoDetails.streamingPlaylists[0] + const resolutions = hlsPlaylist.files.map(f => f.resolution.id) + await checkResolutionsInMasterPlaylist({ server: servers[0], playlistUrl: hlsPlaylist.playlistUrl, resolutions }) + + const shaBody = await servers[0].streamingPlaylists.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry: true }) + expect(Object.keys(shaBody)).to.have.lengthOf(5) + } } }) - const { uuid } = await servers[0].videos.quickUpload({ name: 'quick' }) - - await waitJobs(servers) - - for (const server of servers) { - const videoDetails = await server.videos.get({ id: uuid }) - - expect(videoDetails.files).to.have.lengthOf(1) - expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) - expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(1) - - if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) - - shouldBeDeleted = [ - videoDetails.streamingPlaylists[0].files[0].fileUrl, - videoDetails.streamingPlaylists[0].playlistUrl, - videoDetails.streamingPlaylists[0].segmentsSha256Url - ] - } - - await servers[0].config.updateExistingConfig({ - newConfig: { - transcoding: { - enabled: true, - resolutions: ConfigCommand.getConfigResolutions(true), - - webVideos: { - enabled: true - }, - hls: { - enabled: true - } - } + it('Should have correctly deleted previous files', async function () { + for (const fileUrl of shouldBeDeleted) { + await makeRawRequest({ url: fileUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) } }) - await servers[0].videos.runTranscoding({ videoId: uuid, transcodingType: 'hls' }) - await waitJobs(servers) + it('Should not have updated published at attributes', async function () { + const video = await servers[0].videos.get({ id: videoUUID }) - for (const server of servers) { - const videoDetails = await server.videos.get({ id: uuid }) + expect(video.publishedAt).to.equal(publishedAt) + }) + }) - expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) - expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + describe('With split audio and video', function () { - if (enableObjectStorage) { - await checkFilesInObjectStorage(objectStorage, videoDetails) + async function runTest (options: { + audio: boolean + hls: boolean + webVideo: boolean + afterWebVideo: boolean + resolutions?: number[] + }) { + let resolutions = options.resolutions - const hlsPlaylist = videoDetails.streamingPlaylists[0] - const resolutions = hlsPlaylist.files.map(f => f.resolution.id) - await checkResolutionsInMasterPlaylist({ server: servers[0], playlistUrl: hlsPlaylist.playlistUrl, resolutions }) + if (!resolutions) { + resolutions = [ 720, 240 ] - const shaBody = await servers[0].streamingPlaylists.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry: true }) - expect(Object.keys(shaBody)).to.have.lengthOf(5) + if (options.audio) resolutions.push(0) } + + const objectStorageBaseUrl = enableObjectStorage + ? objectStorage?.getMockPlaylistBaseUrl() + : undefined + + await servers[0].config.enableTranscoding({ + resolutions, + hls: options.hls, + splitAudioAndVideo: false, + webVideo: options.webVideo + }) + + const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'hls splitted' }) + await waitJobs(servers) + await completeCheckHlsPlaylist({ + servers, + resolutions, + videoUUID, + hlsOnly: !options.webVideo, + splittedAudio: false, + objectStorageBaseUrl + }) + + await servers[0].config.enableTranscoding({ + resolutions, + hls: true, + splitAudioAndVideo: true, + webVideo: options.afterWebVideo + }) + + await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'hls' }) + await waitJobs(servers) + + if (options.afterWebVideo) { + await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' }) + await waitJobs(servers) + } + + await completeCheckHlsPlaylist({ + servers, + resolutions, + videoUUID, + hlsOnly: !options.afterWebVideo, + splittedAudio: true, + objectStorageBaseUrl + }) } - }) - it('Should have correctly deleted previous files', async function () { - for (const fileUrl of shouldBeDeleted) { - await makeRawRequest({ url: fileUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) - } - }) + it('Should split audio and video from an existing Web & HLS video', async function () { + this.timeout(60000) - it('Should not have updated published at attributes', async function () { - const video = await servers[0].videos.get({ id: videoUUID }) + await runTest({ webVideo: true, hls: true, afterWebVideo: true, audio: false }) + }) - expect(video.publishedAt).to.equal(publishedAt) + it('Should split audio and video from an existing HLS video without audio resolution', async function () { + this.timeout(60000) + + await runTest({ webVideo: false, hls: true, afterWebVideo: true, audio: false }) + }) + + it('Should split audio and video to a HLS only video from an existing HLS video without audio resolution', async function () { + this.timeout(60000) + + await runTest({ webVideo: false, hls: true, afterWebVideo: false, audio: false }) + }) + + it('Should split audio and video to a HLS only video from an existing HLS video with audio resolution', async function () { + this.timeout(60000) + + await runTest({ webVideo: false, hls: true, afterWebVideo: false, audio: false }) + }) + + it('Should split audio and video on HLS only video that only have 1 resolution', async function () { + this.timeout(60000) + + await runTest({ webVideo: false, hls: true, afterWebVideo: false, audio: false, resolutions: [ 720 ] }) + }) }) after(async function () { diff --git a/packages/tests/src/peertube-runner/vod-transcoding.ts b/packages/tests/src/peertube-runner/vod-transcoding.ts index 2a55b9c4c..21fd85e34 100644 --- a/packages/tests/src/peertube-runner/vod-transcoding.ts +++ b/packages/tests/src/peertube-runner/vod-transcoding.ts @@ -243,6 +243,41 @@ describe('Test VOD transcoding in peertube-runner program', function () { resolutions: [ 720, 480, 360, 240, 144, 0 ] }) }) + + it('Should re-transcode a non splitted audio/video HLS only video', async function () { + this.timeout(240000) + + const resolutions = [ 720, 240 ] + + await servers[0].config.enableTranscoding({ + hls: true, + webVideo: false, + resolutions, + splitAudioAndVideo: false + }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'manual hls only transcoding', fixture: 'video_short.mp4' }) + await waitJobs(servers, { runnerJobs: true }) + + await servers[0].config.enableTranscoding({ + hls: hlsEnabled, + webVideo: webVideoEnabled, + resolutions, + splitAudioAndVideo: splittedAudio + }) + + await servers[0].videos.runTranscoding({ transcodingType: 'hls', videoId: uuid }) + await waitJobs(servers, { runnerJobs: true }) + + await completeCheckHlsPlaylist({ + hlsOnly: true, + servers: [ servers[0] ], + videoUUID: uuid, + splittedAudio, + objectStorageBaseUrl: objectStorageBaseUrlHLS, + resolutions + }) + }) } before(async function () { @@ -266,10 +301,12 @@ describe('Test VOD transcoding in peertube-runner program', function () { }) function runSuites (objectStorage?: ObjectStorageCommand) { + const resolutions = 'max' + describe('Web video only enabled', function () { before(async function () { - await servers[0].config.enableTranscoding({ resolutions: 'max', webVideo: true, hls: false, with0p: true }) + await servers[0].config.enableTranscoding({ resolutions, webVideo: true, hls: false, with0p: true }) }) runSpecificSuite({ webVideoEnabled: true, hlsEnabled: false, objectStorage }) @@ -278,7 +315,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { describe('HLS videos only enabled', function () { before(async function () { - await servers[0].config.enableTranscoding({ webVideo: false, hls: true, with0p: true }) + await servers[0].config.enableTranscoding({ resolutions, webVideo: false, hls: true, with0p: true }) }) runSpecificSuite({ webVideoEnabled: false, hlsEnabled: true, objectStorage }) @@ -287,7 +324,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { describe('HLS only with separated audio only enabled', function () { before(async function () { - await servers[0].config.enableTranscoding({ webVideo: false, hls: true, splitAudioAndVideo: true, with0p: true }) + await servers[0].config.enableTranscoding({ resolutions, webVideo: false, hls: true, splitAudioAndVideo: true, with0p: true }) }) runSpecificSuite({ webVideoEnabled: false, hlsEnabled: true, splittedAudio: true, objectStorage }) @@ -296,7 +333,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { describe('Web video & HLS with separated audio only enabled', function () { before(async function () { - await servers[0].config.enableTranscoding({ hls: true, webVideo: true, splitAudioAndVideo: true, with0p: true }) + await servers[0].config.enableTranscoding({ resolutions, hls: true, webVideo: true, splitAudioAndVideo: true, with0p: true }) }) runSpecificSuite({ webVideoEnabled: true, hlsEnabled: true, splittedAudio: true, objectStorage }) @@ -305,7 +342,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { describe('Web video & HLS enabled', function () { before(async function () { - await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true, splitAudioAndVideo: false }) + await servers[0].config.enableTranscoding({ resolutions, hls: true, webVideo: true, with0p: true, splitAudioAndVideo: false }) }) runSpecificSuite({ webVideoEnabled: true, hlsEnabled: true, objectStorage }) diff --git a/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts b/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts index 06875df5c..86d11134d 100644 --- a/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts +++ b/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts @@ -127,8 +127,7 @@ export abstract class AbstractJobBuilder

{ } await this.createJobs({ - parent: mergeOrOptimizePayload, - children, + payloads: [ [ mergeOrOptimizePayload ], ...children ], user, video }) @@ -151,19 +150,24 @@ export abstract class AbstractJobBuilder

{ const inputFPS = video.getMaxFPS() - const children = childrenResolutions.map(resolution => { - const fps = computeOutputFPS({ inputFPS, resolution, isOriginResolution: maxResolution === resolution, type: 'vod' }) + const children = childrenResolutions + .map(resolution => { + const fps = computeOutputFPS({ inputFPS, resolution, isOriginResolution: maxResolution === resolution, type: 'vod' }) - if (transcodingType === 'hls') { - return this.buildHLSJobPayload({ video, resolution, fps, isNewVideo, separatedAudio }) - } + if (transcodingType === 'hls') { + // We'll generate audio resolution in a parent job + if (resolution === VideoResolution.H_NOVIDEO && separatedAudio) return undefined - if (transcodingType === 'webtorrent' || transcodingType === 'web-video') { - return this.buildWebVideoJobPayload({ video, resolution, fps, isNewVideo }) - } + return this.buildHLSJobPayload({ video, resolution, fps, isNewVideo, separatedAudio }) + } - throw new Error('Unknown transcoding type') - }) + if (transcodingType === 'webtorrent' || transcodingType === 'web-video') { + return this.buildWebVideoJobPayload({ video, resolution, fps, isNewVideo }) + } + + throw new Error('Unknown transcoding type') + }) + .filter(r => !!r) const fps = computeOutputFPS({ inputFPS, resolution: maxResolution, isOriginResolution: true, type: 'vod' }) @@ -171,9 +175,17 @@ export abstract class AbstractJobBuilder

{ ? this.buildHLSJobPayload({ video, resolution: maxResolution, fps, isNewVideo, separatedAudio }) : this.buildWebVideoJobPayload({ video, resolution: maxResolution, fps, isNewVideo }) - // Process the last resolution after the other ones to prevent concurrency issue - // Because low resolutions use the biggest one as ffmpeg input - await this.createJobs({ video, parent, children: [ children ], user: null }) + // Low resolutions use the biggest one as ffmpeg input so we need to process max resolution (with audio) independently + const payloads: [ [ P ], ...(P[][]) ] = [ [ parent ] ] + + // Process audio first to not override the max resolution where the audio stream will be removed + if (transcodingType === 'hls' && separatedAudio) { + payloads.unshift([ this.buildHLSJobPayload({ video, resolution: VideoResolution.H_NOVIDEO, fps, isNewVideo, separatedAudio }) ]) + } + + if (children && children.length !== 0) payloads.push(children) + + await this.createJobs({ video, payloads, user: null }) } private async buildLowerResolutionJobPayloads (options: { @@ -247,8 +259,7 @@ export abstract class AbstractJobBuilder

{ protected abstract createJobs (options: { video: MVideoFullLight - parent: P - children: P[][] + payloads: [ [ P ], ...(P[][]) ] // Array of sequential jobs to create that depend on parent job user: MUserId | null }): Promise diff --git a/server/core/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts b/server/core/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts index c17a3e375..ea5cf5fd1 100644 --- a/server/core/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts +++ b/server/core/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts @@ -22,14 +22,16 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder { protected async createJobs (options: { video: MVideo - parent: Payload - children: Payload[][] + payloads: [ [ Payload ], ...(Payload[][]) ] // Array of sequential jobs to create that depend on parent job user: MUserId | null }): Promise { - const { video, parent, children, user } = options + const { video, payloads, user } = options - const nextTranscodingSequentialJobs = await Bluebird.mapSeries(children, payloads => { - return Bluebird.mapSeries(payloads, payload => { + const parent = payloads[0][0] + payloads.shift() + + const nextTranscodingSequentialJobs = await Bluebird.mapSeries(payloads, p => { + return Bluebird.mapSeries(p, payload => { return this.buildTranscodingJob({ payload, user }) }) }) @@ -42,9 +44,9 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder { } } - const mergeOrOptimizeJob = await this.buildTranscodingJob({ payload: parent, user, hasChildren: !!children.length }) + const parentJob = await this.buildTranscodingJob({ payload: parent, user, hasChildren: payloads.length !== 0 }) - await JobQueue.Instance.createSequentialJobFlow(mergeOrOptimizeJob, transcodingJobBuilderJob) + await JobQueue.Instance.createSequentialJobFlow(parentJob, transcodingJobBuilderJob) // transcoding-job-builder job will increase pendingTranscode await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode') diff --git a/server/core/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts b/server/core/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts index 2b041c20c..44cee513a 100644 --- a/server/core/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts +++ b/server/core/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts @@ -31,15 +31,17 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder { protected async createJobs (options: { video: MVideo - parent: Payload - children: Payload[][] // Array of sequential jobs to create that depend on parent job + payloads: [ [ Payload ], ...(Payload[][]) ] // Array of sequential jobs to create that depend on parent job user: MUserId | null }): Promise { - const { parent, children, user } = options + const { payloads, user } = options + + const parent = payloads[0][0] + payloads.shift() const parentJob = await this.createJob({ payload: parent, user }) - for (const parallelPayloads of children) { + for (const parallelPayloads of payloads) { let lastJob = parentJob for (const parallelPayload of parallelPayloads) {