import { expect } from 'chai' ; import { createReadStream , stat } from 'fs-extra' ; import got , { Response as GotResponse } from 'got' ; import { omit } from 'lodash' ; import validator from 'validator' ; import { buildAbsoluteFixturePath , pick , wait } from '@shared/core-utils' ; import { buildUUID } from '@shared/extra-utils' ; import { HttpStatusCode , ResultList , UserVideoRateType , Video , VideoCreate , VideoCreateResult , VideoDetails , VideoFileMetadata , VideoPrivacy , VideosCommonQuery , VideoTranscodingCreate } from '@shared/models' ; import { unwrapBody } from '../requests' ; import { waitJobs } from '../server' ; import { AbstractCommand , OverrideCommandOptions } from '../shared' ; export type VideoEdit = Partial < Omit < VideoCreate , 'thumbnailfile' | 'previewfile' > > & { fixture ? : string ; thumbnailfile ? : string ; previewfile ? : string ; } ; export class VideosCommand extends AbstractCommand { getCategories ( options = { } ) { const path = '/api/v1/videos/categories' ; return this . getRequestBody < { [ id ] : string ; } > ( { ... options , path , implicitToken : false , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } getLicences ( options = { } ) { const path = '/api/v1/videos/licences' ; return this . getRequestBody < { [ id ] : string ; } > ( { ... options , path , implicitToken : false , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } getLanguages ( options = { } ) { const path = '/api/v1/videos/languages' ; return this . getRequestBody < { [ id ] : string ; } > ( { ... options , path , implicitToken : false , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } getPrivacies ( options = { } ) { const path = '/api/v1/videos/privacies' ; return this . getRequestBody < { [ id in VideoPrivacy ] : string ; } > ( { ... options , path , implicitToken : false , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } getDescription ( options ) { return this . getRequestBody < { description : string ; } > ( { ... options , path : options . descriptionPath , implicitToken : false , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } getFileMetadata ( options ) { return unwrapBody < VideoFileMetadata > ( this . getRawRequest ( { ... options , url : options . url , implicitToken : false , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ) ; } view ( options ) { const { id , xForwardedFor } = options ; const path = '/api/v1/videos/' + id + '/views' ; return this . postBodyRequest ( { ... options , path , xForwardedFor , implicitToken : false , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ; } rate ( options ) { const { id , rating } = options ; const path = '/api/v1/videos/' + id + '/rate' ; return this . putBodyRequest ( { ... options , path , fields : { rating } , implicitToken : true , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ; } get ( options ) { const path = '/api/v1/videos/' + options . id ; return this . getRequestBody < VideoDetails > ( { ... options , path , implicitToken : false , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } getWithToken ( options ) { return this . get ( { ... options , token : this . buildCommonRequestToken ( { ... options , implicitToken : true } ) } ) ; } async getId ( options ) { const { uuid } = options ; if ( validator . isUUID ( '' + uuid ) === false ) return uuid as number ; const { id } = await this . get ( { ... options , id : uuid } ) ; return id ; } async listFiles ( options ) { const video = await this . get ( options ) ; const files = video . files || [ ] ; const hlsFiles = video . streamingPlaylists [ 0 ] ?. files || [ ] ; return files . concat ( hlsFiles ) ; } listMyVideos ( options = { } ) { const path = '/api/v1/users/me/videos' ; return this . getRequestBody < ResultList < Video > > ( { ... options , path , query : pick ( options , [ 'start' , 'count' , 'sort' , 'search' , 'isLive' , 'channelId' ] ) , implicitToken : true , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } list ( options = { } ) { const path = '/api/v1/videos' ; const query = this . buildListQuery ( options ) ; return this . getRequestBody < ResultList < Video > > ( { ... options , path , query : { sort : 'name' , ... query } , implicitToken : false , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } listWithToken ( options = { } ) { return this . list ( { ... options , token : this . buildCommonRequestToken ( { ... options , implicitToken : true } ) } ) ; } listByAccount ( options ) { const { handle , search } = options ; const path = '/api/v1/accounts/' + handle + '/videos' ; return this . getRequestBody < ResultList < Video > > ( { ... options , path , query : { search , ... this . buildListQuery ( options ) } , implicitToken : true , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } listByChannel ( options ) { const { handle } = options ; const path = '/api/v1/video-channels/' + handle + '/videos' ; return this . getRequestBody < ResultList < Video > > ( { ... options , path , query : this . buildListQuery ( options ) , implicitToken : true , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; } async find ( options ) { const { data } = await this . list ( options ) ; return data . find ( v => v . name === options . name ) ; } update ( options ) { const { id , attributes = { } } = options ; const path = '/api/v1/videos/' + id ; if ( attributes . thumbnailfile || attributes . previewfile ) { const attaches = { } ; if ( attributes . thumbnailfile ) attaches . thumbnailfile = attributes . thumbnailfile ; if ( attributes . previewfile ) attaches . previewfile = attributes . previewfile ; return this . putUploadRequest ( { ... options , path , fields : options . attributes , attaches : { thumbnailfile : attributes . thumbnailfile , previewfile : attributes . previewfile } , implicitToken : true , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ; } return this . putBodyRequest ( { ... options , path , fields : options . attributes , implicitToken : true , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ; } remove ( options ) { const path = '/api/v1/videos/' + options . id ; return unwrapBody ( this . deleteRequest ( { ... options , path , implicitToken : true , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ) ; } async removeAll ( ) { const { data } = await this . list ( ) ; for ( const v of data ) { await this . remove ( { id : v . id } ) ; } } async upload ( options = { } ) { const { mode = 'legacy' } = options ; let defaultChannelId = 1 ; try { const { videoChannels } = await this . server . users . getMyInfo ( { token : options . token } ) ; defaultChannelId = videoChannels [ 0 ] . id ; } catch ( e ) { } const attributes = { name : 'my super video' , category : 5 , licence : 4 , language : 'zh' , channelId : defaultChannelId , nsfw : true , waitTranscoding : false , description : 'my super description' , support : 'my super support text' , tags : [ 'tag' ] , privacy : VideoPrivacy . PUBLIC , commentsEnabled : true , downloadEnabled : true , fixture : 'video_short.webm' , ... options . attributes } ; const created = mode === 'legacy' ? await this . buildLegacyUpload ( { ... options , attributes } ) : await this . buildResumeUpload ( { ... options , attributes } ) ; const expectedStatus = this . buildExpectedStatus ( { ... options , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ; if ( expectedStatus === HttpStatusCode . OK_200 ) { let video ; do { video = await this . getWithToken ( { ... options , id : created . uuid } ) ; await wait ( 50 ) ; } while ( ! video . files [ 0 ] . torrentUrl ) ; } return created ; } async buildLegacyUpload ( options ) { const path = '/api/v1/videos/upload' ; return unwrapBody < { video : VideoCreateResult ; } > ( this . postUploadRequest ( { ... options , path , fields : this . buildUploadFields ( options . attributes ) , attaches : this . buildUploadAttaches ( options . attributes ) , implicitToken : true , defaultExpectedStatus : HttpStatusCode . OK_200 } ) ) . then ( body => body . video || body as any ) ; } async buildResumeUpload ( options ) { const { attributes , expectedStatus } = options ; let size = 0 ; let videoFilePath ; let mimetype = 'video/mp4' ; if ( attributes . fixture ) { videoFilePath = buildAbsoluteFixturePath ( attributes . fixture ) ; size = ( await stat ( videoFilePath ) ) . size ; if ( videoFilePath . endsWith ( '.mkv' ) ) { mimetype = 'video/x-matroska' ; } else if ( videoFilePath . endsWith ( '.webm' ) ) { mimetype = 'video/webm' ; } } const initializeSessionRes = await this . prepareResumableUpload ( { ... options , expectedStatus : null , attributes , size , mimetype } ) ; const initStatus = initializeSessionRes . status ; if ( videoFilePath && initStatus === HttpStatusCode . CREATED_201 ) { const locationHeader = initializeSessionRes . header [ 'location' ] ; expect ( locationHeader ) . to . not . be . undefined ; const pathUploadId = locationHeader . split ( '?' ) [ 1 ] ; const result = await this . sendResumableChunks ( { ... options , pathUploadId , videoFilePath , size } ) ; if ( result . statusCode === HttpStatusCode . OK_200 ) { await this . endResumableUpload ( { ... options , expectedStatus : HttpStatusCode . NO_CONTENT_204 , pathUploadId } ) ; } return result . body ?. video || result . body as any ; } const expectedInitStatus = expectedStatus === HttpStatusCode . OK_200 ? HttpStatusCode . CREATED_201 : expectedStatus ; expect ( initStatus ) . to . equal ( expectedInitStatus ) ; return initializeSessionRes . body . video || initializeSessionRes . body ; } async prepareResumableUpload ( options ) { const { attributes , originalName , lastModified , size , mimetype } = options ; const path = '/api/v1/videos/upload-resumable' ; return this . postUploadRequest ( { ... options , path , headers : { 'X-Upload-Content-Type' : mimetype , 'X-Upload-Content-Length' : size . toString ( ) } , fields : { filename : attributes . fixture , originalName , lastModified , ... this . buildUploadFields ( options . attributes ) } , attaches : this . buildUploadAttaches ( omit ( options . attributes , 'fixture' ) ) , implicitToken : true , defaultExpectedStatus : null } ) ; } sendResumableChunks ( options ) { const { pathUploadId , videoFilePath , size , contentLength , contentRangeBuilder , expectedStatus = HttpStatusCode . OK_200 } = options ; const path = '/api/v1/videos/upload-resumable' ; let start = 0 ; const token = this . buildCommonRequestToken ( { ... options , implicitToken : true } ) ; const url = this . server . url ; const readable = createReadStream ( videoFilePath , { highWaterMark : 8 * 1024 } ) ; return new Promise < GotResponse < { video : VideoCreateResult ; } > > ( ( resolve , reject ) => { readable . on ( 'data' , async function onData ( chunk ) { readable . pause ( ) ; const headers = { 'Authorization' : 'Bearer ' + token , 'Content-Type' : 'application/octet-stream' , 'Content-Range' : contentRangeBuilder ? contentRangeBuilder ( start , chunk ) : `bytes ${ start }-${ start + chunk . length - 1 }/${ size }` , 'Content-Length' : contentLength ? contentLength + '' : chunk . length + '' } ; const res = await got < { video : VideoCreateResult ; } > ( { url , method : 'put' , headers , path : path + '?' + pathUploadId , body : chunk , responseType : 'json' , throwHttpErrors : false } ) ; start += chunk . length ; if ( res . statusCode === expectedStatus ) { return resolve ( res ) ; } if ( res . statusCode !== HttpStatusCode . PERMANENT_REDIRECT_308 ) { readable . off ( 'data' , onData ) ; return reject ( new Error ( 'Incorrect transient behaviour sending intermediary chunks' ) ) ; } readable . resume ( ) ; } ) ; } ) ; } endResumableUpload ( options ) { return this . deleteRequest ( { ... options , path : '/api/v1/videos/upload-resumable' , rawQuery : options . pathUploadId , implicitToken : true , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ; } quickUpload ( options ) { const attributes = { name : options . name } ; if ( options . nsfw ) attributes . nsfw = options . nsfw ; if ( options . privacy ) attributes . privacy = options . privacy ; if ( options . fixture ) attributes . fixture = options . fixture ; return this . upload ( { ... options , attributes } ) ; } async randomUpload ( options = { } ) { const { wait = true , additionalParams } = options ; const prefixName = additionalParams ?. prefixName || '' ; const name = prefixName + buildUUID ( ) ; const attributes = { name , ... additionalParams } ; const result = await this . upload ( { ... options , attributes } ) ; if ( wait ) await waitJobs ( [ this . server ] ) ; return { ... result , name } ; } removeHLSFiles ( options ) { const path = '/api/v1/videos/' + options . videoId + '/hls' ; return this . deleteRequest ( { ... options , path , implicitToken : true , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ; } removeWebTorrentFiles ( options ) { const path = '/api/v1/videos/' + options . videoId + '/webtorrent' ; return this . deleteRequest ( { ... options , path , implicitToken : true , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ; } runTranscoding ( options ) { const path = '/api/v1/videos/' + options . videoId + '/transcoding' ; const fields = pick ( options , [ 'transcodingType' ] ) ; return this . postBodyRequest ( { ... options , path , fields , implicitToken : true , defaultExpectedStatus : HttpStatusCode . NO_CONTENT_204 } ) ; } private buildListQuery ( options ) { return pick ( options , [ 'start' , 'count' , 'sort' , 'nsfw' , 'isLive' , 'categoryOneOf' , 'licenceOneOf' , 'languageOneOf' , 'tagsOneOf' , 'tagsAllOf' , 'isLocal' , 'include' , 'skipCount' ] ) ; } private buildUploadFields ( attributes ) { return omit ( attributes , [ 'fixture' , 'thumbnailfile' , 'previewfile' ] ) ; } private buildUploadAttaches ( attributes ) { const attaches = { } ; for ( const key of [ 'thumbnailfile' , 'previewfile' ] ) { if ( attributes [ key ] ) attaches [ key ] = buildAbsoluteFixturePath ( attributes [ key ] ) ; } if ( attributes . fixture ) attaches . videofile = buildAbsoluteFixturePath ( attributes . fixture ) ; return attaches ; } }