]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - shared/extra-utils/videos/videos-command.ts
Merge branch 'release/3.3.0' into develop
[github/Chocobozzz/PeerTube.git] / shared / extra-utils / 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 { buildUUID } from '@server/helpers/uuid'
9 import { loadLanguages } from '@server/initializers/constants'
10 import { pick } from '@shared/core-utils'
11 import {
12 HttpStatusCode,
13 ResultList,
14 UserVideoRateType,
15 Video,
16 VideoCreate,
17 VideoCreateResult,
18 VideoDetails,
19 VideoFileMetadata,
20 VideoPrivacy,
21 VideosCommonQuery,
22 VideosWithSearchCommonQuery
23 } from '@shared/models'
24 import { buildAbsoluteFixturePath, wait } from '../miscs'
25 import { unwrapBody } from '../requests'
26 import { PeerTubeServer, waitJobs } from '../server'
27 import { AbstractCommand, OverrideCommandOptions } from '../shared'
28
29 export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
30 fixture?: string
31 thumbnailfile?: string
32 previewfile?: string
33 }
34
35 export class VideosCommand extends AbstractCommand {
36
37 constructor (server: PeerTubeServer) {
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
191 // ---------------------------------------------------------------------------
192
193 listMyVideos (options: OverrideCommandOptions & {
194 start?: number
195 count?: number
196 sort?: string
197 search?: string
198 isLive?: boolean
199 } = {}) {
200 const path = '/api/v1/users/me/videos'
201
202 return this.getRequestBody<ResultList<Video>>({
203 ...options,
204
205 path,
206 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive' ]),
207 implicitToken: true,
208 defaultExpectedStatus: HttpStatusCode.OK_200
209 })
210 }
211
212 // ---------------------------------------------------------------------------
213
214 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
215 const path = '/api/v1/videos'
216
217 const query = this.buildListQuery(options)
218
219 return this.getRequestBody<ResultList<Video>>({
220 ...options,
221
222 path,
223 query: { sort: 'name', ...query },
224 implicitToken: false,
225 defaultExpectedStatus: HttpStatusCode.OK_200
226 })
227 }
228
229 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
230 return this.list({
231 ...options,
232
233 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
234 })
235 }
236
237 listByAccount (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
238 handle: string
239 }) {
240 const { handle, search } = options
241 const path = '/api/v1/accounts/' + handle + '/videos'
242
243 return this.getRequestBody<ResultList<Video>>({
244 ...options,
245
246 path,
247 query: { search, ...this.buildListQuery(options) },
248 implicitToken: true,
249 defaultExpectedStatus: HttpStatusCode.OK_200
250 })
251 }
252
253 listByChannel (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
254 handle: string
255 }) {
256 const { handle } = options
257 const path = '/api/v1/video-channels/' + handle + '/videos'
258
259 return this.getRequestBody<ResultList<Video>>({
260 ...options,
261
262 path,
263 query: this.buildListQuery(options),
264 implicitToken: true,
265 defaultExpectedStatus: HttpStatusCode.OK_200
266 })
267 }
268
269 // ---------------------------------------------------------------------------
270
271 async find (options: OverrideCommandOptions & {
272 name: string
273 }) {
274 const { data } = await this.list(options)
275
276 return data.find(v => v.name === options.name)
277 }
278
279 // ---------------------------------------------------------------------------
280
281 update (options: OverrideCommandOptions & {
282 id: number | string
283 attributes?: VideoEdit
284 }) {
285 const { id, attributes = {} } = options
286 const path = '/api/v1/videos/' + id
287
288 // Upload request
289 if (attributes.thumbnailfile || attributes.previewfile) {
290 const attaches: any = {}
291 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
292 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
293
294 return this.putUploadRequest({
295 ...options,
296
297 path,
298 fields: options.attributes,
299 attaches: {
300 thumbnailfile: attributes.thumbnailfile,
301 previewfile: attributes.previewfile
302 },
303 implicitToken: true,
304 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
305 })
306 }
307
308 return this.putBodyRequest({
309 ...options,
310
311 path,
312 fields: options.attributes,
313 implicitToken: true,
314 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
315 })
316 }
317
318 remove (options: OverrideCommandOptions & {
319 id: number | string
320 }) {
321 const path = '/api/v1/videos/' + options.id
322
323 return unwrapBody(this.deleteRequest({
324 ...options,
325
326 path,
327 implicitToken: true,
328 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
329 }))
330 }
331
332 async removeAll () {
333 const { data } = await this.list()
334
335 for (const v of data) {
336 await this.remove({ id: v.id })
337 }
338 }
339
340 // ---------------------------------------------------------------------------
341
342 async upload (options: OverrideCommandOptions & {
343 attributes?: VideoEdit
344 mode?: 'legacy' | 'resumable' // default legacy
345 } = {}) {
346 const { mode = 'legacy' } = options
347 let defaultChannelId = 1
348
349 try {
350 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
351 defaultChannelId = videoChannels[0].id
352 } catch (e) { /* empty */ }
353
354 // Override default attributes
355 const attributes = {
356 name: 'my super video',
357 category: 5,
358 licence: 4,
359 language: 'zh',
360 channelId: defaultChannelId,
361 nsfw: true,
362 waitTranscoding: false,
363 description: 'my super description',
364 support: 'my super support text',
365 tags: [ 'tag' ],
366 privacy: VideoPrivacy.PUBLIC,
367 commentsEnabled: true,
368 downloadEnabled: true,
369 fixture: 'video_short.webm',
370
371 ...options.attributes
372 }
373
374 const created = mode === 'legacy'
375 ? await this.buildLegacyUpload({ ...options, attributes })
376 : await this.buildResumeUpload({ ...options, attributes })
377
378 // Wait torrent generation
379 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
380 if (expectedStatus === HttpStatusCode.OK_200) {
381 let video: VideoDetails
382
383 do {
384 video = await this.getWithToken({ ...options, id: created.uuid })
385
386 await wait(50)
387 } while (!video.files[0].torrentUrl)
388 }
389
390 return created
391 }
392
393 async buildLegacyUpload (options: OverrideCommandOptions & {
394 attributes: VideoEdit
395 }): Promise<VideoCreateResult> {
396 const path = '/api/v1/videos/upload'
397
398 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
399 ...options,
400
401 path,
402 fields: this.buildUploadFields(options.attributes),
403 attaches: this.buildUploadAttaches(options.attributes),
404 implicitToken: true,
405 defaultExpectedStatus: HttpStatusCode.OK_200
406 })).then(body => body.video || body as any)
407 }
408
409 async buildResumeUpload (options: OverrideCommandOptions & {
410 attributes: VideoEdit
411 }): Promise<VideoCreateResult> {
412 const { attributes, expectedStatus } = options
413
414 let size = 0
415 let videoFilePath: string
416 let mimetype = 'video/mp4'
417
418 if (attributes.fixture) {
419 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
420 size = (await stat(videoFilePath)).size
421
422 if (videoFilePath.endsWith('.mkv')) {
423 mimetype = 'video/x-matroska'
424 } else if (videoFilePath.endsWith('.webm')) {
425 mimetype = 'video/webm'
426 }
427 }
428
429 // Do not check status automatically, we'll check it manually
430 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
431 const initStatus = initializeSessionRes.status
432
433 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
434 const locationHeader = initializeSessionRes.header['location']
435 expect(locationHeader).to.not.be.undefined
436
437 const pathUploadId = locationHeader.split('?')[1]
438
439 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
440
441 return result.body?.video || result.body as any
442 }
443
444 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
445 ? HttpStatusCode.CREATED_201
446 : expectedStatus
447
448 expect(initStatus).to.equal(expectedInitStatus)
449
450 return initializeSessionRes.body.video || initializeSessionRes.body
451 }
452
453 async prepareResumableUpload (options: OverrideCommandOptions & {
454 attributes: VideoEdit
455 size: number
456 mimetype: string
457 }) {
458 const { attributes, size, mimetype } = options
459
460 const path = '/api/v1/videos/upload-resumable'
461
462 return this.postUploadRequest({
463 ...options,
464
465 path,
466 headers: {
467 'X-Upload-Content-Type': mimetype,
468 'X-Upload-Content-Length': size.toString()
469 },
470 fields: { filename: attributes.fixture, ...this.buildUploadFields(options.attributes) },
471 // Fixture will be sent later
472 attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
473 implicitToken: true,
474
475 defaultExpectedStatus: null
476 })
477 }
478
479 sendResumableChunks (options: OverrideCommandOptions & {
480 pathUploadId: string
481 videoFilePath: string
482 size: number
483 contentLength?: number
484 contentRangeBuilder?: (start: number, chunk: any) => string
485 }) {
486 const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options
487
488 const path = '/api/v1/videos/upload-resumable'
489 let start = 0
490
491 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
492 const url = this.server.url
493
494 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
495 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
496 readable.on('data', async function onData (chunk) {
497 readable.pause()
498
499 const headers = {
500 'Authorization': 'Bearer ' + token,
501 'Content-Type': 'application/octet-stream',
502 'Content-Range': contentRangeBuilder
503 ? contentRangeBuilder(start, chunk)
504 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
505 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
506 }
507
508 const res = await got<{ video: VideoCreateResult }>({
509 url,
510 method: 'put',
511 headers,
512 path: path + '?' + pathUploadId,
513 body: chunk,
514 responseType: 'json',
515 throwHttpErrors: false
516 })
517
518 start += chunk.length
519
520 if (res.statusCode === expectedStatus) {
521 return resolve(res)
522 }
523
524 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
525 readable.off('data', onData)
526 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
527 }
528
529 readable.resume()
530 })
531 })
532 }
533
534 quickUpload (options: OverrideCommandOptions & {
535 name: string
536 nsfw?: boolean
537 privacy?: VideoPrivacy
538 fixture?: string
539 }) {
540 const attributes: VideoEdit = { name: options.name }
541 if (options.nsfw) attributes.nsfw = options.nsfw
542 if (options.privacy) attributes.privacy = options.privacy
543 if (options.fixture) attributes.fixture = options.fixture
544
545 return this.upload({ ...options, attributes })
546 }
547
548 async randomUpload (options: OverrideCommandOptions & {
549 wait?: boolean // default true
550 additionalParams?: VideoEdit & { prefixName?: string }
551 } = {}) {
552 const { wait = true, additionalParams } = options
553 const prefixName = additionalParams?.prefixName || ''
554 const name = prefixName + buildUUID()
555
556 const attributes = { name, ...additionalParams }
557
558 const result = await this.upload({ ...options, attributes })
559
560 if (wait) await waitJobs([ this.server ])
561
562 return { ...result, name }
563 }
564
565 // ---------------------------------------------------------------------------
566
567 private buildListQuery (options: VideosCommonQuery) {
568 return pick(options, [
569 'start',
570 'count',
571 'sort',
572 'nsfw',
573 'isLive',
574 'categoryOneOf',
575 'licenceOneOf',
576 'languageOneOf',
577 'tagsOneOf',
578 'tagsAllOf',
579 'filter',
580 'skipCount'
581 ])
582 }
583
584 private buildUploadFields (attributes: VideoEdit) {
585 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
586 }
587
588 private buildUploadAttaches (attributes: VideoEdit) {
589 const attaches: { [ name: string ]: string } = {}
590
591 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
592 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
593 }
594
595 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)
596
597 return attaches
598 }
599 }