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