From 2baea0c77cc765f7cbca9c9a2f4272268892a35c Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 14 Jun 2018 18:06:56 +0200 Subject: [PATCH] Add ability for uploaders to schedule video update --- .../comment/video-comments.component.ts | 1 - server.ts | 6 +- server/controllers/api/users.ts | 6 +- server/controllers/api/videos/index.ts | 20 +++ server/helpers/custom-validators/videos.ts | 13 +- server/helpers/database-utils.ts | 6 +- server/initializers/constants.ts | 15 +- server/initializers/database.ts | 4 +- server/lib/schedulers/abstract-scheduler.ts | 8 +- .../schedulers/bad-actor-follow-scheduler.ts | 3 + .../schedulers/remove-old-jobs-scheduler.ts | 3 + .../lib/schedulers/update-videos-scheduler.ts | 62 +++++++ server/middlewares/validators/videos.ts | 46 ++++- server/models/video/schedule-video-update.ts | 71 ++++++++ server/models/video/video.ts | 37 +++- server/tests/api/index-slow.ts | 1 + .../tests/api/videos/video-schedule-update.ts | 164 ++++++++++++++++++ server/tests/utils/videos/videos.ts | 13 ++ shared/models/videos/video-create.model.ts | 4 + shared/models/videos/video-update.model.ts | 4 + shared/models/videos/video.model.ts | 4 + 21 files changed, 469 insertions(+), 22 deletions(-) create mode 100644 server/lib/schedulers/update-videos-scheduler.ts create mode 100644 server/models/video/schedule-video-update.ts create mode 100644 server/tests/api/videos/video-schedule-update.ts diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts index 274c32d31..3743cd228 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts @@ -83,7 +83,6 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { // 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) diff --git a/server.ts b/server.ts index c0e679b02..ef89ff5f6 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,6 @@ // 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' @@ -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) 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) { @@ -80,6 +82,7 @@ import { 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 ----------- @@ -200,6 +203,7 @@ async function startApplication () { // Enable Schedulers BadActorFollowScheduler.Instance.enable() RemoveOldJobsScheduler.Instance.enable() + UpdateVideosScheduler.Instance.enable() // Redis initialization Redis.Instance.init() diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts index 0aeb77964..891056912 100644 --- a/server/controllers/api/users.ts +++ b/server/controllers/api/users.ts @@ -174,7 +174,11 @@ async function getUserVideos (req: express.Request, res: express.Response, next: 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 })) } diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 78963d89b..79ca4699f 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -52,6 +52,7 @@ import { rateVideoRouter } from './rate' 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() @@ -231,6 +232,7 @@ async function addVideo (req: express.Request, res: express.Response) { video.VideoFiles = [ videoFile ] + // Create tags if (videoInfo.tags !== undefined) { const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t) @@ -238,6 +240,15 @@ async function addVideo (req: express.Request, res: express.Response) { 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) @@ -324,6 +335,15 @@ async function updateVideo (req: express.Request, res: express.Response) { 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) }) diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 8496e679a..a227136ac 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -3,7 +3,7 @@ import 'express-validator' 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, @@ -98,10 +98,18 @@ function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | 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) } @@ -174,6 +182,7 @@ export { isVideoFileInfoHashValid, isVideoNameValid, isVideoTagsValid, + isScheduleVideoUpdatePrivacyValid, isVideoAbuseReasonValid, isVideoFile, isVideoStateValid, diff --git a/server/helpers/database-utils.ts b/server/helpers/database-utils.ts index 9b861a88c..ededa7901 100644 --- a/server/helpers/database-utils.ts +++ b/server/helpers/database-utils.ts @@ -21,12 +21,16 @@ function retryTransactionWrapper ( arg1: A ): Promise +function retryTransactionWrapper ( + functionToRetry: () => Promise | Bluebird +): Promise + function retryTransactionWrapper ( functionToRetry: (...args: any[]) => Promise | Bluebird, ...args: any[] ): Promise { return transactionRetryer(callback => { - functionToRetry.apply(this, args) + functionToRetry.apply(null, args) .then((result: T) => callback(null, result)) .catch(err => callback(err)) }) diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 65f89ff7f..164378505 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -8,6 +8,8 @@ import { VideoPrivacy } from '../../shared/models/videos' 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') @@ -94,7 +96,11 @@ const JOB_REQUEST_TTL = 60000 * 10 // 10 minutes 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 +} // --------------------------------------------------------------------------- @@ -460,7 +466,10 @@ if (isTestInstance() === true) { 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 @@ -513,7 +522,7 @@ export { JOB_REQUEST_TTL, USER_PASSWORD_RESET_LIFETIME, IMAGE_MIMETYPE_EXT, - SCHEDULER_INTERVAL, + SCHEDULER_INTERVALS_MS, STATIC_DOWNLOAD_PATHS, RATES_LIMIT, VIDEO_EXT_MIMETYPE, diff --git a/server/initializers/database.ts b/server/initializers/database.ts index b537ee59a..4d90c90fc 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -22,6 +22,7 @@ import { VideoFileModel } from '../models/video/video-file' 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 @@ -73,7 +74,8 @@ async function initDatabaseModels (silent: boolean) { VideoBlacklistModel, VideoTagModel, VideoModel, - VideoCommentModel + VideoCommentModel, + ScheduleVideoUpdateModel ]) if (!silent) logger.info('Database %s is ready.', dbname) diff --git a/server/lib/schedulers/abstract-scheduler.ts b/server/lib/schedulers/abstract-scheduler.ts index 473544ddf..6ec5e3360 100644 --- a/server/lib/schedulers/abstract-scheduler.ts +++ b/server/lib/schedulers/abstract-scheduler.ts @@ -1,11 +1,13 @@ -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 () { diff --git a/server/lib/schedulers/bad-actor-follow-scheduler.ts b/server/lib/schedulers/bad-actor-follow-scheduler.ts index 121f7145e..617149aaf 100644 --- a/server/lib/schedulers/bad-actor-follow-scheduler.ts +++ b/server/lib/schedulers/bad-actor-follow-scheduler.ts @@ -2,11 +2,14 @@ import { isTestInstance } from '../../helpers/core-utils' 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() } diff --git a/server/lib/schedulers/remove-old-jobs-scheduler.ts b/server/lib/schedulers/remove-old-jobs-scheduler.ts index 0e8ad1554..a29a6b800 100644 --- a/server/lib/schedulers/remove-old-jobs-scheduler.ts +++ b/server/lib/schedulers/remove-old-jobs-scheduler.ts @@ -2,11 +2,14 @@ import { isTestInstance } from '../../helpers/core-utils' 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() } diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts new file mode 100644 index 000000000..d123c3ceb --- /dev/null +++ b/server/lib/schedulers/update-videos-scheduler.ts @@ -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()) + } +} diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts index e181aebdb..9fe5a253b 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos.ts @@ -2,8 +2,17 @@ import * as express from 'express' 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, @@ -84,14 +93,21 @@ const videosAddValidator = [ .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 @@ -183,12 +199,20 @@ const videosUpdateValidator = [ .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 @@ -371,7 +395,7 @@ function areErrorsInVideoImageFiles (req: express.Request, res: express.Response 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 } @@ -379,3 +403,17 @@ function areErrorsInVideoImageFiles (req: express.Request, res: express.Response 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 +} diff --git a/server/models/video/schedule-video-update.ts b/server/models/video/schedule-video-update.ts new file mode 100644 index 000000000..d4e37beb5 --- /dev/null +++ b/server/models/video/schedule-video-update.ts @@ -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 { + + @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) + } + +} diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 59c378efa..440f4d171 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -15,6 +15,7 @@ import { Default, ForeignKey, HasMany, + HasOne, IFindOptions, Is, IsInt, @@ -47,7 +48,8 @@ import { isVideoLanguageValid, isVideoLicenceValid, isVideoNameValid, - isVideoPrivacyValid, isVideoStateValid, + isVideoPrivacyValid, + isVideoStateValid, isVideoSupportValid } from '../../helpers/custom-validators/videos' import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' @@ -66,7 +68,8 @@ import { VIDEO_EXT_MIMETYPE, VIDEO_LANGUAGES, VIDEO_LICENCES, - VIDEO_PRIVACIES, VIDEO_STATES + VIDEO_PRIVACIES, + VIDEO_STATES } from '../../initializers' import { getVideoCommentsActivityPubUrl, @@ -88,8 +91,9 @@ import { VideoCommentModel } from './video-comment' 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', @@ -495,6 +499,15 @@ export class VideoModel extends Model { }) VideoComments: VideoCommentModel[] + @HasOne(() => ScheduleVideoUpdateModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + ScheduleVideoUpdate: ScheduleVideoUpdateModel + @BeforeDestroy static async sendDelete (instance: VideoModel, options) { if (instance.isOwned()) { @@ -673,6 +686,10 @@ export class VideoModel extends Model { required: true } ] + }, + { + model: ScheduleVideoUpdateModel, + required: false } ] } @@ -1006,7 +1023,8 @@ export class VideoModel extends Model { toFormattedJSON (options?: { additionalAttributes: { state: boolean, - waitTranscoding: boolean + waitTranscoding: boolean, + scheduledUpdate: boolean } }): Video { const formattedAccount = this.VideoChannel.Account.toFormattedJSON() @@ -1073,7 +1091,16 @@ export class VideoModel extends Model { } } - 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 diff --git a/server/tests/api/index-slow.ts b/server/tests/api/index-slow.ts index cde546856..d987442b3 100644 --- a/server/tests/api/index-slow.ts +++ b/server/tests/api/index-slow.ts @@ -6,3 +6,4 @@ import './server/jobs' import './videos/video-comments' import './users/users-multiple-servers' import './server/handle-down' +import './videos/video-schedule-update' diff --git a/server/tests/api/videos/video-schedule-update.ts b/server/tests/api/videos/video-schedule-update.ts new file mode 100644 index 000000000..8b87ea855 --- /dev/null +++ b/server/tests/api/videos/video-schedule-update.ts @@ -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) + }) +}) diff --git a/server/tests/utils/videos/videos.ts b/server/tests/utils/videos/videos.ts index 2c1d20ef1..4f7ce6d6b 100644 --- a/server/tests/utils/videos/videos.ts +++ b/server/tests/utils/videos/videos.ts @@ -35,6 +35,10 @@ type VideoAttributes = { fixture?: string thumbnailfile?: string previewfile?: string + scheduleUpdate?: { + updateAt: string + privacy?: VideoPrivacy + } } function getVideoCategories (url: string) { @@ -371,6 +375,14 @@ async function uploadVideo (url: string, accessToken: string, videoAttributesArg 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) } @@ -389,6 +401,7 @@ function updateVideo (url: string, accessToken: string, id: number | string, att 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) { diff --git a/shared/models/videos/video-create.model.ts b/shared/models/videos/video-create.model.ts index 2a1f622f6..531eafe54 100644 --- a/shared/models/videos/video-create.model.ts +++ b/shared/models/videos/video-create.model.ts @@ -13,4 +13,8 @@ export interface VideoCreate { tags?: string[] commentsEnabled?: boolean privacy: VideoPrivacy + scheduleUpdate?: { + updateAt: Date + privacy?: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED + } } diff --git a/shared/models/videos/video-update.model.ts b/shared/models/videos/video-update.model.ts index 681b00b18..fc0df6810 100644 --- a/shared/models/videos/video-update.model.ts +++ b/shared/models/videos/video-update.model.ts @@ -15,4 +15,8 @@ export interface VideoUpdate { channelId?: number thumbnailfile?: Blob previewfile?: Blob + scheduleUpdate?: { + updateAt: Date + privacy?: VideoPrivacy + } } diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index 857ca1fd9..676354ce3 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts @@ -43,6 +43,10 @@ export interface Video { waitTranscoding?: boolean state?: VideoConstant + scheduledUpdate?: { + updateAt: Date | string + privacy?: VideoPrivacy + } account: { id: number