Add ability for uploaders to schedule video update
authorChocobozzz <me@florianbigard.com>
Thu, 14 Jun 2018 16:06:56 +0000 (18:06 +0200)
committerChocobozzz <me@florianbigard.com>
Thu, 14 Jun 2018 16:06:56 +0000 (18:06 +0200)
21 files changed:
client/src/app/videos/+video-watch/comment/video-comments.component.ts
server.ts
server/controllers/api/users.ts
server/controllers/api/videos/index.ts
server/helpers/custom-validators/videos.ts
server/helpers/database-utils.ts
server/initializers/constants.ts
server/initializers/database.ts
server/lib/schedulers/abstract-scheduler.ts
server/lib/schedulers/bad-actor-follow-scheduler.ts
server/lib/schedulers/remove-old-jobs-scheduler.ts
server/lib/schedulers/update-videos-scheduler.ts [new file with mode: 0644]
server/middlewares/validators/videos.ts
server/models/video/schedule-video-update.ts [new file with mode: 0644]
server/models/video/video.ts
server/tests/api/index-slow.ts
server/tests/api/videos/video-schedule-update.ts [new file with mode: 0644]
server/tests/utils/videos/videos.ts
shared/models/videos/video-create.model.ts
shared/models/videos/video-update.model.ts
shared/models/videos/video.model.ts

index 274c32d31b7126e005a985bb7b235f2c03883bb4..3743cd22807cb4f92b624c395c4f068c330c3b37 100644 (file)
@@ -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)
index c0e679b02539195cc97bb3583e58af9b8dda5cf3..ef89ff5f653dad11ef915af1dc13efc3c0e4032c 100644 (file)
--- 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()
index 0aeb77964434b301743c60ebf49d94d6fdf364ec..89105691267786772d361956f22e92acbff9d327 100644 (file)
@@ -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 }))
 }
 
index 78963d89bd7358ea3dae840c542b2a800bb65d58..79ca4699ff536499b7cb667979ecf52b2a09bc64 100644 (file)
@@ -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)
     })
index 8496e679aad708c31d2f046c4ad52685ff213c56..a227136acc5fde6644f97584932cd7b312f05a7e 100644 (file)
@@ -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,
index 9b861a88ccd3c417bf963ae659979ab57c28482b..ededa79016fb3e3be8c79274eb390d5788805895 100644 (file)
@@ -21,12 +21,16 @@ function retryTransactionWrapper <T, A> (
   arg1: A
 ): Promise<T>
 
+function retryTransactionWrapper <T> (
+  functionToRetry: () => Promise<T> | Bluebird<T>
+): Promise<T>
+
 function retryTransactionWrapper <T> (
   functionToRetry: (...args: any[]) => Promise<T> | Bluebird<T>,
   ...args: any[]
 ): Promise<T> {
   return transactionRetryer<T>(callback => {
-    functionToRetry.apply(this, args)
+    functionToRetry.apply(null, args)
         .then((result: T) => callback(null, result))
         .catch(err => callback(err))
   })
index 65f89ff7f76d1ad7a90f8e712b2296f1709d0733..1643785058098a704ea1528600ec35488b6c3902 100644 (file)
@@ -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,
index b537ee59a582118f4af3ce1df78128c328dc263d..4d90c90fc084c4524d40c645aa70ec8edbf71180 100644 (file)
@@ -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)
index 473544ddfff1d78ba23ed5861f4597c9be779b11..6ec5e336069a137768689884fad9f7bb37ab5e1c 100644 (file)
@@ -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 () {
index 121f7145e3b657d0698848e4c93bbadc76a20bc6..617149aaf363a86dd2faf3dd30d79e51b46d3da7 100644 (file)
@@ -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()
   }
index 0e8ad155443a8f87d030d6a1dfbb067620b4b2c0..a29a6b80091bf1bc811acf2636f17c2e301a91fb 100644 (file)
@@ -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 (file)
index 0000000..d123c3c
--- /dev/null
@@ -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())
+  }
+}
index e181aebdbcdaa43e4431356952c6768d1b3cd561..9fe5a253b5d0a2f78030f5b4ad6f7d40517c240c 100644 (file)
@@ -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 (file)
index 0000000..d4e37be
--- /dev/null
@@ -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)
+  }
+
+}
index 59c378efaa2f2ace4922596f6d08ab49c734f07c..440f4d17161a7a1c78f4ad6fa51baec9431acd98 100644 (file)
@@ -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<VideoModel> {
   })
   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<VideoModel> {
               required: true
             }
           ]
+        },
+        {
+          model: ScheduleVideoUpdateModel,
+          required: false
         }
       ]
     }
@@ -1006,7 +1023,8 @@ export class VideoModel extends Model<VideoModel> {
   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<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
index cde5468564f23f9b5b9fd2028e881aafacb351c4..d987442b3684bbe700c7ebb581fed4f301b984a2 100644 (file)
@@ -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 (file)
index 0000000..8b87ea8
--- /dev/null
@@ -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)
+  })
+})
index 2c1d20ef10dff61cf119d5a51eb0417312b1dbf4..4f7ce6d6bc4bd6d6dadc3a48a5d27fd4b25759c0 100644 (file)
@@ -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) {
index 2a1f622f61b3ebc3f824385fe613de084ad605e7..531eafe5415c8cebf2b7689fac07d4815d70b003 100644 (file)
@@ -13,4 +13,8 @@ export interface VideoCreate {
   tags?: string[]
   commentsEnabled?: boolean
   privacy: VideoPrivacy
+  scheduleUpdate?: {
+    updateAt: Date
+    privacy?: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED
+  }
 }
index 681b00b1874e8027be15ac2d8666373976315322..fc0df681085f6df80e316a6c81697dd59acc3942 100644 (file)
@@ -15,4 +15,8 @@ export interface VideoUpdate {
   channelId?: number
   thumbnailfile?: Blob
   previewfile?: Blob
+  scheduleUpdate?: {
+    updateAt: Date
+    privacy?: VideoPrivacy
+  }
 }
index 857ca1fd97982560ae7a2bafbadab55e819c9b41..676354ce374095820d8b8292e21e3cead464f7c3 100644 (file)
@@ -43,6 +43,10 @@ export interface Video {
 
   waitTranscoding?: boolean
   state?: VideoConstant<VideoState>
+  scheduledUpdate?: {
+    updateAt: Date | string
+    privacy?: VideoPrivacy
+  }
 
   account: {
     id: number