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