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 validator from 'validator'
7 import { buildAbsoluteFixturePath, omit, pick, wait } from '@shared/core-utils'
8 import { buildUUID } from '@shared/extra-utils'
20 VideoTranscodingCreate
21 } from '@shared/models'
22 import { VideoSource } from '@shared/models/videos/video-source'
23 import { unwrapBody } from '../requests'
24 import { waitJobs } from '../server'
25 import { AbstractCommand, OverrideCommandOptions } from '../shared'
27 export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
29 thumbnailfile?: string
33 export class VideosCommand extends AbstractCommand {
34 getCategories (options: OverrideCommandOptions = {}) {
35 const path = '/api/v1/videos/categories'
37 return this.getRequestBody<{ [id: number]: string }>({
42 defaultExpectedStatus: HttpStatusCode.OK_200
46 getLicences (options: OverrideCommandOptions = {}) {
47 const path = '/api/v1/videos/licences'
49 return this.getRequestBody<{ [id: number]: string }>({
54 defaultExpectedStatus: HttpStatusCode.OK_200
58 getLanguages (options: OverrideCommandOptions = {}) {
59 const path = '/api/v1/videos/languages'
61 return this.getRequestBody<{ [id: string]: string }>({
66 defaultExpectedStatus: HttpStatusCode.OK_200
70 getPrivacies (options: OverrideCommandOptions = {}) {
71 const path = '/api/v1/videos/privacies'
73 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
78 defaultExpectedStatus: HttpStatusCode.OK_200
82 // ---------------------------------------------------------------------------
84 getDescription (options: OverrideCommandOptions & {
85 descriptionPath: string
87 return this.getRequestBody<{ description: string }>({
89 path: options.descriptionPath,
92 defaultExpectedStatus: HttpStatusCode.OK_200
96 getFileMetadata (options: OverrideCommandOptions & {
99 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
103 implicitToken: false,
104 defaultExpectedStatus: HttpStatusCode.OK_200
108 // ---------------------------------------------------------------------------
110 rate (options: OverrideCommandOptions & {
112 rating: UserVideoRateType
114 const { id, rating } = options
115 const path = '/api/v1/videos/' + id + '/rate'
117 return this.putBodyRequest({
123 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
127 // ---------------------------------------------------------------------------
129 get (options: OverrideCommandOptions & {
132 const path = '/api/v1/videos/' + options.id
134 return this.getRequestBody<VideoDetails>({
138 implicitToken: false,
139 defaultExpectedStatus: HttpStatusCode.OK_200
143 getWithToken (options: OverrideCommandOptions & {
149 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
153 getSource (options: OverrideCommandOptions & {
156 const path = '/api/v1/videos/' + options.id + '/source'
158 return this.getRequestBody<VideoSource>({
163 defaultExpectedStatus: HttpStatusCode.OK_200
167 async getId (options: OverrideCommandOptions & {
168 uuid: number | string
170 const { uuid } = options
172 if (validator.isUUID('' + uuid) === false) return uuid as number
174 const { id } = await this.get({ ...options, id: uuid })
179 async listFiles (options: OverrideCommandOptions & {
182 const video = await this.get(options)
184 const files = video.files || []
185 const hlsFiles = video.streamingPlaylists[0]?.files || []
187 return files.concat(hlsFiles)
190 // ---------------------------------------------------------------------------
192 listMyVideos (options: OverrideCommandOptions & {
200 const path = '/api/v1/users/me/videos'
202 return this.getRequestBody<ResultList<Video>>({
206 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
208 defaultExpectedStatus: HttpStatusCode.OK_200
212 // ---------------------------------------------------------------------------
214 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
215 const path = '/api/v1/videos'
217 const query = this.buildListQuery(options)
219 return this.getRequestBody<ResultList<Video>>({
223 query: { sort: 'name', ...query },
224 implicitToken: false,
225 defaultExpectedStatus: HttpStatusCode.OK_200
229 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
233 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
237 listByAccount (options: OverrideCommandOptions & VideosCommonQuery & {
240 const { handle, search } = options
241 const path = '/api/v1/accounts/' + handle + '/videos'
243 return this.getRequestBody<ResultList<Video>>({
247 query: { search, ...this.buildListQuery(options) },
249 defaultExpectedStatus: HttpStatusCode.OK_200
253 listByChannel (options: OverrideCommandOptions & VideosCommonQuery & {
256 const { handle } = options
257 const path = '/api/v1/video-channels/' + handle + '/videos'
259 return this.getRequestBody<ResultList<Video>>({
263 query: this.buildListQuery(options),
265 defaultExpectedStatus: HttpStatusCode.OK_200
269 // ---------------------------------------------------------------------------
271 async find (options: OverrideCommandOptions & {
274 const { data } = await this.list(options)
276 return data.find(v => v.name === options.name)
279 // ---------------------------------------------------------------------------
281 update (options: OverrideCommandOptions & {
283 attributes?: VideoEdit
285 const { id, attributes = {} } = options
286 const path = '/api/v1/videos/' + id
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
294 return this.putUploadRequest({
298 fields: options.attributes,
300 thumbnailfile: attributes.thumbnailfile,
301 previewfile: attributes.previewfile
304 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
308 return this.putBodyRequest({
312 fields: options.attributes,
314 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
318 remove (options: OverrideCommandOptions & {
321 const path = '/api/v1/videos/' + options.id
323 return unwrapBody(this.deleteRequest({
328 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
333 const { data } = await this.list()
335 for (const v of data) {
336 await this.remove({ id: v.id })
340 // ---------------------------------------------------------------------------
342 async upload (options: OverrideCommandOptions & {
343 attributes?: VideoEdit
344 mode?: 'legacy' | 'resumable' // default legacy
346 const { mode = 'legacy' } = options
347 let defaultChannelId = 1
350 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
351 defaultChannelId = videoChannels[0].id
352 } catch (e) { /* empty */ }
354 // Override default attributes
356 name: 'my super video',
360 channelId: defaultChannelId,
362 waitTranscoding: false,
363 description: 'my super description',
364 support: 'my super support text',
366 privacy: VideoPrivacy.PUBLIC,
367 commentsEnabled: true,
368 downloadEnabled: true,
369 fixture: 'video_short.webm',
371 ...options.attributes
374 const created = mode === 'legacy'
375 ? await this.buildLegacyUpload({ ...options, attributes })
376 : await this.buildResumeUpload({ ...options, attributes })
378 // Wait torrent generation
379 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
380 if (expectedStatus === HttpStatusCode.OK_200) {
381 let video: VideoDetails
384 video = await this.getWithToken({ ...options, id: created.uuid })
387 } while (!video.files[0].torrentUrl)
393 async buildLegacyUpload (options: OverrideCommandOptions & {
394 attributes: VideoEdit
395 }): Promise<VideoCreateResult> {
396 const path = '/api/v1/videos/upload'
398 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
402 fields: this.buildUploadFields(options.attributes),
403 attaches: this.buildUploadAttaches(options.attributes),
405 defaultExpectedStatus: HttpStatusCode.OK_200
406 })).then(body => body.video || body as any)
409 async buildResumeUpload (options: OverrideCommandOptions & {
410 attributes: VideoEdit
411 }): Promise<VideoCreateResult> {
412 const { attributes, expectedStatus } = options
415 let videoFilePath: string
416 let mimetype = 'video/mp4'
418 if (attributes.fixture) {
419 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
420 size = (await stat(videoFilePath)).size
422 if (videoFilePath.endsWith('.mkv')) {
423 mimetype = 'video/x-matroska'
424 } else if (videoFilePath.endsWith('.webm')) {
425 mimetype = 'video/webm'
429 // Do not check status automatically, we'll check it manually
430 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
431 const initStatus = initializeSessionRes.status
433 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
434 const locationHeader = initializeSessionRes.header['location']
435 expect(locationHeader).to.not.be.undefined
437 const pathUploadId = locationHeader.split('?')[1]
439 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
441 if (result.statusCode === HttpStatusCode.OK_200) {
442 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
445 return result.body?.video || result.body as any
448 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
449 ? HttpStatusCode.CREATED_201
452 expect(initStatus).to.equal(expectedInitStatus)
454 return initializeSessionRes.body.video || initializeSessionRes.body
457 async prepareResumableUpload (options: OverrideCommandOptions & {
458 attributes: VideoEdit
462 originalName?: string
463 lastModified?: number
465 const { attributes, originalName, lastModified, size, mimetype } = options
467 const path = '/api/v1/videos/upload-resumable'
469 return this.postUploadRequest({
474 'X-Upload-Content-Type': mimetype,
475 'X-Upload-Content-Length': size.toString()
478 filename: attributes.fixture,
482 ...this.buildUploadFields(options.attributes)
485 // Fixture will be sent later
486 attaches: this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ])),
489 defaultExpectedStatus: null
493 sendResumableChunks (options: OverrideCommandOptions & {
495 videoFilePath: string
497 contentLength?: number
498 contentRangeBuilder?: (start: number, chunk: any) => string
499 digestBuilder?: (chunk: any) => string
508 expectedStatus = HttpStatusCode.OK_200
511 const path = '/api/v1/videos/upload-resumable'
514 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
515 const url = this.server.url
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) {
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 + ''
532 Object.assign(headers, { digest: digestBuilder(chunk) })
535 const res = await got<{ video: VideoCreateResult }>({
539 path: path + '?' + pathUploadId,
541 responseType: 'json',
542 throwHttpErrors: false
545 start += chunk.length
547 if (res.statusCode === expectedStatus) {
551 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
552 readable.off('data', onData)
553 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
561 endResumableUpload (options: OverrideCommandOptions & {
564 return this.deleteRequest({
567 path: '/api/v1/videos/upload-resumable',
568 rawQuery: options.pathUploadId,
570 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
574 quickUpload (options: OverrideCommandOptions & {
577 privacy?: VideoPrivacy
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
585 return this.upload({ ...options, attributes })
588 async randomUpload (options: OverrideCommandOptions & {
589 wait?: boolean // default true
590 additionalParams?: VideoEdit & { prefixName?: string }
592 const { wait = true, additionalParams } = options
593 const prefixName = additionalParams?.prefixName || ''
594 const name = prefixName + buildUUID()
596 const attributes = { name, ...additionalParams }
598 const result = await this.upload({ ...options, attributes })
600 if (wait) await waitJobs([ this.server ])
602 return { ...result, name }
605 // ---------------------------------------------------------------------------
607 removeHLSPlaylist (options: OverrideCommandOptions & {
608 videoId: number | string
610 const path = '/api/v1/videos/' + options.videoId + '/hls'
612 return this.deleteRequest({
617 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
621 removeHLSFile (options: OverrideCommandOptions & {
622 videoId: number | string
625 const path = '/api/v1/videos/' + options.videoId + '/hls/' + options.fileId
627 return this.deleteRequest({
632 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
636 removeAllWebTorrentFiles (options: OverrideCommandOptions & {
637 videoId: number | string
639 const path = '/api/v1/videos/' + options.videoId + '/webtorrent'
641 return this.deleteRequest({
646 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
650 removeWebTorrentFile (options: OverrideCommandOptions & {
651 videoId: number | string
654 const path = '/api/v1/videos/' + options.videoId + '/webtorrent/' + options.fileId
656 return this.deleteRequest({
661 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
665 runTranscoding (options: OverrideCommandOptions & {
666 videoId: number | string
667 transcodingType: 'hls' | 'webtorrent'
669 const path = '/api/v1/videos/' + options.videoId + '/transcoding'
671 const fields: VideoTranscodingCreate = pick(options, [ 'transcodingType' ])
673 return this.postBodyRequest({
679 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
683 // ---------------------------------------------------------------------------
685 private buildListQuery (options: VideosCommonQuery) {
686 return pick(options, [
703 private buildUploadFields (attributes: VideoEdit) {
704 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
707 private buildUploadAttaches (attributes: VideoEdit) {
708 const attaches: { [ name: string ]: string } = {}
710 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
711 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
714 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)