]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - shared/extra-utils/videos/videos-command.ts
Fix silent 500 after resumable upload
[github/Chocobozzz/PeerTube.git] / shared / extra-utils / videos / videos-command.ts
CommitLineData
d23dd9fb
C
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3import { expect } from 'chai'
4import { createReadStream, stat } from 'fs-extra'
5import got, { Response as GotResponse } from 'got'
b033851f 6import { omit } from 'lodash'
d23dd9fb
C
7import validator from 'validator'
8import { buildUUID } from '@server/helpers/uuid'
9import { loadLanguages } from '@server/initializers/constants'
b033851f 10import { pick } from '@shared/core-utils'
d23dd9fb 11import {
4c7e60bc 12 HttpStatusCode,
d23dd9fb
C
13 ResultList,
14 UserVideoRateType,
15 Video,
16 VideoCreate,
17 VideoCreateResult,
18 VideoDetails,
19 VideoFileMetadata,
20 VideoPrivacy,
21 VideosCommonQuery,
22 VideosWithSearchCommonQuery
23} from '@shared/models'
24import { buildAbsoluteFixturePath, wait } from '../miscs'
25import { unwrapBody } from '../requests'
254d3579 26import { PeerTubeServer, waitJobs } from '../server'
d23dd9fb
C
27import { AbstractCommand, OverrideCommandOptions } from '../shared'
28
29export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
30 fixture?: string
31 thumbnailfile?: string
32 previewfile?: string
33}
34
35export class VideosCommand extends AbstractCommand {
36
254d3579 37 constructor (server: PeerTubeServer) {
d23dd9fb
C
38 super(server)
39
40 loadLanguages()
41 }
42
43 getCategories (options: OverrideCommandOptions = {}) {
44 const path = '/api/v1/videos/categories'
45
46 return this.getRequestBody<{ [id: number]: string }>({
47 ...options,
48 path,
49
50 implicitToken: false,
51 defaultExpectedStatus: HttpStatusCode.OK_200
52 })
53 }
54
55 getLicences (options: OverrideCommandOptions = {}) {
56 const path = '/api/v1/videos/licences'
57
58 return this.getRequestBody<{ [id: number]: string }>({
59 ...options,
60 path,
61
62 implicitToken: false,
63 defaultExpectedStatus: HttpStatusCode.OK_200
64 })
65 }
66
67 getLanguages (options: OverrideCommandOptions = {}) {
68 const path = '/api/v1/videos/languages'
69
70 return this.getRequestBody<{ [id: string]: string }>({
71 ...options,
72 path,
73
74 implicitToken: false,
75 defaultExpectedStatus: HttpStatusCode.OK_200
76 })
77 }
78
79 getPrivacies (options: OverrideCommandOptions = {}) {
80 const path = '/api/v1/videos/privacies'
81
82 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
83 ...options,
84 path,
85
86 implicitToken: false,
87 defaultExpectedStatus: HttpStatusCode.OK_200
88 })
89 }
90
91 // ---------------------------------------------------------------------------
92
93 getDescription (options: OverrideCommandOptions & {
94 descriptionPath: string
95 }) {
96 return this.getRequestBody<{ description: string }>({
97 ...options,
98 path: options.descriptionPath,
99
100 implicitToken: false,
101 defaultExpectedStatus: HttpStatusCode.OK_200
102 })
103 }
104
105 getFileMetadata (options: OverrideCommandOptions & {
106 url: string
107 }) {
108 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
109 ...options,
110
111 url: options.url,
112 implicitToken: false,
113 defaultExpectedStatus: HttpStatusCode.OK_200
114 }))
115 }
116
117 // ---------------------------------------------------------------------------
118
119 view (options: OverrideCommandOptions & {
120 id: number | string
121 xForwardedFor?: string
122 }) {
123 const { id, xForwardedFor } = options
124 const path = '/api/v1/videos/' + id + '/views'
125
126 return this.postBodyRequest({
127 ...options,
128
129 path,
130 xForwardedFor,
131 implicitToken: false,
132 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
133 })
134 }
135
136 rate (options: OverrideCommandOptions & {
137 id: number | string
138 rating: UserVideoRateType
139 }) {
140 const { id, rating } = options
141 const path = '/api/v1/videos/' + id + '/rate'
142
143 return this.putBodyRequest({
144 ...options,
145
146 path,
147 fields: { rating },
148 implicitToken: true,
149 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
150 })
151 }
152
153 // ---------------------------------------------------------------------------
154
155 get (options: OverrideCommandOptions & {
156 id: number | string
157 }) {
158 const path = '/api/v1/videos/' + options.id
159
160 return this.getRequestBody<VideoDetails>({
161 ...options,
162
163 path,
164 implicitToken: false,
165 defaultExpectedStatus: HttpStatusCode.OK_200
166 })
167 }
168
169 getWithToken (options: OverrideCommandOptions & {
170 id: number | string
171 }) {
172 return this.get({
173 ...options,
174
175 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
176 })
177 }
178
179 async getId (options: OverrideCommandOptions & {
180 uuid: number | string
181 }) {
182 const { uuid } = options
183
184 if (validator.isUUID('' + uuid) === false) return uuid as number
185
186 const { id } = await this.get({ ...options, id: uuid })
187
188 return id
189 }
190
0305db28
JB
191 async listFiles (options: OverrideCommandOptions & {
192 id: number | string
193 }) {
194 const video = await this.get(options)
195
196 const files = video.files || []
197 const hlsFiles = video.streamingPlaylists[0]?.files || []
198
199 return files.concat(hlsFiles)
200 }
201
d23dd9fb
C
202 // ---------------------------------------------------------------------------
203
204 listMyVideos (options: OverrideCommandOptions & {
205 start?: number
206 count?: number
207 sort?: string
208 search?: string
209 isLive?: boolean
210 } = {}) {
211 const path = '/api/v1/users/me/videos'
212
213 return this.getRequestBody<ResultList<Video>>({
214 ...options,
215
216 path,
217 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive' ]),
218 implicitToken: true,
219 defaultExpectedStatus: HttpStatusCode.OK_200
220 })
221 }
222
223 // ---------------------------------------------------------------------------
224
225 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
226 const path = '/api/v1/videos'
227
228 const query = this.buildListQuery(options)
229
230 return this.getRequestBody<ResultList<Video>>({
231 ...options,
232
233 path,
234 query: { sort: 'name', ...query },
235 implicitToken: false,
236 defaultExpectedStatus: HttpStatusCode.OK_200
237 })
238 }
239
240 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
241 return this.list({
242 ...options,
243
244 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
245 })
246 }
247
248 listByAccount (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
c0e8b12e 249 handle: string
d23dd9fb 250 }) {
c0e8b12e
C
251 const { handle, search } = options
252 const path = '/api/v1/accounts/' + handle + '/videos'
d23dd9fb
C
253
254 return this.getRequestBody<ResultList<Video>>({
255 ...options,
256
257 path,
258 query: { search, ...this.buildListQuery(options) },
259 implicitToken: true,
260 defaultExpectedStatus: HttpStatusCode.OK_200
261 })
262 }
263
264 listByChannel (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
c0e8b12e 265 handle: string
d23dd9fb 266 }) {
c0e8b12e
C
267 const { handle } = options
268 const path = '/api/v1/video-channels/' + handle + '/videos'
d23dd9fb
C
269
270 return this.getRequestBody<ResultList<Video>>({
271 ...options,
272
273 path,
274 query: this.buildListQuery(options),
275 implicitToken: true,
276 defaultExpectedStatus: HttpStatusCode.OK_200
277 })
278 }
279
280 // ---------------------------------------------------------------------------
281
4d029ef8
C
282 async find (options: OverrideCommandOptions & {
283 name: string
284 }) {
285 const { data } = await this.list(options)
286
287 return data.find(v => v.name === options.name)
288 }
289
290 // ---------------------------------------------------------------------------
291
d23dd9fb
C
292 update (options: OverrideCommandOptions & {
293 id: number | string
294 attributes?: VideoEdit
295 }) {
296 const { id, attributes = {} } = options
297 const path = '/api/v1/videos/' + id
298
299 // Upload request
300 if (attributes.thumbnailfile || attributes.previewfile) {
301 const attaches: any = {}
302 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
303 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
304
305 return this.putUploadRequest({
306 ...options,
307
308 path,
309 fields: options.attributes,
310 attaches: {
311 thumbnailfile: attributes.thumbnailfile,
312 previewfile: attributes.previewfile
313 },
314 implicitToken: true,
315 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
316 })
317 }
318
319 return this.putBodyRequest({
320 ...options,
321
322 path,
323 fields: options.attributes,
324 implicitToken: true,
325 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
326 })
327 }
328
329 remove (options: OverrideCommandOptions & {
330 id: number | string
331 }) {
332 const path = '/api/v1/videos/' + options.id
333
c0e8b12e 334 return unwrapBody(this.deleteRequest({
d23dd9fb
C
335 ...options,
336
337 path,
338 implicitToken: true,
339 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
c0e8b12e 340 }))
d23dd9fb
C
341 }
342
343 async removeAll () {
344 const { data } = await this.list()
345
346 for (const v of data) {
347 await this.remove({ id: v.id })
348 }
349 }
350
351 // ---------------------------------------------------------------------------
352
353 async upload (options: OverrideCommandOptions & {
354 attributes?: VideoEdit
355 mode?: 'legacy' | 'resumable' // default legacy
356 } = {}) {
4c7e60bc 357 const { mode = 'legacy' } = options
d23dd9fb
C
358 let defaultChannelId = 1
359
360 try {
89d241a7 361 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
d23dd9fb
C
362 defaultChannelId = videoChannels[0].id
363 } catch (e) { /* empty */ }
364
365 // Override default attributes
366 const attributes = {
367 name: 'my super video',
368 category: 5,
369 licence: 4,
370 language: 'zh',
371 channelId: defaultChannelId,
372 nsfw: true,
373 waitTranscoding: false,
374 description: 'my super description',
375 support: 'my super support text',
376 tags: [ 'tag' ],
377 privacy: VideoPrivacy.PUBLIC,
378 commentsEnabled: true,
379 downloadEnabled: true,
380 fixture: 'video_short.webm',
381
382 ...options.attributes
383 }
384
4c7e60bc 385 const created = mode === 'legacy'
d23dd9fb
C
386 ? await this.buildLegacyUpload({ ...options, attributes })
387 : await this.buildResumeUpload({ ...options, attributes })
388
389 // Wait torrent generation
4c7e60bc 390 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
d23dd9fb
C
391 if (expectedStatus === HttpStatusCode.OK_200) {
392 let video: VideoDetails
393
394 do {
4c7e60bc 395 video = await this.getWithToken({ ...options, id: created.uuid })
d23dd9fb
C
396
397 await wait(50)
398 } while (!video.files[0].torrentUrl)
399 }
400
4c7e60bc 401 return created
d23dd9fb
C
402 }
403
404 async buildLegacyUpload (options: OverrideCommandOptions & {
405 attributes: VideoEdit
406 }): Promise<VideoCreateResult> {
407 const path = '/api/v1/videos/upload'
408
409 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
410 ...options,
411
412 path,
413 fields: this.buildUploadFields(options.attributes),
414 attaches: this.buildUploadAttaches(options.attributes),
415 implicitToken: true,
416 defaultExpectedStatus: HttpStatusCode.OK_200
417 })).then(body => body.video || body as any)
418 }
419
420 async buildResumeUpload (options: OverrideCommandOptions & {
421 attributes: VideoEdit
c0e8b12e 422 }): Promise<VideoCreateResult> {
d23dd9fb
C
423 const { attributes, expectedStatus } = options
424
425 let size = 0
426 let videoFilePath: string
427 let mimetype = 'video/mp4'
428
429 if (attributes.fixture) {
430 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
431 size = (await stat(videoFilePath)).size
432
433 if (videoFilePath.endsWith('.mkv')) {
434 mimetype = 'video/x-matroska'
435 } else if (videoFilePath.endsWith('.webm')) {
436 mimetype = 'video/webm'
437 }
438 }
439
c0e8b12e
C
440 // Do not check status automatically, we'll check it manually
441 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
d23dd9fb
C
442 const initStatus = initializeSessionRes.status
443
444 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
445 const locationHeader = initializeSessionRes.header['location']
446 expect(locationHeader).to.not.be.undefined
447
448 const pathUploadId = locationHeader.split('?')[1]
449
450 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
451
790c2837
C
452 await this.endResumableUpload({ ...options, pathUploadId })
453
c0e8b12e 454 return result.body?.video || result.body as any
d23dd9fb
C
455 }
456
457 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
458 ? HttpStatusCode.CREATED_201
459 : expectedStatus
460
461 expect(initStatus).to.equal(expectedInitStatus)
462
c0e8b12e 463 return initializeSessionRes.body.video || initializeSessionRes.body
d23dd9fb
C
464 }
465
466 async prepareResumableUpload (options: OverrideCommandOptions & {
467 attributes: VideoEdit
468 size: number
469 mimetype: string
470 }) {
471 const { attributes, size, mimetype } = options
472
473 const path = '/api/v1/videos/upload-resumable'
474
475 return this.postUploadRequest({
476 ...options,
477
478 path,
479 headers: {
480 'X-Upload-Content-Type': mimetype,
481 'X-Upload-Content-Length': size.toString()
482 },
483 fields: { filename: attributes.fixture, ...this.buildUploadFields(options.attributes) },
c0e8b12e
C
484 // Fixture will be sent later
485 attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
d23dd9fb 486 implicitToken: true,
c0e8b12e 487
d23dd9fb
C
488 defaultExpectedStatus: null
489 })
490 }
491
492 sendResumableChunks (options: OverrideCommandOptions & {
493 pathUploadId: string
494 videoFilePath: string
495 size: number
496 contentLength?: number
497 contentRangeBuilder?: (start: number, chunk: any) => string
498 }) {
499 const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options
500
501 const path = '/api/v1/videos/upload-resumable'
502 let start = 0
503
504 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
505 const url = this.server.url
506
507 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
508 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
509 readable.on('data', async function onData (chunk) {
510 readable.pause()
511
512 const headers = {
513 'Authorization': 'Bearer ' + token,
514 'Content-Type': 'application/octet-stream',
515 'Content-Range': contentRangeBuilder
516 ? contentRangeBuilder(start, chunk)
517 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
518 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
519 }
520
521 const res = await got<{ video: VideoCreateResult }>({
522 url,
523 method: 'put',
524 headers,
525 path: path + '?' + pathUploadId,
526 body: chunk,
527 responseType: 'json',
528 throwHttpErrors: false
529 })
530
531 start += chunk.length
532
533 if (res.statusCode === expectedStatus) {
534 return resolve(res)
535 }
536
537 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
538 readable.off('data', onData)
539 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
540 }
541
542 readable.resume()
543 })
544 })
545 }
546
790c2837
C
547 endResumableUpload (options: OverrideCommandOptions & {
548 pathUploadId: string
549 }) {
550 return this.deleteRequest({
551 ...options,
552
553 path: '/api/v1/videos/upload-resumable',
554 rawQuery: options.pathUploadId,
555 implicitToken: true,
556 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
557 })
558 }
559
d23dd9fb
C
560 quickUpload (options: OverrideCommandOptions & {
561 name: string
562 nsfw?: boolean
563 privacy?: VideoPrivacy
564 fixture?: string
565 }) {
566 const attributes: VideoEdit = { name: options.name }
567 if (options.nsfw) attributes.nsfw = options.nsfw
568 if (options.privacy) attributes.privacy = options.privacy
569 if (options.fixture) attributes.fixture = options.fixture
570
571 return this.upload({ ...options, attributes })
572 }
573
574 async randomUpload (options: OverrideCommandOptions & {
575 wait?: boolean // default true
4c7e60bc 576 additionalParams?: VideoEdit & { prefixName?: string }
d23dd9fb
C
577 } = {}) {
578 const { wait = true, additionalParams } = options
579 const prefixName = additionalParams?.prefixName || ''
580 const name = prefixName + buildUUID()
581
4c7e60bc 582 const attributes = { name, ...additionalParams }
d23dd9fb 583
d23dd9fb
C
584 const result = await this.upload({ ...options, attributes })
585
c0e8b12e
C
586 if (wait) await waitJobs([ this.server ])
587
d23dd9fb
C
588 return { ...result, name }
589 }
590
591 // ---------------------------------------------------------------------------
592
593 private buildListQuery (options: VideosCommonQuery) {
594 return pick(options, [
595 'start',
596 'count',
597 'sort',
598 'nsfw',
599 'isLive',
600 'categoryOneOf',
601 'licenceOneOf',
602 'languageOneOf',
603 'tagsOneOf',
604 'tagsAllOf',
605 'filter',
606 'skipCount'
607 ])
608 }
609
610 private buildUploadFields (attributes: VideoEdit) {
c0e8b12e 611 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
d23dd9fb
C
612 }
613
614 private buildUploadAttaches (attributes: VideoEdit) {
615 const attaches: { [ name: string ]: string } = {}
616
617 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
618 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
619 }
620
621 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)
622
623 return attaches
624 }
625}