]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - shared/server-commands/videos/videos-command.ts
feat: show contained playlists under My videos (#5125)
[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'
bbd5aa7e 7import { buildAbsoluteFixturePath, 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,
18 VideoPrivacy,
ad5db104
C
19 VideosCommonQuery,
20 VideoTranscodingCreate
d23dd9fb 21} from '@shared/models'
1bb4c9ab 22import { VideoSource } from '@shared/models/videos/video-source'
d23dd9fb 23import { unwrapBody } from '../requests'
06aad801 24import { waitJobs } from '../server'
d23dd9fb
C
25import { AbstractCommand, OverrideCommandOptions } from '../shared'
26
27export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
28 fixture?: string
29 thumbnailfile?: string
30 previewfile?: string
31}
32
33export class VideosCommand extends AbstractCommand {
d23dd9fb
C
34 getCategories (options: OverrideCommandOptions = {}) {
35 const path = '/api/v1/videos/categories'
36
37 return this.getRequestBody<{ [id: number]: string }>({
38 ...options,
39 path,
40
41 implicitToken: false,
42 defaultExpectedStatus: HttpStatusCode.OK_200
43 })
44 }
45
46 getLicences (options: OverrideCommandOptions = {}) {
47 const path = '/api/v1/videos/licences'
48
49 return this.getRequestBody<{ [id: number]: string }>({
50 ...options,
51 path,
52
53 implicitToken: false,
54 defaultExpectedStatus: HttpStatusCode.OK_200
55 })
56 }
57
58 getLanguages (options: OverrideCommandOptions = {}) {
59 const path = '/api/v1/videos/languages'
60
61 return this.getRequestBody<{ [id: string]: string }>({
62 ...options,
63 path,
64
65 implicitToken: false,
66 defaultExpectedStatus: HttpStatusCode.OK_200
67 })
68 }
69
70 getPrivacies (options: OverrideCommandOptions = {}) {
71 const path = '/api/v1/videos/privacies'
72
73 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
74 ...options,
75 path,
76
77 implicitToken: false,
78 defaultExpectedStatus: HttpStatusCode.OK_200
79 })
80 }
81
82 // ---------------------------------------------------------------------------
83
84 getDescription (options: OverrideCommandOptions & {
85 descriptionPath: string
86 }) {
87 return this.getRequestBody<{ description: string }>({
88 ...options,
89 path: options.descriptionPath,
90
91 implicitToken: false,
92 defaultExpectedStatus: HttpStatusCode.OK_200
93 })
94 }
95
96 getFileMetadata (options: OverrideCommandOptions & {
97 url: string
98 }) {
99 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
100 ...options,
101
102 url: options.url,
103 implicitToken: false,
104 defaultExpectedStatus: HttpStatusCode.OK_200
105 }))
106 }
107
108 // ---------------------------------------------------------------------------
109
d23dd9fb
C
110 rate (options: OverrideCommandOptions & {
111 id: number | string
112 rating: UserVideoRateType
113 }) {
114 const { id, rating } = options
115 const path = '/api/v1/videos/' + id + '/rate'
116
117 return this.putBodyRequest({
118 ...options,
119
120 path,
121 fields: { rating },
122 implicitToken: true,
123 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
124 })
125 }
126
127 // ---------------------------------------------------------------------------
128
129 get (options: OverrideCommandOptions & {
130 id: number | string
131 }) {
132 const path = '/api/v1/videos/' + options.id
133
134 return this.getRequestBody<VideoDetails>({
135 ...options,
136
137 path,
138 implicitToken: false,
139 defaultExpectedStatus: HttpStatusCode.OK_200
140 })
141 }
142
143 getWithToken (options: OverrideCommandOptions & {
144 id: number | string
145 }) {
146 return this.get({
147 ...options,
148
149 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
150 })
151 }
152
2e401e85 153 getSource (options: OverrideCommandOptions & {
154 id: number | string
155 }) {
156 const path = '/api/v1/videos/' + options.id + '/source'
157
158 return this.getRequestBody<VideoSource>({
159 ...options,
160
161 path,
162 implicitToken: true,
163 defaultExpectedStatus: HttpStatusCode.OK_200
164 })
165 }
166
d23dd9fb
C
167 async getId (options: OverrideCommandOptions & {
168 uuid: number | string
169 }) {
170 const { uuid } = options
171
172 if (validator.isUUID('' + uuid) === false) return uuid as number
173
174 const { id } = await this.get({ ...options, id: uuid })
175
176 return id
177 }
178
0305db28
JB
179 async listFiles (options: OverrideCommandOptions & {
180 id: number | string
181 }) {
182 const video = await this.get(options)
183
184 const files = video.files || []
185 const hlsFiles = video.streamingPlaylists[0]?.files || []
186
187 return files.concat(hlsFiles)
188 }
189
d23dd9fb
C
190 // ---------------------------------------------------------------------------
191
192 listMyVideos (options: OverrideCommandOptions & {
193 start?: number
194 count?: number
195 sort?: string
196 search?: string
197 isLive?: boolean
978c87e7 198 channelId?: number
d23dd9fb
C
199 } = {}) {
200 const path = '/api/v1/users/me/videos'
201
202 return this.getRequestBody<ResultList<Video>>({
203 ...options,
204
205 path,
978c87e7 206 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
d23dd9fb
C
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
2760b454 237 listByAccount (options: OverrideCommandOptions & VideosCommonQuery & {
c0e8b12e 238 handle: string
d23dd9fb 239 }) {
c0e8b12e
C
240 const { handle, search } = options
241 const path = '/api/v1/accounts/' + handle + '/videos'
d23dd9fb
C
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
2760b454 253 listByChannel (options: OverrideCommandOptions & VideosCommonQuery & {
c0e8b12e 254 handle: string
d23dd9fb 255 }) {
c0e8b12e
C
256 const { handle } = options
257 const path = '/api/v1/video-channels/' + handle + '/videos'
d23dd9fb
C
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
4d029ef8
C
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
d23dd9fb
C
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
c0e8b12e 323 return unwrapBody(this.deleteRequest({
d23dd9fb
C
324 ...options,
325
326 path,
327 implicitToken: true,
328 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
c0e8b12e 329 }))
d23dd9fb
C
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 } = {}) {
4c7e60bc 346 const { mode = 'legacy' } = options
d23dd9fb
C
347 let defaultChannelId = 1
348
349 try {
89d241a7 350 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
d23dd9fb
C
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
4c7e60bc 374 const created = mode === 'legacy'
d23dd9fb
C
375 ? await this.buildLegacyUpload({ ...options, attributes })
376 : await this.buildResumeUpload({ ...options, attributes })
377
378 // Wait torrent generation
4c7e60bc 379 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
d23dd9fb
C
380 if (expectedStatus === HttpStatusCode.OK_200) {
381 let video: VideoDetails
382
383 do {
4c7e60bc 384 video = await this.getWithToken({ ...options, id: created.uuid })
d23dd9fb
C
385
386 await wait(50)
387 } while (!video.files[0].torrentUrl)
388 }
389
4c7e60bc 390 return created
d23dd9fb
C
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
c0e8b12e 411 }): Promise<VideoCreateResult> {
d23dd9fb
C
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
c0e8b12e
C
429 // Do not check status automatically, we'll check it manually
430 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
d23dd9fb
C
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
8de589b9
C
441 if (result.statusCode === HttpStatusCode.OK_200) {
442 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
443 }
790c2837 444
c0e8b12e 445 return result.body?.video || result.body as any
d23dd9fb
C
446 }
447
448 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
449 ? HttpStatusCode.CREATED_201
450 : expectedStatus
451
452 expect(initStatus).to.equal(expectedInitStatus)
453
c0e8b12e 454 return initializeSessionRes.body.video || initializeSessionRes.body
d23dd9fb
C
455 }
456
457 async prepareResumableUpload (options: OverrideCommandOptions & {
458 attributes: VideoEdit
459 size: number
460 mimetype: string
020d3d3d
C
461
462 originalName?: string
463 lastModified?: number
d23dd9fb 464 }) {
020d3d3d 465 const { attributes, originalName, lastModified, size, mimetype } = options
d23dd9fb
C
466
467 const path = '/api/v1/videos/upload-resumable'
468
469 return this.postUploadRequest({
470 ...options,
471
472 path,
473 headers: {
474 'X-Upload-Content-Type': mimetype,
475 'X-Upload-Content-Length': size.toString()
476 },
020d3d3d
C
477 fields: {
478 filename: attributes.fixture,
479 originalName,
480 lastModified,
481
482 ...this.buildUploadFields(options.attributes)
483 },
484
c0e8b12e 485 // Fixture will be sent later
bbd5aa7e 486 attaches: this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ])),
d23dd9fb 487 implicitToken: true,
c0e8b12e 488
d23dd9fb
C
489 defaultExpectedStatus: null
490 })
491 }
492
493 sendResumableChunks (options: OverrideCommandOptions & {
494 pathUploadId: string
495 videoFilePath: string
496 size: number
497 contentLength?: number
498 contentRangeBuilder?: (start: number, chunk: any) => string
33ac85bf 499 digestBuilder?: (chunk: any) => string
d23dd9fb 500 }) {
33ac85bf
C
501 const {
502 pathUploadId,
503 videoFilePath,
504 size,
505 contentLength,
506 contentRangeBuilder,
507 digestBuilder,
508 expectedStatus = HttpStatusCode.OK_200
509 } = options
d23dd9fb
C
510
511 const path = '/api/v1/videos/upload-resumable'
512 let start = 0
513
514 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
515 const url = this.server.url
516
517 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
518 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
519 readable.on('data', async function onData (chunk) {
520 readable.pause()
521
522 const headers = {
523 'Authorization': 'Bearer ' + token,
524 'Content-Type': 'application/octet-stream',
525 'Content-Range': contentRangeBuilder
526 ? contentRangeBuilder(start, chunk)
527 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
528 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
529 }
530
33ac85bf
C
531 if (digestBuilder) {
532 Object.assign(headers, { digest: digestBuilder(chunk) })
533 }
534
d23dd9fb
C
535 const res = await got<{ video: VideoCreateResult }>({
536 url,
537 method: 'put',
538 headers,
539 path: path + '?' + pathUploadId,
540 body: chunk,
541 responseType: 'json',
542 throwHttpErrors: false
543 })
544
545 start += chunk.length
546
547 if (res.statusCode === expectedStatus) {
548 return resolve(res)
549 }
550
551 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
552 readable.off('data', onData)
553 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
554 }
555
556 readable.resume()
557 })
558 })
559 }
560
790c2837
C
561 endResumableUpload (options: OverrideCommandOptions & {
562 pathUploadId: string
563 }) {
564 return this.deleteRequest({
565 ...options,
566
567 path: '/api/v1/videos/upload-resumable',
568 rawQuery: options.pathUploadId,
569 implicitToken: true,
570 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
571 })
572 }
573
d23dd9fb
C
574 quickUpload (options: OverrideCommandOptions & {
575 name: string
576 nsfw?: boolean
577 privacy?: VideoPrivacy
578 fixture?: string
579 }) {
580 const attributes: VideoEdit = { name: options.name }
581 if (options.nsfw) attributes.nsfw = options.nsfw
582 if (options.privacy) attributes.privacy = options.privacy
583 if (options.fixture) attributes.fixture = options.fixture
584
585 return this.upload({ ...options, attributes })
586 }
587
588 async randomUpload (options: OverrideCommandOptions & {
589 wait?: boolean // default true
4c7e60bc 590 additionalParams?: VideoEdit & { prefixName?: string }
d23dd9fb
C
591 } = {}) {
592 const { wait = true, additionalParams } = options
593 const prefixName = additionalParams?.prefixName || ''
594 const name = prefixName + buildUUID()
595
4c7e60bc 596 const attributes = { name, ...additionalParams }
d23dd9fb 597
d23dd9fb
C
598 const result = await this.upload({ ...options, attributes })
599
c0e8b12e
C
600 if (wait) await waitJobs([ this.server ])
601
d23dd9fb
C
602 return { ...result, name }
603 }
604
605 // ---------------------------------------------------------------------------
606
1bb4c9ab 607 removeHLSPlaylist (options: OverrideCommandOptions & {
b46cf4b9
C
608 videoId: number | string
609 }) {
610 const path = '/api/v1/videos/' + options.videoId + '/hls'
611
612 return this.deleteRequest({
613 ...options,
614
615 path,
616 implicitToken: true,
617 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
618 })
619 }
620
1bb4c9ab
C
621 removeHLSFile (options: OverrideCommandOptions & {
622 videoId: number | string
623 fileId: number
624 }) {
625 const path = '/api/v1/videos/' + options.videoId + '/hls/' + options.fileId
626
627 return this.deleteRequest({
628 ...options,
629
630 path,
631 implicitToken: true,
632 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
633 })
634 }
635
636 removeAllWebTorrentFiles (options: OverrideCommandOptions & {
b46cf4b9
C
637 videoId: number | string
638 }) {
639 const path = '/api/v1/videos/' + options.videoId + '/webtorrent'
640
641 return this.deleteRequest({
642 ...options,
643
644 path,
645 implicitToken: true,
ad5db104
C
646 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
647 })
648 }
649
1bb4c9ab
C
650 removeWebTorrentFile (options: OverrideCommandOptions & {
651 videoId: number | string
652 fileId: number
653 }) {
654 const path = '/api/v1/videos/' + options.videoId + '/webtorrent/' + options.fileId
655
656 return this.deleteRequest({
657 ...options,
658
659 path,
660 implicitToken: true,
661 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
662 })
663 }
664
ad5db104
C
665 runTranscoding (options: OverrideCommandOptions & {
666 videoId: number | string
667 transcodingType: 'hls' | 'webtorrent'
668 }) {
669 const path = '/api/v1/videos/' + options.videoId + '/transcoding'
670
671 const fields: VideoTranscodingCreate = pick(options, [ 'transcodingType' ])
672
673 return this.postBodyRequest({
674 ...options,
675
676 path,
677 fields,
678 implicitToken: true,
b46cf4b9
C
679 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
680 })
681 }
682
683 // ---------------------------------------------------------------------------
684
d23dd9fb
C
685 private buildListQuery (options: VideosCommonQuery) {
686 return pick(options, [
687 'start',
688 'count',
689 'sort',
690 'nsfw',
691 'isLive',
692 'categoryOneOf',
693 'licenceOneOf',
694 'languageOneOf',
695 'tagsOneOf',
696 'tagsAllOf',
2760b454
C
697 'isLocal',
698 'include',
d23dd9fb
C
699 'skipCount'
700 ])
701 }
702
703 private buildUploadFields (attributes: VideoEdit) {
c0e8b12e 704 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
d23dd9fb
C
705 }
706
707 private buildUploadAttaches (attributes: VideoEdit) {
708 const attaches: { [ name: string ]: string } = {}
709
710 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
711 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
712 }
713
714 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)
715
716 return attaches
717 }
718}