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, pick } from 'lodash'
7 import validator from 'validator'
8 import { buildUUID } from '@server/helpers/uuid'
9 import { loadLanguages } from '@server/initializers/constants'
10 import { HttpStatusCode } from '@shared/core-utils'
21 VideosWithSearchCommonQuery
22 } from '@shared/models'
23 import { buildAbsoluteFixturePath, wait } from '../miscs'
24 import { unwrapBody } from '../requests'
25 import { PeerTubeServer, waitJobs } from '../server'
26 import { AbstractCommand, OverrideCommandOptions } from '../shared'
28 export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
30 thumbnailfile?: string
34 export class VideosCommand extends AbstractCommand {
36 constructor (server: PeerTubeServer) {
42 getCategories (options: OverrideCommandOptions = {}) {
43 const path = '/api/v1/videos/categories'
45 return this.getRequestBody<{ [id: number]: string }>({
50 defaultExpectedStatus: HttpStatusCode.OK_200
54 getLicences (options: OverrideCommandOptions = {}) {
55 const path = '/api/v1/videos/licences'
57 return this.getRequestBody<{ [id: number]: string }>({
62 defaultExpectedStatus: HttpStatusCode.OK_200
66 getLanguages (options: OverrideCommandOptions = {}) {
67 const path = '/api/v1/videos/languages'
69 return this.getRequestBody<{ [id: string]: string }>({
74 defaultExpectedStatus: HttpStatusCode.OK_200
78 getPrivacies (options: OverrideCommandOptions = {}) {
79 const path = '/api/v1/videos/privacies'
81 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
86 defaultExpectedStatus: HttpStatusCode.OK_200
90 // ---------------------------------------------------------------------------
92 getDescription (options: OverrideCommandOptions & {
93 descriptionPath: string
95 return this.getRequestBody<{ description: string }>({
97 path: options.descriptionPath,
100 defaultExpectedStatus: HttpStatusCode.OK_200
104 getFileMetadata (options: OverrideCommandOptions & {
107 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
111 implicitToken: false,
112 defaultExpectedStatus: HttpStatusCode.OK_200
116 // ---------------------------------------------------------------------------
118 view (options: OverrideCommandOptions & {
120 xForwardedFor?: string
122 const { id, xForwardedFor } = options
123 const path = '/api/v1/videos/' + id + '/views'
125 return this.postBodyRequest({
130 implicitToken: false,
131 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
135 rate (options: OverrideCommandOptions & {
137 rating: UserVideoRateType
139 const { id, rating } = options
140 const path = '/api/v1/videos/' + id + '/rate'
142 return this.putBodyRequest({
148 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
152 // ---------------------------------------------------------------------------
154 get (options: OverrideCommandOptions & {
157 const path = '/api/v1/videos/' + options.id
159 return this.getRequestBody<VideoDetails>({
163 implicitToken: false,
164 defaultExpectedStatus: HttpStatusCode.OK_200
168 getWithToken (options: OverrideCommandOptions & {
174 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
178 async getId (options: OverrideCommandOptions & {
179 uuid: number | string
181 const { uuid } = options
183 if (validator.isUUID('' + uuid) === false) return uuid as number
185 const { id } = await this.get({ ...options, id: uuid })
190 // ---------------------------------------------------------------------------
192 listMyVideos (options: OverrideCommandOptions & {
199 const path = '/api/v1/users/me/videos'
201 return this.getRequestBody<ResultList<Video>>({
205 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive' ]),
207 defaultExpectedStatus: HttpStatusCode.OK_200
211 // ---------------------------------------------------------------------------
213 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
214 const path = '/api/v1/videos'
216 const query = this.buildListQuery(options)
218 return this.getRequestBody<ResultList<Video>>({
222 query: { sort: 'name', ...query },
223 implicitToken: false,
224 defaultExpectedStatus: HttpStatusCode.OK_200
228 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
232 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
236 listByAccount (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
239 const { accountName, search } = options
240 const path = '/api/v1/accounts/' + accountName + '/videos'
242 return this.getRequestBody<ResultList<Video>>({
246 query: { search, ...this.buildListQuery(options) },
248 defaultExpectedStatus: HttpStatusCode.OK_200
252 listByChannel (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
253 videoChannelName: string
255 const { videoChannelName } = options
256 const path = '/api/v1/video-channels/' + videoChannelName + '/videos'
258 return this.getRequestBody<ResultList<Video>>({
262 query: this.buildListQuery(options),
264 defaultExpectedStatus: HttpStatusCode.OK_200
268 // ---------------------------------------------------------------------------
270 update (options: OverrideCommandOptions & {
272 attributes?: VideoEdit
274 const { id, attributes = {} } = options
275 const path = '/api/v1/videos/' + id
278 if (attributes.thumbnailfile || attributes.previewfile) {
279 const attaches: any = {}
280 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
281 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
283 return this.putUploadRequest({
287 fields: options.attributes,
289 thumbnailfile: attributes.thumbnailfile,
290 previewfile: attributes.previewfile
293 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
297 return this.putBodyRequest({
301 fields: options.attributes,
303 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
307 remove (options: OverrideCommandOptions & {
310 const path = '/api/v1/videos/' + options.id
312 return this.deleteRequest({
317 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
322 const { data } = await this.list()
324 for (const v of data) {
325 await this.remove({ id: v.id })
329 // ---------------------------------------------------------------------------
331 async upload (options: OverrideCommandOptions & {
332 attributes?: VideoEdit
333 mode?: 'legacy' | 'resumable' // default legacy
335 const { mode = 'legacy', expectedStatus } = options
336 let defaultChannelId = 1
339 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
340 defaultChannelId = videoChannels[0].id
341 } catch (e) { /* empty */ }
343 // Override default attributes
345 name: 'my super video',
349 channelId: defaultChannelId,
351 waitTranscoding: false,
352 description: 'my super description',
353 support: 'my super support text',
355 privacy: VideoPrivacy.PUBLIC,
356 commentsEnabled: true,
357 downloadEnabled: true,
358 fixture: 'video_short.webm',
360 ...options.attributes
363 const res = mode === 'legacy'
364 ? await this.buildLegacyUpload({ ...options, attributes })
365 : await this.buildResumeUpload({ ...options, attributes })
367 // Wait torrent generation
368 if (expectedStatus === HttpStatusCode.OK_200) {
369 let video: VideoDetails
372 video = await this.getWithToken({ ...options, id: video.uuid })
375 } while (!video.files[0].torrentUrl)
381 async buildLegacyUpload (options: OverrideCommandOptions & {
382 attributes: VideoEdit
383 }): Promise<VideoCreateResult> {
384 const path = '/api/v1/videos/upload'
386 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
390 fields: this.buildUploadFields(options.attributes),
391 attaches: this.buildUploadAttaches(options.attributes),
393 defaultExpectedStatus: HttpStatusCode.OK_200
394 })).then(body => body.video || body as any)
397 async buildResumeUpload (options: OverrideCommandOptions & {
398 attributes: VideoEdit
400 const { attributes, expectedStatus } = options
403 let videoFilePath: string
404 let mimetype = 'video/mp4'
406 if (attributes.fixture) {
407 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
408 size = (await stat(videoFilePath)).size
410 if (videoFilePath.endsWith('.mkv')) {
411 mimetype = 'video/x-matroska'
412 } else if (videoFilePath.endsWith('.webm')) {
413 mimetype = 'video/webm'
417 const initializeSessionRes = await this.prepareResumableUpload({ ...options, attributes, size, mimetype })
418 const initStatus = initializeSessionRes.status
420 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
421 const locationHeader = initializeSessionRes.header['location']
422 expect(locationHeader).to.not.be.undefined
424 const pathUploadId = locationHeader.split('?')[1]
426 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
428 return result.body.video
431 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
432 ? HttpStatusCode.CREATED_201
435 expect(initStatus).to.equal(expectedInitStatus)
437 return initializeSessionRes.body.video as VideoCreateResult
440 async prepareResumableUpload (options: OverrideCommandOptions & {
441 attributes: VideoEdit
445 const { attributes, size, mimetype } = options
447 const path = '/api/v1/videos/upload-resumable'
449 return this.postUploadRequest({
454 'X-Upload-Content-Type': mimetype,
455 'X-Upload-Content-Length': size.toString()
457 fields: { filename: attributes.fixture, ...this.buildUploadFields(options.attributes) },
459 defaultExpectedStatus: null
463 sendResumableChunks (options: OverrideCommandOptions & {
465 videoFilePath: string
467 contentLength?: number
468 contentRangeBuilder?: (start: number, chunk: any) => string
470 const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options
472 const path = '/api/v1/videos/upload-resumable'
475 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
476 const url = this.server.url
478 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
479 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
480 readable.on('data', async function onData (chunk) {
484 'Authorization': 'Bearer ' + token,
485 'Content-Type': 'application/octet-stream',
486 'Content-Range': contentRangeBuilder
487 ? contentRangeBuilder(start, chunk)
488 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
489 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
492 const res = await got<{ video: VideoCreateResult }>({
496 path: path + '?' + pathUploadId,
498 responseType: 'json',
499 throwHttpErrors: false
502 start += chunk.length
504 if (res.statusCode === expectedStatus) {
508 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
509 readable.off('data', onData)
510 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
518 quickUpload (options: OverrideCommandOptions & {
521 privacy?: VideoPrivacy
524 const attributes: VideoEdit = { name: options.name }
525 if (options.nsfw) attributes.nsfw = options.nsfw
526 if (options.privacy) attributes.privacy = options.privacy
527 if (options.fixture) attributes.fixture = options.fixture
529 return this.upload({ ...options, attributes })
532 async randomUpload (options: OverrideCommandOptions & {
533 wait?: boolean // default true
534 additionalParams?: VideoEdit & { prefixName: string }
536 const { wait = true, additionalParams } = options
537 const prefixName = additionalParams?.prefixName || ''
538 const name = prefixName + buildUUID()
540 const attributes = { name, additionalParams }
542 if (wait) await waitJobs([ this.server ])
544 const result = await this.upload({ ...options, attributes })
546 return { ...result, name }
549 // ---------------------------------------------------------------------------
551 private buildListQuery (options: VideosCommonQuery) {
552 return pick(options, [
568 private buildUploadFields (attributes: VideoEdit) {
569 return omit(attributes, [ 'thumbnailfile', 'previewfile' ])
572 private buildUploadAttaches (attributes: VideoEdit) {
573 const attaches: { [ name: string ]: string } = {}
575 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
576 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
579 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)