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