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, getAllPrivacies, omit, pick, wait } from '@shared/core-utils'
8 import { buildUUID } from '@shared/extra-utils'
21 VideoTranscodingCreate
22 } from '@shared/models'
23 import { VideoSource } from '@shared/models/videos/video-source'
24 import { unwrapBody } from '../requests'
25 import { 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 {
35 getCategories (options: OverrideCommandOptions = {}) {
36 const path = '/api/v1/videos/categories'
38 return this.getRequestBody<{ [id: number]: string }>({
43 defaultExpectedStatus: HttpStatusCode.OK_200
47 getLicences (options: OverrideCommandOptions = {}) {
48 const path = '/api/v1/videos/licences'
50 return this.getRequestBody<{ [id: number]: string }>({
55 defaultExpectedStatus: HttpStatusCode.OK_200
59 getLanguages (options: OverrideCommandOptions = {}) {
60 const path = '/api/v1/videos/languages'
62 return this.getRequestBody<{ [id: string]: string }>({
67 defaultExpectedStatus: HttpStatusCode.OK_200
71 getPrivacies (options: OverrideCommandOptions = {}) {
72 const path = '/api/v1/videos/privacies'
74 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
79 defaultExpectedStatus: HttpStatusCode.OK_200
83 // ---------------------------------------------------------------------------
85 getDescription (options: OverrideCommandOptions & {
86 descriptionPath: string
88 return this.getRequestBody<{ description: string }>({
90 path: options.descriptionPath,
93 defaultExpectedStatus: HttpStatusCode.OK_200
97 getFileMetadata (options: OverrideCommandOptions & {
100 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
104 implicitToken: false,
105 defaultExpectedStatus: HttpStatusCode.OK_200
109 // ---------------------------------------------------------------------------
111 rate (options: OverrideCommandOptions & {
113 rating: UserVideoRateType
115 const { id, rating } = options
116 const path = '/api/v1/videos/' + id + '/rate'
118 return this.putBodyRequest({
124 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
128 // ---------------------------------------------------------------------------
130 get (options: OverrideCommandOptions & {
133 const path = '/api/v1/videos/' + options.id
135 return this.getRequestBody<VideoDetails>({
139 implicitToken: false,
140 defaultExpectedStatus: HttpStatusCode.OK_200
144 getWithToken (options: OverrideCommandOptions & {
150 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
154 getSource (options: OverrideCommandOptions & {
157 const path = '/api/v1/videos/' + options.id + '/source'
159 return this.getRequestBody<VideoSource>({
164 defaultExpectedStatus: HttpStatusCode.OK_200
168 async getId (options: OverrideCommandOptions & {
169 uuid: number | string
171 const { uuid } = options
173 if (validator.isUUID('' + uuid) === false) return uuid as number
175 const { id } = await this.get({ ...options, id: uuid })
180 async listFiles (options: OverrideCommandOptions & {
183 const video = await this.get(options)
185 const files = video.files || []
186 const hlsFiles = video.streamingPlaylists[0]?.files || []
188 return files.concat(hlsFiles)
191 // ---------------------------------------------------------------------------
193 listMyVideos (options: OverrideCommandOptions & {
201 const path = '/api/v1/users/me/videos'
203 return this.getRequestBody<ResultList<Video>>({
207 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
209 defaultExpectedStatus: HttpStatusCode.OK_200
213 listMySubscriptionVideos (options: OverrideCommandOptions & VideosCommonQuery = {}) {
214 const { sort = '-createdAt' } = options
215 const path = '/api/v1/users/me/subscriptions/videos'
217 return this.getRequestBody<ResultList<Video>>({
221 query: { sort, ...this.buildListQuery(options) },
223 defaultExpectedStatus: HttpStatusCode.OK_200
227 // ---------------------------------------------------------------------------
229 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
230 const path = '/api/v1/videos'
232 const query = this.buildListQuery(options)
234 return this.getRequestBody<ResultList<Video>>({
238 query: { sort: 'name', ...query },
239 implicitToken: false,
240 defaultExpectedStatus: HttpStatusCode.OK_200
244 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
248 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
252 listAllForAdmin (options: OverrideCommandOptions & VideosCommonQuery = {}) {
253 const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER
255 const privacyOneOf = getAllPrivacies()
264 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
268 listByAccount (options: OverrideCommandOptions & VideosCommonQuery & {
271 const { handle, search } = options
272 const path = '/api/v1/accounts/' + handle + '/videos'
274 return this.getRequestBody<ResultList<Video>>({
278 query: { search, ...this.buildListQuery(options) },
280 defaultExpectedStatus: HttpStatusCode.OK_200
284 listByChannel (options: OverrideCommandOptions & VideosCommonQuery & {
287 const { handle } = options
288 const path = '/api/v1/video-channels/' + handle + '/videos'
290 return this.getRequestBody<ResultList<Video>>({
294 query: this.buildListQuery(options),
296 defaultExpectedStatus: HttpStatusCode.OK_200
300 // ---------------------------------------------------------------------------
302 async find (options: OverrideCommandOptions & {
305 const { data } = await this.list(options)
307 return data.find(v => v.name === options.name)
310 // ---------------------------------------------------------------------------
312 update (options: OverrideCommandOptions & {
314 attributes?: VideoEdit
316 const { id, attributes = {} } = options
317 const path = '/api/v1/videos/' + id
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
325 return this.putUploadRequest({
329 fields: options.attributes,
331 thumbnailfile: attributes.thumbnailfile,
332 previewfile: attributes.previewfile
335 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
339 return this.putBodyRequest({
343 fields: options.attributes,
345 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
349 remove (options: OverrideCommandOptions & {
352 const path = '/api/v1/videos/' + options.id
354 return unwrapBody(this.deleteRequest({
359 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
364 const { data } = await this.list()
366 for (const v of data) {
367 await this.remove({ id: v.id })
371 // ---------------------------------------------------------------------------
373 async upload (options: OverrideCommandOptions & {
374 attributes?: VideoEdit
375 mode?: 'legacy' | 'resumable' // default legacy
376 waitTorrentGeneration?: boolean // default true
378 const { mode = 'legacy', waitTorrentGeneration = true } = options
379 let defaultChannelId = 1
382 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
383 defaultChannelId = videoChannels[0].id
384 } catch (e) { /* empty */ }
386 // Override default attributes
388 name: 'my super video',
392 channelId: defaultChannelId,
394 waitTranscoding: false,
395 description: 'my super description',
396 support: 'my super support text',
398 privacy: VideoPrivacy.PUBLIC,
399 commentsEnabled: true,
400 downloadEnabled: true,
401 fixture: 'video_short.webm',
403 ...options.attributes
406 const created = mode === 'legacy'
407 ? await this.buildLegacyUpload({ ...options, attributes })
408 : await this.buildResumeUpload({ ...options, attributes })
410 // Wait torrent generation
411 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
412 if (expectedStatus === HttpStatusCode.OK_200 && waitTorrentGeneration) {
413 let video: VideoDetails
416 video = await this.getWithToken({ ...options, id: created.uuid })
419 } while (!video.files[0].torrentUrl)
425 async buildLegacyUpload (options: OverrideCommandOptions & {
426 attributes: VideoEdit
427 }): Promise<VideoCreateResult> {
428 const path = '/api/v1/videos/upload'
430 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
434 fields: this.buildUploadFields(options.attributes),
435 attaches: this.buildUploadAttaches(options.attributes),
437 defaultExpectedStatus: HttpStatusCode.OK_200
438 })).then(body => body.video || body as any)
441 async buildResumeUpload (options: OverrideCommandOptions & {
442 attributes: VideoEdit
443 }): Promise<VideoCreateResult> {
444 const { attributes, expectedStatus } = options
447 let videoFilePath: string
448 let mimetype = 'video/mp4'
450 if (attributes.fixture) {
451 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
452 size = (await stat(videoFilePath)).size
454 if (videoFilePath.endsWith('.mkv')) {
455 mimetype = 'video/x-matroska'
456 } else if (videoFilePath.endsWith('.webm')) {
457 mimetype = 'video/webm'
461 // Do not check status automatically, we'll check it manually
462 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
463 const initStatus = initializeSessionRes.status
465 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
466 const locationHeader = initializeSessionRes.header['location']
467 expect(locationHeader).to.not.be.undefined
469 const pathUploadId = locationHeader.split('?')[1]
471 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
473 if (result.statusCode === HttpStatusCode.OK_200) {
474 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
477 return result.body?.video || result.body as any
480 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
481 ? HttpStatusCode.CREATED_201
484 expect(initStatus).to.equal(expectedInitStatus)
486 return initializeSessionRes.body.video || initializeSessionRes.body
489 async prepareResumableUpload (options: OverrideCommandOptions & {
490 attributes: VideoEdit
494 originalName?: string
495 lastModified?: number
497 const { attributes, originalName, lastModified, size, mimetype } = options
499 const path = '/api/v1/videos/upload-resumable'
501 return this.postUploadRequest({
506 'X-Upload-Content-Type': mimetype,
507 'X-Upload-Content-Length': size.toString()
510 filename: attributes.fixture,
514 ...this.buildUploadFields(options.attributes)
517 // Fixture will be sent later
518 attaches: this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ])),
521 defaultExpectedStatus: null
525 sendResumableChunks (options: OverrideCommandOptions & {
527 videoFilePath: string
529 contentLength?: number
530 contentRangeBuilder?: (start: number, chunk: any) => string
531 digestBuilder?: (chunk: any) => string
540 expectedStatus = HttpStatusCode.OK_200
543 const path = '/api/v1/videos/upload-resumable'
546 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
547 const url = this.server.url
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) {
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 + ''
564 Object.assign(headers, { digest: digestBuilder(chunk) })
567 const res = await got<{ video: VideoCreateResult }>({
571 path: path + '?' + pathUploadId,
573 responseType: 'json',
574 throwHttpErrors: false
577 start += chunk.length
579 if (res.statusCode === expectedStatus) {
583 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
584 readable.off('data', onData)
585 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
593 endResumableUpload (options: OverrideCommandOptions & {
596 return this.deleteRequest({
599 path: '/api/v1/videos/upload-resumable',
600 rawQuery: options.pathUploadId,
602 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
606 quickUpload (options: OverrideCommandOptions & {
609 privacy?: VideoPrivacy
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
617 return this.upload({ ...options, attributes })
620 async randomUpload (options: OverrideCommandOptions & {
621 wait?: boolean // default true
622 additionalParams?: VideoEdit & { prefixName?: string }
624 const { wait = true, additionalParams } = options
625 const prefixName = additionalParams?.prefixName || ''
626 const name = prefixName + buildUUID()
628 const attributes = { name, ...additionalParams }
630 const result = await this.upload({ ...options, attributes })
632 if (wait) await waitJobs([ this.server ])
634 return { ...result, name }
637 // ---------------------------------------------------------------------------
639 removeHLSPlaylist (options: OverrideCommandOptions & {
640 videoId: number | string
642 const path = '/api/v1/videos/' + options.videoId + '/hls'
644 return this.deleteRequest({
649 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
653 removeHLSFile (options: OverrideCommandOptions & {
654 videoId: number | string
657 const path = '/api/v1/videos/' + options.videoId + '/hls/' + options.fileId
659 return this.deleteRequest({
664 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
668 removeAllWebTorrentFiles (options: OverrideCommandOptions & {
669 videoId: number | string
671 const path = '/api/v1/videos/' + options.videoId + '/webtorrent'
673 return this.deleteRequest({
678 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
682 removeWebTorrentFile (options: OverrideCommandOptions & {
683 videoId: number | string
686 const path = '/api/v1/videos/' + options.videoId + '/webtorrent/' + options.fileId
688 return this.deleteRequest({
693 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
697 runTranscoding (options: OverrideCommandOptions & {
698 videoId: number | string
699 transcodingType: 'hls' | 'webtorrent'
701 const path = '/api/v1/videos/' + options.videoId + '/transcoding'
703 const fields: VideoTranscodingCreate = pick(options, [ 'transcodingType' ])
705 return this.postBodyRequest({
711 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
715 // ---------------------------------------------------------------------------
717 private buildListQuery (options: VideosCommonQuery) {
718 return pick(options, [
736 private buildUploadFields (attributes: VideoEdit) {
737 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
740 private buildUploadAttaches (attributes: VideoEdit) {
741 const attaches: { [ name: string ]: string } = {}
743 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
744 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
747 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)