// Scroll to the highlighted thread
setTimeout(() => {
// -60 because of the fixed header
- console.log(this.commentHighlightBlock.nativeElement.offsetTop)
const scrollY = this.commentHighlightBlock.nativeElement.offsetTop - 60
window.scroll(0, scrollY)
}, 500)
// FIXME: https://github.com/nodejs/node/pull/16853
+import { ScheduleVideoUpdateModel } from './server/models/video/schedule-video-update'
+
require('tls').DEFAULT_ECDH_CURVE = 'auto'
import { isTestInstance } from './server/helpers/core-utils'
// Do not use barrels because we don't want to load all modules here (we need to initialize database first)
import { logger } from './server/helpers/logger'
-import { ACCEPT_HEADERS, API_VERSION, CONFIG, STATIC_PATHS } from './server/initializers/constants'
+import { API_VERSION, CONFIG, STATIC_PATHS } from './server/initializers/constants'
const missed = checkMissedConfig()
if (missed.length !== 0) {
import { Redis } from './server/lib/redis'
import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler'
import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
+import { UpdateVideosScheduler } from './server/lib/schedulers/update-videos-scheduler'
// ----------- Command line -----------
// Enable Schedulers
BadActorFollowScheduler.Instance.enable()
RemoveOldJobsScheduler.Instance.enable()
+ UpdateVideosScheduler.Instance.enable()
// Redis initialization
Redis.Instance.init()
false // Display my NSFW videos
)
- const additionalAttributes = { waitTranscoding: true, state: true }
+ const additionalAttributes = {
+ waitTranscoding: true,
+ state: true,
+ scheduledUpdate: true
+ }
return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes }))
}
import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
+import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
const videosRouter = express.Router()
video.VideoFiles = [ videoFile ]
+ // Create tags
if (videoInfo.tags !== undefined) {
const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
video.Tags = tagInstances
}
+ // Schedule an update in the future?
+ if (videoInfo.scheduleUpdate) {
+ await ScheduleVideoUpdateModel.create({
+ videoId: video.id,
+ updateAt: videoInfo.scheduleUpdate.updateAt,
+ privacy: videoInfo.scheduleUpdate.privacy || null
+ }, { transaction: t })
+ }
+
await federateVideoIfNeeded(video, true, t)
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
if (wasPrivateVideo === false) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
}
+ // Schedule an update in the future?
+ if (videoInfoToUpdate.scheduleUpdate) {
+ await ScheduleVideoUpdateModel.upsert({
+ videoId: videoInstanceUpdated.id,
+ updateAt: videoInfoToUpdate.scheduleUpdate.updateAt,
+ privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
+ }, { transaction: t })
+ }
+
const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE
await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo)
})
import { values } from 'lodash'
import 'multer'
import * as validator from 'validator'
-import { UserRight, VideoRateType } from '../../../shared'
+import { UserRight, VideoPrivacy, VideoRateType } from '../../../shared'
import {
CONSTRAINTS_FIELDS,
VIDEO_CATEGORIES,
return isFileValid(files, videoImageTypesRegex, field, true)
}
-function isVideoPrivacyValid (value: string) {
+function isVideoPrivacyValid (value: number) {
return validator.isInt(value + '') && VIDEO_PRIVACIES[ value ] !== undefined
}
+function isScheduleVideoUpdatePrivacyValid (value: number) {
+ return validator.isInt(value + '') &&
+ (
+ value === VideoPrivacy.UNLISTED ||
+ value === VideoPrivacy.PUBLIC
+ )
+}
+
function isVideoFileInfoHashValid (value: string) {
return exists(value) && validator.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.INFO_HASH)
}
isVideoFileInfoHashValid,
isVideoNameValid,
isVideoTagsValid,
+ isScheduleVideoUpdatePrivacyValid,
isVideoAbuseReasonValid,
isVideoFile,
isVideoStateValid,
arg1: A
): Promise<T>
+function retryTransactionWrapper <T> (
+ functionToRetry: () => Promise<T> | Bluebird<T>
+): Promise<T>
+
function retryTransactionWrapper <T> (
functionToRetry: (...args: any[]) => Promise<T> | Bluebird<T>,
...args: any[]
): Promise<T> {
return transactionRetryer<T>(callback => {
- functionToRetry.apply(this, args)
+ functionToRetry.apply(null, args)
.then((result: T) => callback(null, result))
.catch(err => callback(err))
})
import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
import { invert } from 'lodash'
+import { RemoveOldJobsScheduler } from '../lib/schedulers/remove-old-jobs-scheduler'
+import { UpdateVideosScheduler } from '../lib/schedulers/update-videos-scheduler'
// Use a variable to reload the configuration if we need
let config: IConfig = require('config')
const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 // 2 days
// 1 hour
-let SCHEDULER_INTERVAL = 60000 * 60
+let SCHEDULER_INTERVALS_MS = {
+ badActorFollow: 60000 * 60, // 1 hour
+ removeOldJobs: 60000 * 60, // 1 jour
+ updateVideos: 60000 * 1, // 1 minute
+}
// ---------------------------------------------------------------------------
CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
- SCHEDULER_INTERVAL = 10000
+ SCHEDULER_INTERVALS_MS.badActorFollow = 10000
+ SCHEDULER_INTERVALS_MS.removeOldJobs = 10000
+ SCHEDULER_INTERVALS_MS.updateVideos = 5000
+
VIDEO_VIEW_LIFETIME = 1000 // 1 second
JOB_ATTEMPTS['email'] = 1
JOB_REQUEST_TTL,
USER_PASSWORD_RESET_LIFETIME,
IMAGE_MIMETYPE_EXT,
- SCHEDULER_INTERVAL,
+ SCHEDULER_INTERVALS_MS,
STATIC_DOWNLOAD_PATHS,
RATES_LIMIT,
VIDEO_EXT_MIMETYPE,
import { VideoShareModel } from '../models/video/video-share'
import { VideoTagModel } from '../models/video/video-tag'
import { CONFIG } from './constants'
+import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
VideoBlacklistModel,
VideoTagModel,
VideoModel,
- VideoCommentModel
+ VideoCommentModel,
+ ScheduleVideoUpdateModel
])
if (!silent) logger.info('Database %s is ready.', dbname)
-import { SCHEDULER_INTERVAL } from '../../initializers'
-
export abstract class AbstractScheduler {
+ protected abstract schedulerIntervalMs: number
+
private interval: NodeJS.Timer
enable () {
- this.interval = setInterval(() => this.execute(), SCHEDULER_INTERVAL)
+ if (!this.schedulerIntervalMs) throw new Error('Interval is not correctly set.')
+
+ this.interval = setInterval(() => this.execute(), this.schedulerIntervalMs)
}
disable () {
import { logger } from '../../helpers/logger'
import { ActorFollowModel } from '../../models/activitypub/actor-follow'
import { AbstractScheduler } from './abstract-scheduler'
+import { SCHEDULER_INTERVALS_MS } from '../../initializers'
export class BadActorFollowScheduler extends AbstractScheduler {
private static instance: AbstractScheduler
+ protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.badActorFollow
+
private constructor () {
super()
}
import { logger } from '../../helpers/logger'
import { JobQueue } from '../job-queue'
import { AbstractScheduler } from './abstract-scheduler'
+import { SCHEDULER_INTERVALS_MS } from '../../initializers'
export class RemoveOldJobsScheduler extends AbstractScheduler {
private static instance: AbstractScheduler
+ protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.removeOldJobs
+
private constructor () {
super()
}
--- /dev/null
+import { isTestInstance } from '../../helpers/core-utils'
+import { logger } from '../../helpers/logger'
+import { JobQueue } from '../job-queue'
+import { AbstractScheduler } from './abstract-scheduler'
+import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-update'
+import { retryTransactionWrapper } from '../../helpers/database-utils'
+import { federateVideoIfNeeded } from '../activitypub'
+import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers'
+import { VideoPrivacy } from '../../../shared/models/videos'
+
+export class UpdateVideosScheduler extends AbstractScheduler {
+
+ private static instance: AbstractScheduler
+
+ protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.updateVideos
+
+ private isRunning = false
+
+ private constructor () {
+ super()
+ }
+
+ async execute () {
+ if (this.isRunning === true) return
+ this.isRunning = true
+
+ try {
+ await retryTransactionWrapper(this.updateVideos.bind(this))
+ } catch (err) {
+ logger.error('Cannot execute update videos scheduler.', { err })
+ } finally {
+ this.isRunning = false
+ }
+ }
+
+ private updateVideos () {
+ return sequelizeTypescript.transaction(async t => {
+ const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate(t)
+
+ for (const schedule of schedules) {
+ const video = schedule.Video
+ logger.info('Executing scheduled video update on %s.', video.uuid)
+
+ if (schedule.privacy) {
+ const oldPrivacy = video.privacy
+
+ video.privacy = schedule.privacy
+ await video.save({ transaction: t })
+
+ const isNewVideo = oldPrivacy === VideoPrivacy.PRIVATE
+ await federateVideoIfNeeded(video, isNewVideo, t)
+ }
+
+ await schedule.destroy({ transaction: t })
+ }
+ })
+ }
+
+ static get Instance () {
+ return this.instance || (this.instance = new this())
+ }
+}
import 'express-validator'
import { body, param, query } from 'express-validator/check'
import { UserRight, VideoPrivacy } from '../../../shared'
-import { isBooleanValid, isIdOrUUIDValid, isIdValid, isUUIDValid, toIntOrNull, toValueOrNull } from '../../helpers/custom-validators/misc'
import {
+ isBooleanValid,
+ isDateValid,
+ isIdOrUUIDValid,
+ isIdValid,
+ isUUIDValid,
+ toIntOrNull,
+ toValueOrNull
+} from '../../helpers/custom-validators/misc'
+import {
+ isScheduleVideoUpdatePrivacyValid,
isVideoAbuseReasonValid,
isVideoCategoryValid,
isVideoChannelOfAccountExist,
.custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
body('channelId')
.toInt()
- .custom(isIdValid)
- .withMessage('Should have correct video channel id'),
+ .custom(isIdValid).withMessage('Should have correct video channel id'),
+ body('scheduleUpdate.updateAt')
+ .optional()
+ .custom(isDateValid).withMessage('Should have a valid schedule update date'),
+ body('scheduleUpdate.privacy')
+ .optional()
+ .toInt()
+ .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
if (areValidationErrors(req, res)) return
if (areErrorsInVideoImageFiles(req, res)) return
+ if (areErrorsInScheduleUpdate(req, res)) return
const videoFile: Express.Multer.File = req.files['videofile'][0]
const user = res.locals.oauth.token.User
.optional()
.toInt()
.custom(isIdValid).withMessage('Should have correct video channel id'),
+ body('scheduleUpdate.updateAt')
+ .optional()
+ .custom(isDateValid).withMessage('Should have a valid schedule update date'),
+ body('scheduleUpdate.privacy')
+ .optional()
+ .toInt()
+ .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videosUpdate parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (areErrorsInVideoImageFiles(req, res)) return
+ if (areErrorsInScheduleUpdate(req, res)) return
if (!await isVideoExist(req.params.id, res)) return
const video = res.locals.video
const imageFile = req.files[ imageField ][ 0 ] as Express.Multer.File
if (imageFile.size > CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max) {
res.status(400)
- .send({ error: `The size of the ${imageField} is too big (>${CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max}).` })
+ .json({ error: `The size of the ${imageField} is too big (>${CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max}).` })
.end()
return true
}
return false
}
+
+function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
+ if (req.body.scheduleUpdate) {
+ if (!req.body.scheduleUpdate.updateAt) {
+ res.status(400)
+ .json({ error: 'Schedule update at is mandatory.' })
+ .end()
+
+ return true
+ }
+ }
+
+ return false
+}
--- /dev/null
+import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Sequelize, Table, UpdatedAt } from 'sequelize-typescript'
+import { ScopeNames as VideoScopeNames, VideoModel } from './video'
+import { VideoPrivacy } from '../../../shared/models/videos'
+import { Transaction } from 'sequelize'
+
+@Table({
+ tableName: 'scheduleVideoUpdate',
+ indexes: [
+ {
+ fields: [ 'videoId' ],
+ unique: true
+ },
+ {
+ fields: [ 'updateAt' ]
+ }
+ ]
+})
+export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
+
+ @AllowNull(false)
+ @Default(null)
+ @Column
+ updateAt: Date
+
+ @AllowNull(true)
+ @Default(null)
+ @Column
+ privacy: VideoPrivacy
+
+ @CreatedAt
+ createdAt: Date
+
+ @UpdatedAt
+ updatedAt: Date
+
+ @ForeignKey(() => VideoModel)
+ @Column
+ videoId: number
+
+ @BelongsTo(() => VideoModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ Video: VideoModel
+
+ static listVideosToUpdate (t: Transaction) {
+ const query = {
+ where: {
+ updateAt: {
+ [Sequelize.Op.lte]: new Date()
+ }
+ },
+ include: [
+ {
+ model: VideoModel.scope(
+ [
+ VideoScopeNames.WITH_FILES,
+ VideoScopeNames.WITH_ACCOUNT_DETAILS
+ ]
+ )
+ }
+ ],
+ transaction: t
+ }
+
+ return ScheduleVideoUpdateModel.findAll(query)
+ }
+
+}
Default,
ForeignKey,
HasMany,
+ HasOne,
IFindOptions,
Is,
IsInt,
isVideoLanguageValid,
isVideoLicenceValid,
isVideoNameValid,
- isVideoPrivacyValid, isVideoStateValid,
+ isVideoPrivacyValid,
+ isVideoStateValid,
isVideoSupportValid
} from '../../helpers/custom-validators/videos'
import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
VIDEO_EXT_MIMETYPE,
VIDEO_LANGUAGES,
VIDEO_LICENCES,
- VIDEO_PRIVACIES, VIDEO_STATES
+ VIDEO_PRIVACIES,
+ VIDEO_STATES
} from '../../initializers'
import {
getVideoCommentsActivityPubUrl,
import { VideoFileModel } from './video-file'
import { VideoShareModel } from './video-share'
import { VideoTagModel } from './video-tag'
+import { ScheduleVideoUpdateModel } from './schedule-video-update'
-enum ScopeNames {
+export enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
WITH_TAGS = 'WITH_TAGS',
})
VideoComments: VideoCommentModel[]
+ @HasOne(() => ScheduleVideoUpdateModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ ScheduleVideoUpdate: ScheduleVideoUpdateModel
+
@BeforeDestroy
static async sendDelete (instance: VideoModel, options) {
if (instance.isOwned()) {
required: true
}
]
+ },
+ {
+ model: ScheduleVideoUpdateModel,
+ required: false
}
]
}
toFormattedJSON (options?: {
additionalAttributes: {
state: boolean,
- waitTranscoding: boolean
+ waitTranscoding: boolean,
+ scheduledUpdate: boolean
}
}): Video {
const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
}
}
- if (options.additionalAttributes.waitTranscoding) videoObject.waitTranscoding = this.waitTranscoding
+ if (options.additionalAttributes.waitTranscoding) {
+ videoObject.waitTranscoding = this.waitTranscoding
+ }
+
+ if (options.additionalAttributes.scheduledUpdate && this.ScheduleVideoUpdate) {
+ videoObject.scheduledUpdate = {
+ updateAt: this.ScheduleVideoUpdate.updateAt,
+ privacy: this.ScheduleVideoUpdate.privacy || undefined
+ }
+ }
}
return videoObject
import './videos/video-comments'
import './users/users-multiple-servers'
import './server/handle-down'
+import './videos/video-schedule-update'
--- /dev/null
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { VideoPrivacy } from '../../../../shared/models/videos'
+import {
+ doubleFollow,
+ flushAndRunMultipleServers, getMyVideos,
+ getVideosList,
+ killallServers,
+ ServerInfo,
+ setAccessTokensToServers, updateVideo,
+ uploadVideo,
+ wait
+} from '../../utils'
+import { join } from 'path'
+import { waitJobs } from '../../utils/server/jobs'
+
+const expect = chai.expect
+
+function in10Seconds () {
+ const now = new Date()
+ now.setSeconds(now.getSeconds() + 10)
+
+ return now
+}
+
+describe('Test video update scheduler', function () {
+ let servers: ServerInfo[] = []
+ let video2UUID: string
+
+ before(async function () {
+ this.timeout(30000)
+
+ // Run servers
+ servers = await flushAndRunMultipleServers(2)
+
+ await setAccessTokensToServers(servers)
+
+ await doubleFollow(servers[0], servers[1])
+ })
+
+ it('Should upload a video and schedule an update in 10 seconds', async function () {
+ this.timeout(10000)
+
+ const videoAttributes = {
+ name: 'video 1',
+ privacy: VideoPrivacy.PRIVATE,
+ scheduleUpdate: {
+ updateAt: in10Seconds().toISOString(),
+ privacy: VideoPrivacy.PUBLIC
+ }
+ }
+
+ await uploadVideo(servers[0].url, servers[0].accessToken, videoAttributes)
+
+ await waitJobs(servers)
+ })
+
+ it('Should not list the video (in privacy mode)', async function () {
+ for (const server of servers) {
+ const res = await getVideosList(server.url)
+
+ expect(res.body.total).to.equal(0)
+ }
+ })
+
+ it('Should have my scheduled video in my account videos', async function () {
+ const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 5)
+ expect(res.body.total).to.equal(1)
+
+ const video = res.body.data[0]
+ expect(video.name).to.equal('video 1')
+ expect(video.privacy.id).to.equal(VideoPrivacy.PRIVATE)
+ expect(new Date(video.scheduledUpdate.updateAt)).to.be.above(new Date())
+ expect(video.scheduledUpdate.privacy).to.equal(VideoPrivacy.PUBLIC)
+ })
+
+ it('Should wait some seconds and have the video in public privacy', async function () {
+ this.timeout(20000)
+
+ await wait(10000)
+ await waitJobs(servers)
+
+ for (const server of servers) {
+ const res = await getVideosList(server.url)
+
+ expect(res.body.total).to.equal(1)
+ expect(res.body.data[0].name).to.equal('video 1')
+ }
+ })
+
+ it('Should upload a video without scheduling an update', async function () {
+ this.timeout(10000)
+
+ const videoAttributes = {
+ name: 'video 2',
+ privacy: VideoPrivacy.PRIVATE
+ }
+
+ const res = await uploadVideo(servers[0].url, servers[0].accessToken, videoAttributes)
+ video2UUID = res.body.video.uuid
+
+ await waitJobs(servers)
+ })
+
+ it('Should update a video by scheduling an update', async function () {
+ this.timeout(10000)
+
+ const videoAttributes = {
+ name: 'video 2 updated',
+ scheduleUpdate: {
+ updateAt: in10Seconds().toISOString(),
+ privacy: VideoPrivacy.PUBLIC
+ }
+ }
+
+ await updateVideo(servers[0].url, servers[0].accessToken, video2UUID, videoAttributes)
+ await waitJobs(servers)
+ })
+
+ it('Should not display the updated video', async function () {
+ for (const server of servers) {
+ const res = await getVideosList(server.url)
+
+ expect(res.body.total).to.equal(1)
+ }
+ })
+
+ it('Should have my scheduled updated video in my account videos', async function () {
+ const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 5)
+ expect(res.body.total).to.equal(2)
+
+ const video = res.body.data.find(v => v.uuid === video2UUID)
+ expect(video).not.to.be.undefined
+
+ expect(video.name).to.equal('video 2 updated')
+ expect(video.privacy.id).to.equal(VideoPrivacy.PRIVATE)
+
+ expect(new Date(video.scheduledUpdate.updateAt)).to.be.above(new Date())
+ expect(video.scheduledUpdate.privacy).to.equal(VideoPrivacy.PUBLIC)
+ })
+
+ it('Should wait some seconds and have the updated video in public privacy', async function () {
+ this.timeout(20000)
+
+ await wait(10000)
+ await waitJobs(servers)
+
+ for (const server of servers) {
+ const res = await getVideosList(server.url)
+
+ expect(res.body.total).to.equal(2)
+
+ const video = res.body.data.find(v => v.uuid === video2UUID)
+ expect(video).not.to.be.undefined
+ expect(video.name).to.equal('video 2 updated')
+ }
+ })
+
+ after(async function () {
+ killallServers(servers)
+ })
+})
fixture?: string
thumbnailfile?: string
previewfile?: string
+ scheduleUpdate?: {
+ updateAt: string
+ privacy?: VideoPrivacy
+ }
}
function getVideoCategories (url: string) {
req.attach('previewfile', buildAbsoluteFixturePath(attributes.previewfile))
}
+ if (attributes.scheduleUpdate) {
+ req.field('scheduleUpdate[updateAt]', attributes.scheduleUpdate.updateAt)
+
+ if (attributes.scheduleUpdate.privacy) {
+ req.field('scheduleUpdate[privacy]', attributes.scheduleUpdate.privacy)
+ }
+ }
+
return req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture))
.expect(specialStatus)
}
if (attributes.tags) body['tags'] = attributes.tags
if (attributes.privacy) body['privacy'] = attributes.privacy
if (attributes.channelId) body['channelId'] = attributes.channelId
+ if (attributes.scheduleUpdate) body['scheduleUpdate'] = attributes.scheduleUpdate
// Upload request
if (attributes.thumbnailfile || attributes.previewfile) {
tags?: string[]
commentsEnabled?: boolean
privacy: VideoPrivacy
+ scheduleUpdate?: {
+ updateAt: Date
+ privacy?: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED
+ }
}
channelId?: number
thumbnailfile?: Blob
previewfile?: Blob
+ scheduleUpdate?: {
+ updateAt: Date
+ privacy?: VideoPrivacy
+ }
}
waitTranscoding?: boolean
state?: VideoConstant<VideoState>
+ scheduledUpdate?: {
+ updateAt: Date | string
+ privacy?: VideoPrivacy
+ }
account: {
id: number