Add ability for uploaders to schedule video update

This commit is contained in:
Chocobozzz 2018-06-14 18:06:56 +02:00
parent bf079b7bfd
commit 2baea0c77c
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
21 changed files with 469 additions and 22 deletions

View File

@ -83,7 +83,6 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
// Scroll to the highlighted thread // Scroll to the highlighted thread
setTimeout(() => { setTimeout(() => {
// -60 because of the fixed header // -60 because of the fixed header
console.log(this.commentHighlightBlock.nativeElement.offsetTop)
const scrollY = this.commentHighlightBlock.nativeElement.offsetTop - 60 const scrollY = this.commentHighlightBlock.nativeElement.offsetTop - 60
window.scroll(0, scrollY) window.scroll(0, scrollY)
}, 500) }, 500)

View File

@ -1,4 +1,6 @@
// FIXME: https://github.com/nodejs/node/pull/16853 // FIXME: https://github.com/nodejs/node/pull/16853
import { ScheduleVideoUpdateModel } from './server/models/video/schedule-video-update'
require('tls').DEFAULT_ECDH_CURVE = 'auto' require('tls').DEFAULT_ECDH_CURVE = 'auto'
import { isTestInstance } from './server/helpers/core-utils' import { isTestInstance } from './server/helpers/core-utils'
@ -28,7 +30,7 @@ import { checkMissedConfig, checkFFmpeg, checkConfig } from './server/initialize
// Do not use barrels because we don't want to load all modules here (we need to initialize database first) // 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 { 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() const missed = checkMissedConfig()
if (missed.length !== 0) { if (missed.length !== 0) {
@ -80,6 +82,7 @@ import {
import { Redis } from './server/lib/redis' import { Redis } from './server/lib/redis'
import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler' import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler'
import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler' import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
import { UpdateVideosScheduler } from './server/lib/schedulers/update-videos-scheduler'
// ----------- Command line ----------- // ----------- Command line -----------
@ -200,6 +203,7 @@ async function startApplication () {
// Enable Schedulers // Enable Schedulers
BadActorFollowScheduler.Instance.enable() BadActorFollowScheduler.Instance.enable()
RemoveOldJobsScheduler.Instance.enable() RemoveOldJobsScheduler.Instance.enable()
UpdateVideosScheduler.Instance.enable()
// Redis initialization // Redis initialization
Redis.Instance.init() Redis.Instance.init()

View File

@ -174,7 +174,11 @@ async function getUserVideos (req: express.Request, res: express.Response, next:
false // Display my NSFW videos 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 })) return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes }))
} }

View File

@ -52,6 +52,7 @@ import { rateVideoRouter } from './rate'
import { VideoFilter } from '../../../../shared/models/videos/video-query.type' import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
const videosRouter = express.Router() const videosRouter = express.Router()
@ -231,6 +232,7 @@ async function addVideo (req: express.Request, res: express.Response) {
video.VideoFiles = [ videoFile ] video.VideoFiles = [ videoFile ]
// Create tags
if (videoInfo.tags !== undefined) { if (videoInfo.tags !== undefined) {
const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t) const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
@ -238,6 +240,15 @@ async function addVideo (req: express.Request, res: express.Response) {
video.Tags = tagInstances 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) await federateVideoIfNeeded(video, true, t)
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid) logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
@ -324,6 +335,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
if (wasPrivateVideo === false) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t) 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 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE
await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo) await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo)
}) })

View File

@ -3,7 +3,7 @@ import 'express-validator'
import { values } from 'lodash' import { values } from 'lodash'
import 'multer' import 'multer'
import * as validator from 'validator' import * as validator from 'validator'
import { UserRight, VideoRateType } from '../../../shared' import { UserRight, VideoPrivacy, VideoRateType } from '../../../shared'
import { import {
CONSTRAINTS_FIELDS, CONSTRAINTS_FIELDS,
VIDEO_CATEGORIES, VIDEO_CATEGORIES,
@ -98,10 +98,18 @@ function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } |
return isFileValid(files, videoImageTypesRegex, field, true) return isFileValid(files, videoImageTypesRegex, field, true)
} }
function isVideoPrivacyValid (value: string) { function isVideoPrivacyValid (value: number) {
return validator.isInt(value + '') && VIDEO_PRIVACIES[ value ] !== undefined 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) { function isVideoFileInfoHashValid (value: string) {
return exists(value) && validator.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.INFO_HASH) return exists(value) && validator.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.INFO_HASH)
} }
@ -174,6 +182,7 @@ export {
isVideoFileInfoHashValid, isVideoFileInfoHashValid,
isVideoNameValid, isVideoNameValid,
isVideoTagsValid, isVideoTagsValid,
isScheduleVideoUpdatePrivacyValid,
isVideoAbuseReasonValid, isVideoAbuseReasonValid,
isVideoFile, isVideoFile,
isVideoStateValid, isVideoStateValid,

View File

@ -21,12 +21,16 @@ function retryTransactionWrapper <T, A> (
arg1: A arg1: A
): Promise<T> ): Promise<T>
function retryTransactionWrapper <T> (
functionToRetry: () => Promise<T> | Bluebird<T>
): Promise<T>
function retryTransactionWrapper <T> ( function retryTransactionWrapper <T> (
functionToRetry: (...args: any[]) => Promise<T> | Bluebird<T>, functionToRetry: (...args: any[]) => Promise<T> | Bluebird<T>,
...args: any[] ...args: any[]
): Promise<T> { ): Promise<T> {
return transactionRetryer<T>(callback => { return transactionRetryer<T>(callback => {
functionToRetry.apply(this, args) functionToRetry.apply(null, args)
.then((result: T) => callback(null, result)) .then((result: T) => callback(null, result))
.catch(err => callback(err)) .catch(err => callback(err))
}) })

View File

@ -8,6 +8,8 @@ import { VideoPrivacy } from '../../shared/models/videos'
import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
import { invert } from 'lodash' 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 // Use a variable to reload the configuration if we need
let config: IConfig = require('config') let config: IConfig = require('config')
@ -94,7 +96,11 @@ const JOB_REQUEST_TTL = 60000 * 10 // 10 minutes
const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 // 2 days const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 // 2 days
// 1 hour // 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
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -460,7 +466,10 @@ if (isTestInstance() === true) {
CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB 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 VIDEO_VIEW_LIFETIME = 1000 // 1 second
JOB_ATTEMPTS['email'] = 1 JOB_ATTEMPTS['email'] = 1
@ -513,7 +522,7 @@ export {
JOB_REQUEST_TTL, JOB_REQUEST_TTL,
USER_PASSWORD_RESET_LIFETIME, USER_PASSWORD_RESET_LIFETIME,
IMAGE_MIMETYPE_EXT, IMAGE_MIMETYPE_EXT,
SCHEDULER_INTERVAL, SCHEDULER_INTERVALS_MS,
STATIC_DOWNLOAD_PATHS, STATIC_DOWNLOAD_PATHS,
RATES_LIMIT, RATES_LIMIT,
VIDEO_EXT_MIMETYPE, VIDEO_EXT_MIMETYPE,

View File

@ -22,6 +22,7 @@ import { VideoFileModel } from '../models/video/video-file'
import { VideoShareModel } from '../models/video/video-share' import { VideoShareModel } from '../models/video/video-share'
import { VideoTagModel } from '../models/video/video-tag' import { VideoTagModel } from '../models/video/video-tag'
import { CONFIG } from './constants' import { CONFIG } from './constants'
import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -73,7 +74,8 @@ async function initDatabaseModels (silent: boolean) {
VideoBlacklistModel, VideoBlacklistModel,
VideoTagModel, VideoTagModel,
VideoModel, VideoModel,
VideoCommentModel VideoCommentModel,
ScheduleVideoUpdateModel
]) ])
if (!silent) logger.info('Database %s is ready.', dbname) if (!silent) logger.info('Database %s is ready.', dbname)

View File

@ -1,11 +1,13 @@
import { SCHEDULER_INTERVAL } from '../../initializers'
export abstract class AbstractScheduler { export abstract class AbstractScheduler {
protected abstract schedulerIntervalMs: number
private interval: NodeJS.Timer private interval: NodeJS.Timer
enable () { 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 () { disable () {

View File

@ -2,11 +2,14 @@ import { isTestInstance } from '../../helpers/core-utils'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { ActorFollowModel } from '../../models/activitypub/actor-follow' import { ActorFollowModel } from '../../models/activitypub/actor-follow'
import { AbstractScheduler } from './abstract-scheduler' import { AbstractScheduler } from './abstract-scheduler'
import { SCHEDULER_INTERVALS_MS } from '../../initializers'
export class BadActorFollowScheduler extends AbstractScheduler { export class BadActorFollowScheduler extends AbstractScheduler {
private static instance: AbstractScheduler private static instance: AbstractScheduler
protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.badActorFollow
private constructor () { private constructor () {
super() super()
} }

View File

@ -2,11 +2,14 @@ import { isTestInstance } from '../../helpers/core-utils'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { JobQueue } from '../job-queue' import { JobQueue } from '../job-queue'
import { AbstractScheduler } from './abstract-scheduler' import { AbstractScheduler } from './abstract-scheduler'
import { SCHEDULER_INTERVALS_MS } from '../../initializers'
export class RemoveOldJobsScheduler extends AbstractScheduler { export class RemoveOldJobsScheduler extends AbstractScheduler {
private static instance: AbstractScheduler private static instance: AbstractScheduler
protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.removeOldJobs
private constructor () { private constructor () {
super() super()
} }

View File

@ -0,0 +1,62 @@
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())
}
}

View File

@ -2,8 +2,17 @@ import * as express from 'express'
import 'express-validator' import 'express-validator'
import { body, param, query } from 'express-validator/check' import { body, param, query } from 'express-validator/check'
import { UserRight, VideoPrivacy } from '../../../shared' import { UserRight, VideoPrivacy } from '../../../shared'
import { isBooleanValid, isIdOrUUIDValid, isIdValid, isUUIDValid, toIntOrNull, toValueOrNull } from '../../helpers/custom-validators/misc'
import { import {
isBooleanValid,
isDateValid,
isIdOrUUIDValid,
isIdValid,
isUUIDValid,
toIntOrNull,
toValueOrNull
} from '../../helpers/custom-validators/misc'
import {
isScheduleVideoUpdatePrivacyValid,
isVideoAbuseReasonValid, isVideoAbuseReasonValid,
isVideoCategoryValid, isVideoCategoryValid,
isVideoChannelOfAccountExist, isVideoChannelOfAccountExist,
@ -84,14 +93,21 @@ const videosAddValidator = [
.custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'), .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
body('channelId') body('channelId')
.toInt() .toInt()
.custom(isIdValid) .custom(isIdValid).withMessage('Should have correct video channel id'),
.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) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (areErrorsInVideoImageFiles(req, res)) return if (areErrorsInVideoImageFiles(req, res)) return
if (areErrorsInScheduleUpdate(req, res)) return
const videoFile: Express.Multer.File = req.files['videofile'][0] const videoFile: Express.Multer.File = req.files['videofile'][0]
const user = res.locals.oauth.token.User const user = res.locals.oauth.token.User
@ -183,12 +199,20 @@ const videosUpdateValidator = [
.optional() .optional()
.toInt() .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) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videosUpdate parameters', { parameters: req.body }) logger.debug('Checking videosUpdate parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (areErrorsInVideoImageFiles(req, res)) return if (areErrorsInVideoImageFiles(req, res)) return
if (areErrorsInScheduleUpdate(req, res)) return
if (!await isVideoExist(req.params.id, res)) return if (!await isVideoExist(req.params.id, res)) return
const video = res.locals.video const video = res.locals.video
@ -371,7 +395,7 @@ function areErrorsInVideoImageFiles (req: express.Request, res: express.Response
const imageFile = req.files[ imageField ][ 0 ] as Express.Multer.File const imageFile = req.files[ imageField ][ 0 ] as Express.Multer.File
if (imageFile.size > CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max) { if (imageFile.size > CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max) {
res.status(400) 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() .end()
return true return true
} }
@ -379,3 +403,17 @@ function areErrorsInVideoImageFiles (req: express.Request, res: express.Response
return false 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
}

View File

@ -0,0 +1,71 @@
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)
}
}

View File

@ -15,6 +15,7 @@ import {
Default, Default,
ForeignKey, ForeignKey,
HasMany, HasMany,
HasOne,
IFindOptions, IFindOptions,
Is, Is,
IsInt, IsInt,
@ -47,7 +48,8 @@ import {
isVideoLanguageValid, isVideoLanguageValid,
isVideoLicenceValid, isVideoLicenceValid,
isVideoNameValid, isVideoNameValid,
isVideoPrivacyValid, isVideoStateValid, isVideoPrivacyValid,
isVideoStateValid,
isVideoSupportValid isVideoSupportValid
} from '../../helpers/custom-validators/videos' } from '../../helpers/custom-validators/videos'
import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
@ -66,7 +68,8 @@ import {
VIDEO_EXT_MIMETYPE, VIDEO_EXT_MIMETYPE,
VIDEO_LANGUAGES, VIDEO_LANGUAGES,
VIDEO_LICENCES, VIDEO_LICENCES,
VIDEO_PRIVACIES, VIDEO_STATES VIDEO_PRIVACIES,
VIDEO_STATES
} from '../../initializers' } from '../../initializers'
import { import {
getVideoCommentsActivityPubUrl, getVideoCommentsActivityPubUrl,
@ -88,8 +91,9 @@ import { VideoCommentModel } from './video-comment'
import { VideoFileModel } from './video-file' import { VideoFileModel } from './video-file'
import { VideoShareModel } from './video-share' import { VideoShareModel } from './video-share'
import { VideoTagModel } from './video-tag' import { VideoTagModel } from './video-tag'
import { ScheduleVideoUpdateModel } from './schedule-video-update'
enum ScopeNames { export enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
WITH_TAGS = 'WITH_TAGS', WITH_TAGS = 'WITH_TAGS',
@ -495,6 +499,15 @@ export class VideoModel extends Model<VideoModel> {
}) })
VideoComments: VideoCommentModel[] VideoComments: VideoCommentModel[]
@HasOne(() => ScheduleVideoUpdateModel, {
foreignKey: {
name: 'videoId',
allowNull: false
},
onDelete: 'cascade'
})
ScheduleVideoUpdate: ScheduleVideoUpdateModel
@BeforeDestroy @BeforeDestroy
static async sendDelete (instance: VideoModel, options) { static async sendDelete (instance: VideoModel, options) {
if (instance.isOwned()) { if (instance.isOwned()) {
@ -673,6 +686,10 @@ export class VideoModel extends Model<VideoModel> {
required: true required: true
} }
] ]
},
{
model: ScheduleVideoUpdateModel,
required: false
} }
] ]
} }
@ -1006,7 +1023,8 @@ export class VideoModel extends Model<VideoModel> {
toFormattedJSON (options?: { toFormattedJSON (options?: {
additionalAttributes: { additionalAttributes: {
state: boolean, state: boolean,
waitTranscoding: boolean waitTranscoding: boolean,
scheduledUpdate: boolean
} }
}): Video { }): Video {
const formattedAccount = this.VideoChannel.Account.toFormattedJSON() const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
@ -1073,7 +1091,16 @@ export class VideoModel extends Model<VideoModel> {
} }
} }
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 return videoObject

View File

@ -6,3 +6,4 @@ import './server/jobs'
import './videos/video-comments' import './videos/video-comments'
import './users/users-multiple-servers' import './users/users-multiple-servers'
import './server/handle-down' import './server/handle-down'
import './videos/video-schedule-update'

View File

@ -0,0 +1,164 @@
/* 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)
})
})

View File

@ -35,6 +35,10 @@ type VideoAttributes = {
fixture?: string fixture?: string
thumbnailfile?: string thumbnailfile?: string
previewfile?: string previewfile?: string
scheduleUpdate?: {
updateAt: string
privacy?: VideoPrivacy
}
} }
function getVideoCategories (url: string) { function getVideoCategories (url: string) {
@ -371,6 +375,14 @@ async function uploadVideo (url: string, accessToken: string, videoAttributesArg
req.attach('previewfile', buildAbsoluteFixturePath(attributes.previewfile)) 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)) return req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture))
.expect(specialStatus) .expect(specialStatus)
} }
@ -389,6 +401,7 @@ function updateVideo (url: string, accessToken: string, id: number | string, att
if (attributes.tags) body['tags'] = attributes.tags if (attributes.tags) body['tags'] = attributes.tags
if (attributes.privacy) body['privacy'] = attributes.privacy if (attributes.privacy) body['privacy'] = attributes.privacy
if (attributes.channelId) body['channelId'] = attributes.channelId if (attributes.channelId) body['channelId'] = attributes.channelId
if (attributes.scheduleUpdate) body['scheduleUpdate'] = attributes.scheduleUpdate
// Upload request // Upload request
if (attributes.thumbnailfile || attributes.previewfile) { if (attributes.thumbnailfile || attributes.previewfile) {

View File

@ -13,4 +13,8 @@ export interface VideoCreate {
tags?: string[] tags?: string[]
commentsEnabled?: boolean commentsEnabled?: boolean
privacy: VideoPrivacy privacy: VideoPrivacy
scheduleUpdate?: {
updateAt: Date
privacy?: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED
}
} }

View File

@ -15,4 +15,8 @@ export interface VideoUpdate {
channelId?: number channelId?: number
thumbnailfile?: Blob thumbnailfile?: Blob
previewfile?: Blob previewfile?: Blob
scheduleUpdate?: {
updateAt: Date
privacy?: VideoPrivacy
}
} }

View File

@ -43,6 +43,10 @@ export interface Video {
waitTranscoding?: boolean waitTranscoding?: boolean
state?: VideoConstant<VideoState> state?: VideoConstant<VideoState>
scheduledUpdate?: {
updateAt: Date | string
privacy?: VideoPrivacy
}
account: { account: {
id: number id: number