diff options
Diffstat (limited to 'packages/tests/src/api/runners/runner-live-transcoding.ts')
-rw-r--r-- | packages/tests/src/api/runners/runner-live-transcoding.ts | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/packages/tests/src/api/runners/runner-live-transcoding.ts b/packages/tests/src/api/runners/runner-live-transcoding.ts new file mode 100644 index 000000000..20c1e5c2a --- /dev/null +++ b/packages/tests/src/api/runners/runner-live-transcoding.ts | |||
@@ -0,0 +1,332 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import { expect } from 'chai' | ||
4 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
5 | import { readFile } from 'fs/promises' | ||
6 | import { wait } from '@peertube/peertube-core-utils' | ||
7 | import { | ||
8 | HttpStatusCode, | ||
9 | LiveRTMPHLSTranscodingUpdatePayload, | ||
10 | LiveVideo, | ||
11 | LiveVideoError, | ||
12 | LiveVideoErrorType, | ||
13 | RunnerJob, | ||
14 | RunnerJobLiveRTMPHLSTranscodingPayload, | ||
15 | Video, | ||
16 | VideoPrivacy, | ||
17 | VideoState | ||
18 | } from '@peertube/peertube-models' | ||
19 | import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' | ||
20 | import { | ||
21 | cleanupTests, | ||
22 | createSingleServer, | ||
23 | makeRawRequest, | ||
24 | PeerTubeServer, | ||
25 | sendRTMPStream, | ||
26 | setAccessTokensToServers, | ||
27 | setDefaultVideoChannel, | ||
28 | stopFfmpeg, | ||
29 | testFfmpegStreamError, | ||
30 | waitJobs | ||
31 | } from '@peertube/peertube-server-commands' | ||
32 | |||
33 | describe('Test runner live transcoding', function () { | ||
34 | let server: PeerTubeServer | ||
35 | let runnerToken: string | ||
36 | let baseUrl: string | ||
37 | |||
38 | before(async function () { | ||
39 | this.timeout(120_000) | ||
40 | |||
41 | server = await createSingleServer(1) | ||
42 | |||
43 | await setAccessTokensToServers([ server ]) | ||
44 | await setDefaultVideoChannel([ server ]) | ||
45 | |||
46 | await server.config.enableRemoteTranscoding() | ||
47 | await server.config.enableTranscoding() | ||
48 | runnerToken = await server.runners.autoRegisterRunner() | ||
49 | |||
50 | baseUrl = server.url + '/static/streaming-playlists/hls' | ||
51 | }) | ||
52 | |||
53 | describe('Without transcoding enabled', function () { | ||
54 | |||
55 | before(async function () { | ||
56 | await server.config.enableLive({ | ||
57 | allowReplay: false, | ||
58 | resolutions: 'min', | ||
59 | transcoding: false | ||
60 | }) | ||
61 | }) | ||
62 | |||
63 | it('Should not have available jobs', async function () { | ||
64 | this.timeout(120000) | ||
65 | |||
66 | const { live, video } = await server.live.quickCreate({ permanentLive: true, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) | ||
67 | |||
68 | const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) | ||
69 | await server.live.waitUntilPublished({ videoId: video.id }) | ||
70 | |||
71 | await waitJobs([ server ]) | ||
72 | |||
73 | const { availableJobs } = await server.runnerJobs.requestLive({ runnerToken }) | ||
74 | expect(availableJobs).to.have.lengthOf(0) | ||
75 | |||
76 | await stopFfmpeg(ffmpegCommand) | ||
77 | }) | ||
78 | }) | ||
79 | |||
80 | describe('With transcoding enabled on classic live', function () { | ||
81 | let live: LiveVideo | ||
82 | let video: Video | ||
83 | let ffmpegCommand: FfmpegCommand | ||
84 | let jobUUID: string | ||
85 | let acceptedJob: RunnerJob & { jobToken: string } | ||
86 | |||
87 | async function testPlaylistFile (fixture: string, expected: string) { | ||
88 | const text = await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/${fixture}` }) | ||
89 | expect(await readFile(buildAbsoluteFixturePath(expected), 'utf-8')).to.equal(text) | ||
90 | |||
91 | } | ||
92 | |||
93 | async function testTSFile (fixture: string, expected: string) { | ||
94 | const { body } = await makeRawRequest({ url: `${baseUrl}/${video.uuid}/${fixture}`, expectedStatus: HttpStatusCode.OK_200 }) | ||
95 | expect(await readFile(buildAbsoluteFixturePath(expected))).to.deep.equal(body) | ||
96 | } | ||
97 | |||
98 | before(async function () { | ||
99 | await server.config.enableLive({ | ||
100 | allowReplay: true, | ||
101 | resolutions: 'max', | ||
102 | transcoding: true | ||
103 | }) | ||
104 | }) | ||
105 | |||
106 | it('Should publish a a live and have available jobs', async function () { | ||
107 | this.timeout(120000) | ||
108 | |||
109 | const data = await server.live.quickCreate({ permanentLive: false, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) | ||
110 | live = data.live | ||
111 | video = data.video | ||
112 | |||
113 | ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) | ||
114 | await waitJobs([ server ]) | ||
115 | |||
116 | const job = await server.runnerJobs.requestLiveJob(runnerToken) | ||
117 | jobUUID = job.uuid | ||
118 | |||
119 | expect(job.type).to.equal('live-rtmp-hls-transcoding') | ||
120 | expect(job.payload.input.rtmpUrl).to.exist | ||
121 | |||
122 | expect(job.payload.output.toTranscode).to.have.lengthOf(5) | ||
123 | |||
124 | for (const { resolution, fps } of job.payload.output.toTranscode) { | ||
125 | expect([ 720, 480, 360, 240, 144 ]).to.contain(resolution) | ||
126 | |||
127 | expect(fps).to.be.above(25) | ||
128 | expect(fps).to.be.below(70) | ||
129 | } | ||
130 | }) | ||
131 | |||
132 | it('Should update the live with a new chunk', async function () { | ||
133 | this.timeout(120000) | ||
134 | |||
135 | const { job } = await server.runnerJobs.accept<RunnerJobLiveRTMPHLSTranscodingPayload>({ jobUUID, runnerToken }) | ||
136 | acceptedJob = job | ||
137 | |||
138 | { | ||
139 | const payload: LiveRTMPHLSTranscodingUpdatePayload = { | ||
140 | masterPlaylistFile: 'live/master.m3u8', | ||
141 | resolutionPlaylistFile: 'live/0.m3u8', | ||
142 | resolutionPlaylistFilename: '0.m3u8', | ||
143 | type: 'add-chunk', | ||
144 | videoChunkFile: 'live/0-000067.ts', | ||
145 | videoChunkFilename: '0-000067.ts' | ||
146 | } | ||
147 | await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: job.jobToken, payload, progress: 50 }) | ||
148 | |||
149 | const updatedJob = await server.runnerJobs.getJob({ uuid: job.uuid }) | ||
150 | expect(updatedJob.progress).to.equal(50) | ||
151 | } | ||
152 | |||
153 | { | ||
154 | const payload: LiveRTMPHLSTranscodingUpdatePayload = { | ||
155 | resolutionPlaylistFile: 'live/1.m3u8', | ||
156 | resolutionPlaylistFilename: '1.m3u8', | ||
157 | type: 'add-chunk', | ||
158 | videoChunkFile: 'live/1-000068.ts', | ||
159 | videoChunkFilename: '1-000068.ts' | ||
160 | } | ||
161 | await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: job.jobToken, payload }) | ||
162 | } | ||
163 | |||
164 | await wait(1000) | ||
165 | |||
166 | await testPlaylistFile('master.m3u8', 'live/master.m3u8') | ||
167 | await testPlaylistFile('0.m3u8', 'live/0.m3u8') | ||
168 | await testPlaylistFile('1.m3u8', 'live/1.m3u8') | ||
169 | |||
170 | await testTSFile('0-000067.ts', 'live/0-000067.ts') | ||
171 | await testTSFile('1-000068.ts', 'live/1-000068.ts') | ||
172 | }) | ||
173 | |||
174 | it('Should replace existing m3u8 on update', async function () { | ||
175 | this.timeout(120000) | ||
176 | |||
177 | const payload: LiveRTMPHLSTranscodingUpdatePayload = { | ||
178 | masterPlaylistFile: 'live/1.m3u8', | ||
179 | resolutionPlaylistFilename: '0.m3u8', | ||
180 | resolutionPlaylistFile: 'live/1.m3u8', | ||
181 | type: 'add-chunk', | ||
182 | videoChunkFile: 'live/1-000069.ts', | ||
183 | videoChunkFilename: '1-000068.ts' | ||
184 | } | ||
185 | await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: acceptedJob.jobToken, payload }) | ||
186 | await wait(1000) | ||
187 | |||
188 | await testPlaylistFile('master.m3u8', 'live/1.m3u8') | ||
189 | await testPlaylistFile('0.m3u8', 'live/1.m3u8') | ||
190 | await testTSFile('1-000068.ts', 'live/1-000069.ts') | ||
191 | }) | ||
192 | |||
193 | it('Should update the live with removed chunks', async function () { | ||
194 | this.timeout(120000) | ||
195 | |||
196 | const payload: LiveRTMPHLSTranscodingUpdatePayload = { | ||
197 | resolutionPlaylistFile: 'live/0.m3u8', | ||
198 | resolutionPlaylistFilename: '0.m3u8', | ||
199 | type: 'remove-chunk', | ||
200 | videoChunkFilename: '1-000068.ts' | ||
201 | } | ||
202 | await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: acceptedJob.jobToken, payload }) | ||
203 | |||
204 | await wait(1000) | ||
205 | |||
206 | await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/master.m3u8` }) | ||
207 | await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/0.m3u8` }) | ||
208 | await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/1.m3u8` }) | ||
209 | await makeRawRequest({ url: `${baseUrl}/${video.uuid}/0-000067.ts`, expectedStatus: HttpStatusCode.OK_200 }) | ||
210 | await makeRawRequest({ url: `${baseUrl}/${video.uuid}/1-000068.ts`, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) | ||
211 | }) | ||
212 | |||
213 | it('Should complete the live and save the replay', async function () { | ||
214 | this.timeout(120000) | ||
215 | |||
216 | for (const segment of [ '0-000069.ts', '0-000070.ts' ]) { | ||
217 | const payload: LiveRTMPHLSTranscodingUpdatePayload = { | ||
218 | masterPlaylistFile: 'live/master.m3u8', | ||
219 | resolutionPlaylistFilename: '0.m3u8', | ||
220 | resolutionPlaylistFile: 'live/0.m3u8', | ||
221 | type: 'add-chunk', | ||
222 | videoChunkFile: 'live/' + segment, | ||
223 | videoChunkFilename: segment | ||
224 | } | ||
225 | await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: acceptedJob.jobToken, payload }) | ||
226 | |||
227 | await wait(1000) | ||
228 | } | ||
229 | |||
230 | await waitJobs([ server ]) | ||
231 | |||
232 | { | ||
233 | const { state } = await server.videos.get({ id: video.uuid }) | ||
234 | expect(state.id).to.equal(VideoState.PUBLISHED) | ||
235 | } | ||
236 | |||
237 | await stopFfmpeg(ffmpegCommand) | ||
238 | |||
239 | await server.runnerJobs.success({ jobUUID, runnerToken, jobToken: acceptedJob.jobToken, payload: {} }) | ||
240 | |||
241 | await wait(1500) | ||
242 | await waitJobs([ server ]) | ||
243 | |||
244 | { | ||
245 | const { state } = await server.videos.get({ id: video.uuid }) | ||
246 | expect(state.id).to.equal(VideoState.LIVE_ENDED) | ||
247 | |||
248 | const session = await server.live.findLatestSession({ videoId: video.uuid }) | ||
249 | expect(session.error).to.be.null | ||
250 | } | ||
251 | }) | ||
252 | }) | ||
253 | |||
254 | describe('With transcoding enabled on cancelled/aborted/errored live', function () { | ||
255 | let live: LiveVideo | ||
256 | let video: Video | ||
257 | let ffmpegCommand: FfmpegCommand | ||
258 | |||
259 | async function prepare () { | ||
260 | ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) | ||
261 | await server.runnerJobs.requestLiveJob(runnerToken) | ||
262 | |||
263 | const { job } = await server.runnerJobs.autoAccept({ runnerToken, type: 'live-rtmp-hls-transcoding' }) | ||
264 | |||
265 | return job | ||
266 | } | ||
267 | |||
268 | async function checkSessionError (error: LiveVideoErrorType) { | ||
269 | await wait(1500) | ||
270 | await waitJobs([ server ]) | ||
271 | |||
272 | const session = await server.live.findLatestSession({ videoId: video.uuid }) | ||
273 | expect(session.error).to.equal(error) | ||
274 | } | ||
275 | |||
276 | before(async function () { | ||
277 | await server.config.enableLive({ | ||
278 | allowReplay: true, | ||
279 | resolutions: 'max', | ||
280 | transcoding: true | ||
281 | }) | ||
282 | |||
283 | const data = await server.live.quickCreate({ permanentLive: true, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) | ||
284 | live = data.live | ||
285 | video = data.video | ||
286 | }) | ||
287 | |||
288 | it('Should abort a running live', async function () { | ||
289 | this.timeout(120000) | ||
290 | |||
291 | const job = await prepare() | ||
292 | |||
293 | await Promise.all([ | ||
294 | server.runnerJobs.abort({ jobUUID: job.uuid, runnerToken, jobToken: job.jobToken, reason: 'abort' }), | ||
295 | testFfmpegStreamError(ffmpegCommand, true) | ||
296 | ]) | ||
297 | |||
298 | // Abort is not supported | ||
299 | await checkSessionError(LiveVideoError.RUNNER_JOB_ERROR) | ||
300 | }) | ||
301 | |||
302 | it('Should cancel a running live', async function () { | ||
303 | this.timeout(120000) | ||
304 | |||
305 | const job = await prepare() | ||
306 | |||
307 | await Promise.all([ | ||
308 | server.runnerJobs.cancelByAdmin({ jobUUID: job.uuid }), | ||
309 | testFfmpegStreamError(ffmpegCommand, true) | ||
310 | ]) | ||
311 | |||
312 | await checkSessionError(LiveVideoError.RUNNER_JOB_CANCEL) | ||
313 | }) | ||
314 | |||
315 | it('Should error a running live', async function () { | ||
316 | this.timeout(120000) | ||
317 | |||
318 | const job = await prepare() | ||
319 | |||
320 | await Promise.all([ | ||
321 | server.runnerJobs.error({ jobUUID: job.uuid, runnerToken, jobToken: job.jobToken, message: 'error' }), | ||
322 | testFfmpegStreamError(ffmpegCommand, true) | ||
323 | ]) | ||
324 | |||
325 | await checkSessionError(LiveVideoError.RUNNER_JOB_ERROR) | ||
326 | }) | ||
327 | }) | ||
328 | |||
329 | after(async function () { | ||
330 | await cleanupTests([ server ]) | ||
331 | }) | ||
332 | }) | ||