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