1 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
3 import { expect } from 'chai'
4 import { createReadStream, stat } from 'fs-extra'
5 import got, { Response as GotResponse } from 'got'
6 import { omit } from 'lodash'
7 import validator from 'validator'
8 import { buildUUID } from '@server/helpers/uuid'
9 import { loadLanguages } from '@server/initializers/constants'
10 import { pick } from '@shared/core-utils'
22 VideosWithSearchCommonQuery
23 } from '@shared/models'
24 import { buildAbsoluteFixturePath, wait } from '../miscs'
25 import { unwrapBody } from '../requests'
26 import { PeerTubeServer, waitJobs } from '../server'
27 import { AbstractCommand, OverrideCommandOptions } from '../shared'
29 export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
31 thumbnailfile?: string
35 export class VideosCommand extends AbstractCommand {
37 constructor (server: PeerTubeServer) {
43 getCategories (options: OverrideCommandOptions = {}) {
44 const path = '/api/v1/videos/categories'
46 return this.getRequestBody<{ [id: number]: string }>({
51 defaultExpectedStatus: HttpStatusCode.OK_200
55 getLicences (options: OverrideCommandOptions = {}) {
56 const path = '/api/v1/videos/licences'
58 return this.getRequestBody<{ [id: number]: string }>({
63 defaultExpectedStatus: HttpStatusCode.OK_200
67 getLanguages (options: OverrideCommandOptions = {}) {
68 const path = '/api/v1/videos/languages'
70 return this.getRequestBody<{ [id: string]: string }>({
75 defaultExpectedStatus: HttpStatusCode.OK_200
79 getPrivacies (options: OverrideCommandOptions = {}) {
80 const path = '/api/v1/videos/privacies'
82 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
87 defaultExpectedStatus: HttpStatusCode.OK_200
91 // ---------------------------------------------------------------------------
93 getDescription (options: OverrideCommandOptions & {
94 descriptionPath: string
96 return this.getRequestBody<{ description: string }>({
98 path: options.descriptionPath,
100 implicitToken: false,
101 defaultExpectedStatus: HttpStatusCode.OK_200
105 getFileMetadata (options: OverrideCommandOptions & {
108 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
112 implicitToken: false,
113 defaultExpectedStatus: HttpStatusCode.OK_200
117 // ---------------------------------------------------------------------------
119 view (options: OverrideCommandOptions & {
121 xForwardedFor?: string
123 const { id, xForwardedFor } = options
124 const path = '/api/v1/videos/' + id + '/views'
126 return this.postBodyRequest({
131 implicitToken: false,
132 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
136 rate (options: OverrideCommandOptions & {
138 rating: UserVideoRateType
140 const { id, rating } = options
141 const path = '/api/v1/videos/' + id + '/rate'
143 return this.putBodyRequest({
149 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
153 // ---------------------------------------------------------------------------
155 get (options: OverrideCommandOptions & {
158 const path = '/api/v1/videos/' + options.id
160 return this.getRequestBody<VideoDetails>({
164 implicitToken: false,
165 defaultExpectedStatus: HttpStatusCode.OK_200
169 getWithToken (options: OverrideCommandOptions & {
175 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
179 async getId (options: OverrideCommandOptions & {
180 uuid: number | string
182 const { uuid } = options
184 if (validator.isUUID('' + uuid) === false) return uuid as number
186 const { id } = await this.get({ ...options, id: uuid })
191 async listFiles (options: OverrideCommandOptions & {
194 const video = await this.get(options)
196 const files = video.files || []
197 const hlsFiles = video.streamingPlaylists[0]?.files || []
199 return files.concat(hlsFiles)
202 // ---------------------------------------------------------------------------
204 listMyVideos (options: OverrideCommandOptions & {
211 const path = '/api/v1/users/me/videos'
213 return this.getRequestBody<ResultList<Video>>({
217 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive' ]),
219 defaultExpectedStatus: HttpStatusCode.OK_200
223 // ---------------------------------------------------------------------------
225 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
226 const path = '/api/v1/videos'
228 const query = this.buildListQuery(options)
230 return this.getRequestBody<ResultList<Video>>({
234 query: { sort: 'name', ...query },
235 implicitToken: false,
236 defaultExpectedStatus: HttpStatusCode.OK_200
240 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
244 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
248 listByAccount (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
251 const { handle, search } = options
252 const path = '/api/v1/accounts/' + handle + '/videos'
254 return this.getRequestBody<ResultList<Video>>({
258 query: { search, ...this.buildListQuery(options) },
260 defaultExpectedStatus: HttpStatusCode.OK_200
264 listByChannel (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
267 const { handle } = options
268 const path = '/api/v1/video-channels/' + handle + '/videos'
270 return this.getRequestBody<ResultList<Video>>({
274 query: this.buildListQuery(options),
276 defaultExpectedStatus: HttpStatusCode.OK_200
280 // ---------------------------------------------------------------------------
282 async find (options: OverrideCommandOptions & {
285 const { data } = await this.list(options)
287 return data.find(v => v.name === options.name)
290 // ---------------------------------------------------------------------------
292 update (options: OverrideCommandOptions & {
294 attributes?: VideoEdit
296 const { id, attributes = {} } = options
297 const path = '/api/v1/videos/' + id
300 if (attributes.thumbnailfile || attributes.previewfile) {
301 const attaches: any = {}
302 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
303 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
305 return this.putUploadRequest({
309 fields: options.attributes,
311 thumbnailfile: attributes.thumbnailfile,
312 previewfile: attributes.previewfile
315 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
319 return this.putBodyRequest({
323 fields: options.attributes,
325 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
329 remove (options: OverrideCommandOptions & {
332 const path = '/api/v1/videos/' + options.id
334 return unwrapBody(this.deleteRequest({
339 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
344 const { data } = await this.list()
346 for (const v of data) {
347 await this.remove({ id: v.id })
351 // ---------------------------------------------------------------------------
353 async upload (options: OverrideCommandOptions & {
354 attributes?: VideoEdit
355 mode?: 'legacy' | 'resumable' // default legacy
357 const { mode = 'legacy' } = options
358 let defaultChannelId = 1
361 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
362 defaultChannelId = videoChannels[0].id
363 } catch (e) { /* empty */ }
365 // Override default attributes
367 name: 'my super video',
371 channelId: defaultChannelId,
373 waitTranscoding: false,
374 description: 'my super description',
375 support: 'my super support text',
377 privacy: VideoPrivacy.PUBLIC,
378 commentsEnabled: true,
379 downloadEnabled: true,
380 fixture: 'video_short.webm',
382 ...options.attributes
385 const created = mode === 'legacy'
386 ? await this.buildLegacyUpload({ ...options, attributes })
387 : await this.buildResumeUpload({ ...options, attributes })
389 // Wait torrent generation
390 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
391 if (expectedStatus === HttpStatusCode.OK_200) {
392 let video: VideoDetails
395 video = await this.getWithToken({ ...options, id: created.uuid })
398 } while (!video.files[0].torrentUrl)
404 async buildLegacyUpload (options: OverrideCommandOptions & {
405 attributes: VideoEdit
406 }): Promise<VideoCreateResult> {
407 const path = '/api/v1/videos/upload'
409 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
413 fields: this.buildUploadFields(options.attributes),
414 attaches: this.buildUploadAttaches(options.attributes),
416 defaultExpectedStatus: HttpStatusCode.OK_200
417 })).then(body => body.video || body as any)
420 async buildResumeUpload (options: OverrideCommandOptions & {
421 attributes: VideoEdit
422 }): Promise<VideoCreateResult> {
423 const { attributes, expectedStatus } = options
426 let videoFilePath: string
427 let mimetype = 'video/mp4'
429 if (attributes.fixture) {
430 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
431 size = (await stat(videoFilePath)).size
433 if (videoFilePath.endsWith('.mkv')) {
434 mimetype = 'video/x-matroska'
435 } else if (videoFilePath.endsWith('.webm')) {
436 mimetype = 'video/webm'
440 // Do not check status automatically, we'll check it manually
441 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
442 const initStatus = initializeSessionRes.status
444 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
445 const locationHeader = initializeSessionRes.header['location']
446 expect(locationHeader).to.not.be.undefined
448 const pathUploadId = locationHeader.split('?')[1]
450 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
452 if (result.statusCode === HttpStatusCode.OK_200) {
453 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
456 return result.body?.video || result.body as any
459 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
460 ? HttpStatusCode.CREATED_201
463 expect(initStatus).to.equal(expectedInitStatus)
465 return initializeSessionRes.body.video || initializeSessionRes.body
468 async prepareResumableUpload (options: OverrideCommandOptions & {
469 attributes: VideoEdit
473 const { attributes, size, mimetype } = options
475 const path = '/api/v1/videos/upload-resumable'
477 return this.postUploadRequest({
482 'X-Upload-Content-Type': mimetype,
483 'X-Upload-Content-Length': size.toString()
485 fields: { filename: attributes.fixture, ...this.buildUploadFields(options.attributes) },
486 // Fixture will be sent later
487 attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
490 defaultExpectedStatus: null
494 sendResumableChunks (options: OverrideCommandOptions & {
496 videoFilePath: string
498 contentLength?: number
499 contentRangeBuilder?: (start: number, chunk: any) => string
501 const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options
503 const path = '/api/v1/videos/upload-resumable'
506 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
507 const url = this.server.url
509 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
510 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
511 readable.on('data', async function onData (chunk) {
515 'Authorization': 'Bearer ' + token,
516 'Content-Type': 'application/octet-stream',
517 'Content-Range': contentRangeBuilder
518 ? contentRangeBuilder(start, chunk)
519 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
520 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
523 const res = await got<{ video: VideoCreateResult }>({
527 path: path + '?' + pathUploadId,
529 responseType: 'json',
530 throwHttpErrors: false
533 start += chunk.length
535 if (res.statusCode === expectedStatus) {
539 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
540 readable.off('data', onData)
541 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
549 endResumableUpload (options: OverrideCommandOptions & {
552 return this.deleteRequest({
555 path: '/api/v1/videos/upload-resumable',
556 rawQuery: options.pathUploadId,
558 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
562 quickUpload (options: OverrideCommandOptions & {
565 privacy?: VideoPrivacy
568 const attributes: VideoEdit = { name: options.name }
569 if (options.nsfw) attributes.nsfw = options.nsfw
570 if (options.privacy) attributes.privacy = options.privacy
571 if (options.fixture) attributes.fixture = options.fixture
573 return this.upload({ ...options, attributes })
576 async randomUpload (options: OverrideCommandOptions & {
577 wait?: boolean // default true
578 additionalParams?: VideoEdit & { prefixName?: string }
580 const { wait = true, additionalParams } = options
581 const prefixName = additionalParams?.prefixName || ''
582 const name = prefixName + buildUUID()
584 const attributes = { name, ...additionalParams }
586 const result = await this.upload({ ...options, attributes })
588 if (wait) await waitJobs([ this.server ])
590 return { ...result, name }
593 // ---------------------------------------------------------------------------
595 private buildListQuery (options: VideosCommonQuery) {
596 return pick(options, [
612 private buildUploadFields (attributes: VideoEdit) {
613 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
616 private buildUploadAttaches (attributes: VideoEdit) {
617 const attaches: { [ name: string ]: string } = {}
619 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
620 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
623 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)