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