aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2018-06-14 18:06:56 +0200
committerChocobozzz <me@florianbigard.com>2018-06-14 18:06:56 +0200
commit2baea0c77cc765f7cbca9c9a2f4272268892a35c (patch)
tree47b1be5535439409a97eb80c0222c9c821b83dae /server
parentbf079b7bfd7f0fb75ceb28e333bb4b74d8840dd4 (diff)
downloadPeerTube-2baea0c77cc765f7cbca9c9a2f4272268892a35c.tar.gz
PeerTube-2baea0c77cc765f7cbca9c9a2f4272268892a35c.tar.zst
PeerTube-2baea0c77cc765f7cbca9c9a2f4272268892a35c.zip
Add ability for uploaders to schedule video update
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/users.ts6
-rw-r--r--server/controllers/api/videos/index.ts20
-rw-r--r--server/helpers/custom-validators/videos.ts13
-rw-r--r--server/helpers/database-utils.ts6
-rw-r--r--server/initializers/constants.ts15
-rw-r--r--server/initializers/database.ts4
-rw-r--r--server/lib/schedulers/abstract-scheduler.ts8
-rw-r--r--server/lib/schedulers/bad-actor-follow-scheduler.ts3
-rw-r--r--server/lib/schedulers/remove-old-jobs-scheduler.ts3
-rw-r--r--server/lib/schedulers/update-videos-scheduler.ts62
-rw-r--r--server/middlewares/validators/videos.ts46
-rw-r--r--server/models/video/schedule-video-update.ts71
-rw-r--r--server/models/video/video.ts37
-rw-r--r--server/tests/api/index-slow.ts1
-rw-r--r--server/tests/api/videos/video-schedule-update.ts164
-rw-r--r--server/tests/utils/videos/videos.ts13
16 files changed, 452 insertions, 20 deletions
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:
174 false // Display my NSFW videos 174 false // Display my NSFW videos
175 ) 175 )
176 176
177 const additionalAttributes = { waitTranscoding: true, state: true } 177 const additionalAttributes = {
178 waitTranscoding: true,
179 state: true,
180 scheduledUpdate: true
181 }
178 return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes })) 182 return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes }))
179} 183}
180 184
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'
52import { VideoFilter } from '../../../../shared/models/videos/video-query.type' 52import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
53import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' 53import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
54import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' 54import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
55import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
55 56
56const videosRouter = express.Router() 57const videosRouter = express.Router()
57 58
@@ -231,6 +232,7 @@ async function addVideo (req: express.Request, res: express.Response) {
231 232
232 video.VideoFiles = [ videoFile ] 233 video.VideoFiles = [ videoFile ]
233 234
235 // Create tags
234 if (videoInfo.tags !== undefined) { 236 if (videoInfo.tags !== undefined) {
235 const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t) 237 const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
236 238
@@ -238,6 +240,15 @@ async function addVideo (req: express.Request, res: express.Response) {
238 video.Tags = tagInstances 240 video.Tags = tagInstances
239 } 241 }
240 242
243 // Schedule an update in the future?
244 if (videoInfo.scheduleUpdate) {
245 await ScheduleVideoUpdateModel.create({
246 videoId: video.id,
247 updateAt: videoInfo.scheduleUpdate.updateAt,
248 privacy: videoInfo.scheduleUpdate.privacy || null
249 }, { transaction: t })
250 }
251
241 await federateVideoIfNeeded(video, true, t) 252 await federateVideoIfNeeded(video, true, t)
242 253
243 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid) 254 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) {
324 if (wasPrivateVideo === false) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t) 335 if (wasPrivateVideo === false) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
325 } 336 }
326 337
338 // Schedule an update in the future?
339 if (videoInfoToUpdate.scheduleUpdate) {
340 await ScheduleVideoUpdateModel.upsert({
341 videoId: videoInstanceUpdated.id,
342 updateAt: videoInfoToUpdate.scheduleUpdate.updateAt,
343 privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
344 }, { transaction: t })
345 }
346
327 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE 347 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE
328 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo) 348 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo)
329 }) 349 })
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'
3import { values } from 'lodash' 3import { values } from 'lodash'
4import 'multer' 4import 'multer'
5import * as validator from 'validator' 5import * as validator from 'validator'
6import { UserRight, VideoRateType } from '../../../shared' 6import { UserRight, VideoPrivacy, VideoRateType } from '../../../shared'
7import { 7import {
8 CONSTRAINTS_FIELDS, 8 CONSTRAINTS_FIELDS,
9 VIDEO_CATEGORIES, 9 VIDEO_CATEGORIES,
@@ -98,10 +98,18 @@ function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } |
98 return isFileValid(files, videoImageTypesRegex, field, true) 98 return isFileValid(files, videoImageTypesRegex, field, true)
99} 99}
100 100
101function isVideoPrivacyValid (value: string) { 101function isVideoPrivacyValid (value: number) {
102 return validator.isInt(value + '') && VIDEO_PRIVACIES[ value ] !== undefined 102 return validator.isInt(value + '') && VIDEO_PRIVACIES[ value ] !== undefined
103} 103}
104 104
105function isScheduleVideoUpdatePrivacyValid (value: number) {
106 return validator.isInt(value + '') &&
107 (
108 value === VideoPrivacy.UNLISTED ||
109 value === VideoPrivacy.PUBLIC
110 )
111}
112
105function isVideoFileInfoHashValid (value: string) { 113function isVideoFileInfoHashValid (value: string) {
106 return exists(value) && validator.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.INFO_HASH) 114 return exists(value) && validator.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.INFO_HASH)
107} 115}
@@ -174,6 +182,7 @@ export {
174 isVideoFileInfoHashValid, 182 isVideoFileInfoHashValid,
175 isVideoNameValid, 183 isVideoNameValid,
176 isVideoTagsValid, 184 isVideoTagsValid,
185 isScheduleVideoUpdatePrivacyValid,
177 isVideoAbuseReasonValid, 186 isVideoAbuseReasonValid,
178 isVideoFile, 187 isVideoFile,
179 isVideoStateValid, 188 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
@@ -22,11 +22,15 @@ function retryTransactionWrapper <T, A> (
22): Promise<T> 22): Promise<T>
23 23
24function retryTransactionWrapper <T> ( 24function retryTransactionWrapper <T> (
25 functionToRetry: () => Promise<T> | Bluebird<T>
26): Promise<T>
27
28function retryTransactionWrapper <T> (
25 functionToRetry: (...args: any[]) => Promise<T> | Bluebird<T>, 29 functionToRetry: (...args: any[]) => Promise<T> | Bluebird<T>,
26 ...args: any[] 30 ...args: any[]
27): Promise<T> { 31): Promise<T> {
28 return transactionRetryer<T>(callback => { 32 return transactionRetryer<T>(callback => {
29 functionToRetry.apply(this, args) 33 functionToRetry.apply(null, args)
30 .then((result: T) => callback(null, result)) 34 .then((result: T) => callback(null, result))
31 .catch(err => callback(err)) 35 .catch(err => callback(err))
32 }) 36 })
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'
8import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' 8import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
10import { invert } from 'lodash' 10import { invert } from 'lodash'
11import { RemoveOldJobsScheduler } from '../lib/schedulers/remove-old-jobs-scheduler'
12import { UpdateVideosScheduler } from '../lib/schedulers/update-videos-scheduler'
11 13
12// Use a variable to reload the configuration if we need 14// Use a variable to reload the configuration if we need
13let config: IConfig = require('config') 15let config: IConfig = require('config')
@@ -94,7 +96,11 @@ const JOB_REQUEST_TTL = 60000 * 10 // 10 minutes
94const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 // 2 days 96const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 // 2 days
95 97
96// 1 hour 98// 1 hour
97let SCHEDULER_INTERVAL = 60000 * 60 99let SCHEDULER_INTERVALS_MS = {
100 badActorFollow: 60000 * 60, // 1 hour
101 removeOldJobs: 60000 * 60, // 1 jour
102 updateVideos: 60000 * 1, // 1 minute
103}
98 104
99// --------------------------------------------------------------------------- 105// ---------------------------------------------------------------------------
100 106
@@ -460,7 +466,10 @@ if (isTestInstance() === true) {
460 466
461 CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB 467 CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
462 468
463 SCHEDULER_INTERVAL = 10000 469 SCHEDULER_INTERVALS_MS.badActorFollow = 10000
470 SCHEDULER_INTERVALS_MS.removeOldJobs = 10000
471 SCHEDULER_INTERVALS_MS.updateVideos = 5000
472
464 VIDEO_VIEW_LIFETIME = 1000 // 1 second 473 VIDEO_VIEW_LIFETIME = 1000 // 1 second
465 474
466 JOB_ATTEMPTS['email'] = 1 475 JOB_ATTEMPTS['email'] = 1
@@ -513,7 +522,7 @@ export {
513 JOB_REQUEST_TTL, 522 JOB_REQUEST_TTL,
514 USER_PASSWORD_RESET_LIFETIME, 523 USER_PASSWORD_RESET_LIFETIME,
515 IMAGE_MIMETYPE_EXT, 524 IMAGE_MIMETYPE_EXT,
516 SCHEDULER_INTERVAL, 525 SCHEDULER_INTERVALS_MS,
517 STATIC_DOWNLOAD_PATHS, 526 STATIC_DOWNLOAD_PATHS,
518 RATES_LIMIT, 527 RATES_LIMIT,
519 VIDEO_EXT_MIMETYPE, 528 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'
22import { VideoShareModel } from '../models/video/video-share' 22import { VideoShareModel } from '../models/video/video-share'
23import { VideoTagModel } from '../models/video/video-tag' 23import { VideoTagModel } from '../models/video/video-tag'
24import { CONFIG } from './constants' 24import { CONFIG } from './constants'
25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
25 26
26require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 27require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
27 28
@@ -73,7 +74,8 @@ async function initDatabaseModels (silent: boolean) {
73 VideoBlacklistModel, 74 VideoBlacklistModel,
74 VideoTagModel, 75 VideoTagModel,
75 VideoModel, 76 VideoModel,
76 VideoCommentModel 77 VideoCommentModel,
78 ScheduleVideoUpdateModel
77 ]) 79 ])
78 80
79 if (!silent) logger.info('Database %s is ready.', dbname) 81 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 @@
1import { SCHEDULER_INTERVAL } from '../../initializers'
2
3export abstract class AbstractScheduler { 1export abstract class AbstractScheduler {
4 2
3 protected abstract schedulerIntervalMs: number
4
5 private interval: NodeJS.Timer 5 private interval: NodeJS.Timer
6 6
7 enable () { 7 enable () {
8 this.interval = setInterval(() => this.execute(), SCHEDULER_INTERVAL) 8 if (!this.schedulerIntervalMs) throw new Error('Interval is not correctly set.')
9
10 this.interval = setInterval(() => this.execute(), this.schedulerIntervalMs)
9 } 11 }
10 12
11 disable () { 13 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'
2import { logger } from '../../helpers/logger' 2import { logger } from '../../helpers/logger'
3import { ActorFollowModel } from '../../models/activitypub/actor-follow' 3import { ActorFollowModel } from '../../models/activitypub/actor-follow'
4import { AbstractScheduler } from './abstract-scheduler' 4import { AbstractScheduler } from './abstract-scheduler'
5import { SCHEDULER_INTERVALS_MS } from '../../initializers'
5 6
6export class BadActorFollowScheduler extends AbstractScheduler { 7export class BadActorFollowScheduler extends AbstractScheduler {
7 8
8 private static instance: AbstractScheduler 9 private static instance: AbstractScheduler
9 10
11 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.badActorFollow
12
10 private constructor () { 13 private constructor () {
11 super() 14 super()
12 } 15 }
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'
2import { logger } from '../../helpers/logger' 2import { logger } from '../../helpers/logger'
3import { JobQueue } from '../job-queue' 3import { JobQueue } from '../job-queue'
4import { AbstractScheduler } from './abstract-scheduler' 4import { AbstractScheduler } from './abstract-scheduler'
5import { SCHEDULER_INTERVALS_MS } from '../../initializers'
5 6
6export class RemoveOldJobsScheduler extends AbstractScheduler { 7export class RemoveOldJobsScheduler extends AbstractScheduler {
7 8
8 private static instance: AbstractScheduler 9 private static instance: AbstractScheduler
9 10
11 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.removeOldJobs
12
10 private constructor () { 13 private constructor () {
11 super() 14 super()
12 } 15 }
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 @@
1import { isTestInstance } from '../../helpers/core-utils'
2import { logger } from '../../helpers/logger'
3import { JobQueue } from '../job-queue'
4import { AbstractScheduler } from './abstract-scheduler'
5import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-update'
6import { retryTransactionWrapper } from '../../helpers/database-utils'
7import { federateVideoIfNeeded } from '../activitypub'
8import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers'
9import { VideoPrivacy } from '../../../shared/models/videos'
10
11export class UpdateVideosScheduler extends AbstractScheduler {
12
13 private static instance: AbstractScheduler
14
15 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.updateVideos
16
17 private isRunning = false
18
19 private constructor () {
20 super()
21 }
22
23 async execute () {
24 if (this.isRunning === true) return
25 this.isRunning = true
26
27 try {
28 await retryTransactionWrapper(this.updateVideos.bind(this))
29 } catch (err) {
30 logger.error('Cannot execute update videos scheduler.', { err })
31 } finally {
32 this.isRunning = false
33 }
34 }
35
36 private updateVideos () {
37 return sequelizeTypescript.transaction(async t => {
38 const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate(t)
39
40 for (const schedule of schedules) {
41 const video = schedule.Video
42 logger.info('Executing scheduled video update on %s.', video.uuid)
43
44 if (schedule.privacy) {
45 const oldPrivacy = video.privacy
46
47 video.privacy = schedule.privacy
48 await video.save({ transaction: t })
49
50 const isNewVideo = oldPrivacy === VideoPrivacy.PRIVATE
51 await federateVideoIfNeeded(video, isNewVideo, t)
52 }
53
54 await schedule.destroy({ transaction: t })
55 }
56 })
57 }
58
59 static get Instance () {
60 return this.instance || (this.instance = new this())
61 }
62}
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'
2import 'express-validator' 2import 'express-validator'
3import { body, param, query } from 'express-validator/check' 3import { body, param, query } from 'express-validator/check'
4import { UserRight, VideoPrivacy } from '../../../shared' 4import { UserRight, VideoPrivacy } from '../../../shared'
5import { isBooleanValid, isIdOrUUIDValid, isIdValid, isUUIDValid, toIntOrNull, toValueOrNull } from '../../helpers/custom-validators/misc'
6import { 5import {
6 isBooleanValid,
7 isDateValid,
8 isIdOrUUIDValid,
9 isIdValid,
10 isUUIDValid,
11 toIntOrNull,
12 toValueOrNull
13} from '../../helpers/custom-validators/misc'
14import {
15 isScheduleVideoUpdatePrivacyValid,
7 isVideoAbuseReasonValid, 16 isVideoAbuseReasonValid,
8 isVideoCategoryValid, 17 isVideoCategoryValid,
9 isVideoChannelOfAccountExist, 18 isVideoChannelOfAccountExist,
@@ -84,14 +93,21 @@ const videosAddValidator = [
84 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'), 93 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
85 body('channelId') 94 body('channelId')
86 .toInt() 95 .toInt()
87 .custom(isIdValid) 96 .custom(isIdValid).withMessage('Should have correct video channel id'),
88 .withMessage('Should have correct video channel id'), 97 body('scheduleUpdate.updateAt')
98 .optional()
99 .custom(isDateValid).withMessage('Should have a valid schedule update date'),
100 body('scheduleUpdate.privacy')
101 .optional()
102 .toInt()
103 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy'),
89 104
90 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 105 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
91 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) 106 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
92 107
93 if (areValidationErrors(req, res)) return 108 if (areValidationErrors(req, res)) return
94 if (areErrorsInVideoImageFiles(req, res)) return 109 if (areErrorsInVideoImageFiles(req, res)) return
110 if (areErrorsInScheduleUpdate(req, res)) return
95 111
96 const videoFile: Express.Multer.File = req.files['videofile'][0] 112 const videoFile: Express.Multer.File = req.files['videofile'][0]
97 const user = res.locals.oauth.token.User 113 const user = res.locals.oauth.token.User
@@ -183,12 +199,20 @@ const videosUpdateValidator = [
183 .optional() 199 .optional()
184 .toInt() 200 .toInt()
185 .custom(isIdValid).withMessage('Should have correct video channel id'), 201 .custom(isIdValid).withMessage('Should have correct video channel id'),
202 body('scheduleUpdate.updateAt')
203 .optional()
204 .custom(isDateValid).withMessage('Should have a valid schedule update date'),
205 body('scheduleUpdate.privacy')
206 .optional()
207 .toInt()
208 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy'),
186 209
187 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 210 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
188 logger.debug('Checking videosUpdate parameters', { parameters: req.body }) 211 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
189 212
190 if (areValidationErrors(req, res)) return 213 if (areValidationErrors(req, res)) return
191 if (areErrorsInVideoImageFiles(req, res)) return 214 if (areErrorsInVideoImageFiles(req, res)) return
215 if (areErrorsInScheduleUpdate(req, res)) return
192 if (!await isVideoExist(req.params.id, res)) return 216 if (!await isVideoExist(req.params.id, res)) return
193 217
194 const video = res.locals.video 218 const video = res.locals.video
@@ -371,7 +395,7 @@ function areErrorsInVideoImageFiles (req: express.Request, res: express.Response
371 const imageFile = req.files[ imageField ][ 0 ] as Express.Multer.File 395 const imageFile = req.files[ imageField ][ 0 ] as Express.Multer.File
372 if (imageFile.size > CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max) { 396 if (imageFile.size > CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max) {
373 res.status(400) 397 res.status(400)
374 .send({ error: `The size of the ${imageField} is too big (>${CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max}).` }) 398 .json({ error: `The size of the ${imageField} is too big (>${CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max}).` })
375 .end() 399 .end()
376 return true 400 return true
377 } 401 }
@@ -379,3 +403,17 @@ function areErrorsInVideoImageFiles (req: express.Request, res: express.Response
379 403
380 return false 404 return false
381} 405}
406
407function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
408 if (req.body.scheduleUpdate) {
409 if (!req.body.scheduleUpdate.updateAt) {
410 res.status(400)
411 .json({ error: 'Schedule update at is mandatory.' })
412 .end()
413
414 return true
415 }
416 }
417
418 return false
419}
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 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Sequelize, Table, UpdatedAt } from 'sequelize-typescript'
2import { ScopeNames as VideoScopeNames, VideoModel } from './video'
3import { VideoPrivacy } from '../../../shared/models/videos'
4import { Transaction } from 'sequelize'
5
6@Table({
7 tableName: 'scheduleVideoUpdate',
8 indexes: [
9 {
10 fields: [ 'videoId' ],
11 unique: true
12 },
13 {
14 fields: [ 'updateAt' ]
15 }
16 ]
17})
18export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
19
20 @AllowNull(false)
21 @Default(null)
22 @Column
23 updateAt: Date
24
25 @AllowNull(true)
26 @Default(null)
27 @Column
28 privacy: VideoPrivacy
29
30 @CreatedAt
31 createdAt: Date
32
33 @UpdatedAt
34 updatedAt: Date
35
36 @ForeignKey(() => VideoModel)
37 @Column
38 videoId: number
39
40 @BelongsTo(() => VideoModel, {
41 foreignKey: {
42 allowNull: false
43 },
44 onDelete: 'cascade'
45 })
46 Video: VideoModel
47
48 static listVideosToUpdate (t: Transaction) {
49 const query = {
50 where: {
51 updateAt: {
52 [Sequelize.Op.lte]: new Date()
53 }
54 },
55 include: [
56 {
57 model: VideoModel.scope(
58 [
59 VideoScopeNames.WITH_FILES,
60 VideoScopeNames.WITH_ACCOUNT_DETAILS
61 ]
62 )
63 }
64 ],
65 transaction: t
66 }
67
68 return ScheduleVideoUpdateModel.findAll(query)
69 }
70
71}
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 {
15 Default, 15 Default,
16 ForeignKey, 16 ForeignKey,
17 HasMany, 17 HasMany,
18 HasOne,
18 IFindOptions, 19 IFindOptions,
19 Is, 20 Is,
20 IsInt, 21 IsInt,
@@ -47,7 +48,8 @@ import {
47 isVideoLanguageValid, 48 isVideoLanguageValid,
48 isVideoLicenceValid, 49 isVideoLicenceValid,
49 isVideoNameValid, 50 isVideoNameValid,
50 isVideoPrivacyValid, isVideoStateValid, 51 isVideoPrivacyValid,
52 isVideoStateValid,
51 isVideoSupportValid 53 isVideoSupportValid
52} from '../../helpers/custom-validators/videos' 54} from '../../helpers/custom-validators/videos'
53import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' 55import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
@@ -66,7 +68,8 @@ import {
66 VIDEO_EXT_MIMETYPE, 68 VIDEO_EXT_MIMETYPE,
67 VIDEO_LANGUAGES, 69 VIDEO_LANGUAGES,
68 VIDEO_LICENCES, 70 VIDEO_LICENCES,
69 VIDEO_PRIVACIES, VIDEO_STATES 71 VIDEO_PRIVACIES,
72 VIDEO_STATES
70} from '../../initializers' 73} from '../../initializers'
71import { 74import {
72 getVideoCommentsActivityPubUrl, 75 getVideoCommentsActivityPubUrl,
@@ -88,8 +91,9 @@ import { VideoCommentModel } from './video-comment'
88import { VideoFileModel } from './video-file' 91import { VideoFileModel } from './video-file'
89import { VideoShareModel } from './video-share' 92import { VideoShareModel } from './video-share'
90import { VideoTagModel } from './video-tag' 93import { VideoTagModel } from './video-tag'
94import { ScheduleVideoUpdateModel } from './schedule-video-update'
91 95
92enum ScopeNames { 96export enum ScopeNames {
93 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 97 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
94 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', 98 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
95 WITH_TAGS = 'WITH_TAGS', 99 WITH_TAGS = 'WITH_TAGS',
@@ -495,6 +499,15 @@ export class VideoModel extends Model<VideoModel> {
495 }) 499 })
496 VideoComments: VideoCommentModel[] 500 VideoComments: VideoCommentModel[]
497 501
502 @HasOne(() => ScheduleVideoUpdateModel, {
503 foreignKey: {
504 name: 'videoId',
505 allowNull: false
506 },
507 onDelete: 'cascade'
508 })
509 ScheduleVideoUpdate: ScheduleVideoUpdateModel
510
498 @BeforeDestroy 511 @BeforeDestroy
499 static async sendDelete (instance: VideoModel, options) { 512 static async sendDelete (instance: VideoModel, options) {
500 if (instance.isOwned()) { 513 if (instance.isOwned()) {
@@ -673,6 +686,10 @@ export class VideoModel extends Model<VideoModel> {
673 required: true 686 required: true
674 } 687 }
675 ] 688 ]
689 },
690 {
691 model: ScheduleVideoUpdateModel,
692 required: false
676 } 693 }
677 ] 694 ]
678 } 695 }
@@ -1006,7 +1023,8 @@ export class VideoModel extends Model<VideoModel> {
1006 toFormattedJSON (options?: { 1023 toFormattedJSON (options?: {
1007 additionalAttributes: { 1024 additionalAttributes: {
1008 state: boolean, 1025 state: boolean,
1009 waitTranscoding: boolean 1026 waitTranscoding: boolean,
1027 scheduledUpdate: boolean
1010 } 1028 }
1011 }): Video { 1029 }): Video {
1012 const formattedAccount = this.VideoChannel.Account.toFormattedJSON() 1030 const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
@@ -1073,7 +1091,16 @@ export class VideoModel extends Model<VideoModel> {
1073 } 1091 }
1074 } 1092 }
1075 1093
1076 if (options.additionalAttributes.waitTranscoding) videoObject.waitTranscoding = this.waitTranscoding 1094 if (options.additionalAttributes.waitTranscoding) {
1095 videoObject.waitTranscoding = this.waitTranscoding
1096 }
1097
1098 if (options.additionalAttributes.scheduledUpdate && this.ScheduleVideoUpdate) {
1099 videoObject.scheduledUpdate = {
1100 updateAt: this.ScheduleVideoUpdate.updateAt,
1101 privacy: this.ScheduleVideoUpdate.privacy || undefined
1102 }
1103 }
1077 } 1104 }
1078 1105
1079 return videoObject 1106 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'
6import './videos/video-comments' 6import './videos/video-comments'
7import './users/users-multiple-servers' 7import './users/users-multiple-servers'
8import './server/handle-down' 8import './server/handle-down'
9import './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 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { VideoPrivacy } from '../../../../shared/models/videos'
6import {
7 doubleFollow,
8 flushAndRunMultipleServers, getMyVideos,
9 getVideosList,
10 killallServers,
11 ServerInfo,
12 setAccessTokensToServers, updateVideo,
13 uploadVideo,
14 wait
15} from '../../utils'
16import { join } from 'path'
17import { waitJobs } from '../../utils/server/jobs'
18
19const expect = chai.expect
20
21function in10Seconds () {
22 const now = new Date()
23 now.setSeconds(now.getSeconds() + 10)
24
25 return now
26}
27
28describe('Test video update scheduler', function () {
29 let servers: ServerInfo[] = []
30 let video2UUID: string
31
32 before(async function () {
33 this.timeout(30000)
34
35 // Run servers
36 servers = await flushAndRunMultipleServers(2)
37
38 await setAccessTokensToServers(servers)
39
40 await doubleFollow(servers[0], servers[1])
41 })
42
43 it('Should upload a video and schedule an update in 10 seconds', async function () {
44 this.timeout(10000)
45
46 const videoAttributes = {
47 name: 'video 1',
48 privacy: VideoPrivacy.PRIVATE,
49 scheduleUpdate: {
50 updateAt: in10Seconds().toISOString(),
51 privacy: VideoPrivacy.PUBLIC
52 }
53 }
54
55 await uploadVideo(servers[0].url, servers[0].accessToken, videoAttributes)
56
57 await waitJobs(servers)
58 })
59
60 it('Should not list the video (in privacy mode)', async function () {
61 for (const server of servers) {
62 const res = await getVideosList(server.url)
63
64 expect(res.body.total).to.equal(0)
65 }
66 })
67
68 it('Should have my scheduled video in my account videos', async function () {
69 const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 5)
70 expect(res.body.total).to.equal(1)
71
72 const video = res.body.data[0]
73 expect(video.name).to.equal('video 1')
74 expect(video.privacy.id).to.equal(VideoPrivacy.PRIVATE)
75 expect(new Date(video.scheduledUpdate.updateAt)).to.be.above(new Date())
76 expect(video.scheduledUpdate.privacy).to.equal(VideoPrivacy.PUBLIC)
77 })
78
79 it('Should wait some seconds and have the video in public privacy', async function () {
80 this.timeout(20000)
81
82 await wait(10000)
83 await waitJobs(servers)
84
85 for (const server of servers) {
86 const res = await getVideosList(server.url)
87
88 expect(res.body.total).to.equal(1)
89 expect(res.body.data[0].name).to.equal('video 1')
90 }
91 })
92
93 it('Should upload a video without scheduling an update', async function () {
94 this.timeout(10000)
95
96 const videoAttributes = {
97 name: 'video 2',
98 privacy: VideoPrivacy.PRIVATE
99 }
100
101 const res = await uploadVideo(servers[0].url, servers[0].accessToken, videoAttributes)
102 video2UUID = res.body.video.uuid
103
104 await waitJobs(servers)
105 })
106
107 it('Should update a video by scheduling an update', async function () {
108 this.timeout(10000)
109
110 const videoAttributes = {
111 name: 'video 2 updated',
112 scheduleUpdate: {
113 updateAt: in10Seconds().toISOString(),
114 privacy: VideoPrivacy.PUBLIC
115 }
116 }
117
118 await updateVideo(servers[0].url, servers[0].accessToken, video2UUID, videoAttributes)
119 await waitJobs(servers)
120 })
121
122 it('Should not display the updated video', async function () {
123 for (const server of servers) {
124 const res = await getVideosList(server.url)
125
126 expect(res.body.total).to.equal(1)
127 }
128 })
129
130 it('Should have my scheduled updated video in my account videos', async function () {
131 const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 5)
132 expect(res.body.total).to.equal(2)
133
134 const video = res.body.data.find(v => v.uuid === video2UUID)
135 expect(video).not.to.be.undefined
136
137 expect(video.name).to.equal('video 2 updated')
138 expect(video.privacy.id).to.equal(VideoPrivacy.PRIVATE)
139
140 expect(new Date(video.scheduledUpdate.updateAt)).to.be.above(new Date())
141 expect(video.scheduledUpdate.privacy).to.equal(VideoPrivacy.PUBLIC)
142 })
143
144 it('Should wait some seconds and have the updated video in public privacy', async function () {
145 this.timeout(20000)
146
147 await wait(10000)
148 await waitJobs(servers)
149
150 for (const server of servers) {
151 const res = await getVideosList(server.url)
152
153 expect(res.body.total).to.equal(2)
154
155 const video = res.body.data.find(v => v.uuid === video2UUID)
156 expect(video).not.to.be.undefined
157 expect(video.name).to.equal('video 2 updated')
158 }
159 })
160
161 after(async function () {
162 killallServers(servers)
163 })
164})
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 = {
35 fixture?: string 35 fixture?: string
36 thumbnailfile?: string 36 thumbnailfile?: string
37 previewfile?: string 37 previewfile?: string
38 scheduleUpdate?: {
39 updateAt: string
40 privacy?: VideoPrivacy
41 }
38} 42}
39 43
40function getVideoCategories (url: string) { 44function getVideoCategories (url: string) {
@@ -371,6 +375,14 @@ async function uploadVideo (url: string, accessToken: string, videoAttributesArg
371 req.attach('previewfile', buildAbsoluteFixturePath(attributes.previewfile)) 375 req.attach('previewfile', buildAbsoluteFixturePath(attributes.previewfile))
372 } 376 }
373 377
378 if (attributes.scheduleUpdate) {
379 req.field('scheduleUpdate[updateAt]', attributes.scheduleUpdate.updateAt)
380
381 if (attributes.scheduleUpdate.privacy) {
382 req.field('scheduleUpdate[privacy]', attributes.scheduleUpdate.privacy)
383 }
384 }
385
374 return req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture)) 386 return req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture))
375 .expect(specialStatus) 387 .expect(specialStatus)
376} 388}
@@ -389,6 +401,7 @@ function updateVideo (url: string, accessToken: string, id: number | string, att
389 if (attributes.tags) body['tags'] = attributes.tags 401 if (attributes.tags) body['tags'] = attributes.tags
390 if (attributes.privacy) body['privacy'] = attributes.privacy 402 if (attributes.privacy) body['privacy'] = attributes.privacy
391 if (attributes.channelId) body['channelId'] = attributes.channelId 403 if (attributes.channelId) body['channelId'] = attributes.channelId
404 if (attributes.scheduleUpdate) body['scheduleUpdate'] = attributes.scheduleUpdate
392 405
393 // Upload request 406 // Upload request
394 if (attributes.thumbnailfile || attributes.previewfile) { 407 if (attributes.thumbnailfile || attributes.previewfile) {