diff options
author | Chocobozzz <me@florianbigard.com> | 2018-06-14 18:06:56 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2018-06-14 18:06:56 +0200 |
commit | 2baea0c77cc765f7cbca9c9a2f4272268892a35c (patch) | |
tree | 47b1be5535439409a97eb80c0222c9c821b83dae /server | |
parent | bf079b7bfd7f0fb75ceb28e333bb4b74d8840dd4 (diff) | |
download | PeerTube-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.ts | 6 | ||||
-rw-r--r-- | server/controllers/api/videos/index.ts | 20 | ||||
-rw-r--r-- | server/helpers/custom-validators/videos.ts | 13 | ||||
-rw-r--r-- | server/helpers/database-utils.ts | 6 | ||||
-rw-r--r-- | server/initializers/constants.ts | 15 | ||||
-rw-r--r-- | server/initializers/database.ts | 4 | ||||
-rw-r--r-- | server/lib/schedulers/abstract-scheduler.ts | 8 | ||||
-rw-r--r-- | server/lib/schedulers/bad-actor-follow-scheduler.ts | 3 | ||||
-rw-r--r-- | server/lib/schedulers/remove-old-jobs-scheduler.ts | 3 | ||||
-rw-r--r-- | server/lib/schedulers/update-videos-scheduler.ts | 62 | ||||
-rw-r--r-- | server/middlewares/validators/videos.ts | 46 | ||||
-rw-r--r-- | server/models/video/schedule-video-update.ts | 71 | ||||
-rw-r--r-- | server/models/video/video.ts | 37 | ||||
-rw-r--r-- | server/tests/api/index-slow.ts | 1 | ||||
-rw-r--r-- | server/tests/api/videos/video-schedule-update.ts | 164 | ||||
-rw-r--r-- | server/tests/utils/videos/videos.ts | 13 |
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' | |||
52 | import { VideoFilter } from '../../../../shared/models/videos/video-query.type' | 52 | import { VideoFilter } from '../../../../shared/models/videos/video-query.type' |
53 | import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' | 53 | import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' |
54 | import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' | 54 | import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' |
55 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | ||
55 | 56 | ||
56 | const videosRouter = express.Router() | 57 | const 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' | |||
3 | import { values } from 'lodash' | 3 | import { values } from 'lodash' |
4 | import 'multer' | 4 | import 'multer' |
5 | import * as validator from 'validator' | 5 | import * as validator from 'validator' |
6 | import { UserRight, VideoRateType } from '../../../shared' | 6 | import { UserRight, VideoPrivacy, VideoRateType } from '../../../shared' |
7 | import { | 7 | import { |
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 | ||
101 | function isVideoPrivacyValid (value: string) { | 101 | function 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 | ||
105 | function isScheduleVideoUpdatePrivacyValid (value: number) { | ||
106 | return validator.isInt(value + '') && | ||
107 | ( | ||
108 | value === VideoPrivacy.UNLISTED || | ||
109 | value === VideoPrivacy.PUBLIC | ||
110 | ) | ||
111 | } | ||
112 | |||
105 | function isVideoFileInfoHashValid (value: string) { | 113 | function 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 | ||
24 | function retryTransactionWrapper <T> ( | 24 | function retryTransactionWrapper <T> ( |
25 | functionToRetry: () => Promise<T> | Bluebird<T> | ||
26 | ): Promise<T> | ||
27 | |||
28 | function 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' | |||
8 | import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' | 8 | import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' |
9 | import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' | 9 | import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' |
10 | import { invert } from 'lodash' | 10 | import { invert } from 'lodash' |
11 | import { RemoveOldJobsScheduler } from '../lib/schedulers/remove-old-jobs-scheduler' | ||
12 | import { 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 |
13 | let config: IConfig = require('config') | 15 | let config: IConfig = require('config') |
@@ -94,7 +96,11 @@ const JOB_REQUEST_TTL = 60000 * 10 // 10 minutes | |||
94 | const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 // 2 days | 96 | const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 // 2 days |
95 | 97 | ||
96 | // 1 hour | 98 | // 1 hour |
97 | let SCHEDULER_INTERVAL = 60000 * 60 | 99 | let 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' | |||
22 | import { VideoShareModel } from '../models/video/video-share' | 22 | import { VideoShareModel } from '../models/video/video-share' |
23 | import { VideoTagModel } from '../models/video/video-tag' | 23 | import { VideoTagModel } from '../models/video/video-tag' |
24 | import { CONFIG } from './constants' | 24 | import { CONFIG } from './constants' |
25 | import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' | ||
25 | 26 | ||
26 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 27 | require('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 @@ | |||
1 | import { SCHEDULER_INTERVAL } from '../../initializers' | ||
2 | |||
3 | export abstract class AbstractScheduler { | 1 | export 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' | |||
2 | import { logger } from '../../helpers/logger' | 2 | import { logger } from '../../helpers/logger' |
3 | import { ActorFollowModel } from '../../models/activitypub/actor-follow' | 3 | import { ActorFollowModel } from '../../models/activitypub/actor-follow' |
4 | import { AbstractScheduler } from './abstract-scheduler' | 4 | import { AbstractScheduler } from './abstract-scheduler' |
5 | import { SCHEDULER_INTERVALS_MS } from '../../initializers' | ||
5 | 6 | ||
6 | export class BadActorFollowScheduler extends AbstractScheduler { | 7 | export 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' | |||
2 | import { logger } from '../../helpers/logger' | 2 | import { logger } from '../../helpers/logger' |
3 | import { JobQueue } from '../job-queue' | 3 | import { JobQueue } from '../job-queue' |
4 | import { AbstractScheduler } from './abstract-scheduler' | 4 | import { AbstractScheduler } from './abstract-scheduler' |
5 | import { SCHEDULER_INTERVALS_MS } from '../../initializers' | ||
5 | 6 | ||
6 | export class RemoveOldJobsScheduler extends AbstractScheduler { | 7 | export 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 @@ | |||
1 | import { isTestInstance } from '../../helpers/core-utils' | ||
2 | import { logger } from '../../helpers/logger' | ||
3 | import { JobQueue } from '../job-queue' | ||
4 | import { AbstractScheduler } from './abstract-scheduler' | ||
5 | import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-update' | ||
6 | import { retryTransactionWrapper } from '../../helpers/database-utils' | ||
7 | import { federateVideoIfNeeded } from '../activitypub' | ||
8 | import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers' | ||
9 | import { VideoPrivacy } from '../../../shared/models/videos' | ||
10 | |||
11 | export 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' | |||
2 | import 'express-validator' | 2 | import 'express-validator' |
3 | import { body, param, query } from 'express-validator/check' | 3 | import { body, param, query } from 'express-validator/check' |
4 | import { UserRight, VideoPrivacy } from '../../../shared' | 4 | import { UserRight, VideoPrivacy } from '../../../shared' |
5 | import { isBooleanValid, isIdOrUUIDValid, isIdValid, isUUIDValid, toIntOrNull, toValueOrNull } from '../../helpers/custom-validators/misc' | ||
6 | import { | 5 | import { |
6 | isBooleanValid, | ||
7 | isDateValid, | ||
8 | isIdOrUUIDValid, | ||
9 | isIdValid, | ||
10 | isUUIDValid, | ||
11 | toIntOrNull, | ||
12 | toValueOrNull | ||
13 | } from '../../helpers/custom-validators/misc' | ||
14 | import { | ||
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 | |||
407 | function 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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Sequelize, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { ScopeNames as VideoScopeNames, VideoModel } from './video' | ||
3 | import { VideoPrivacy } from '../../../shared/models/videos' | ||
4 | import { 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 | }) | ||
18 | export 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' |
53 | import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' | 55 | import { 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' |
71 | import { | 74 | import { |
72 | getVideoCommentsActivityPubUrl, | 75 | getVideoCommentsActivityPubUrl, |
@@ -88,8 +91,9 @@ import { VideoCommentModel } from './video-comment' | |||
88 | import { VideoFileModel } from './video-file' | 91 | import { VideoFileModel } from './video-file' |
89 | import { VideoShareModel } from './video-share' | 92 | import { VideoShareModel } from './video-share' |
90 | import { VideoTagModel } from './video-tag' | 93 | import { VideoTagModel } from './video-tag' |
94 | import { ScheduleVideoUpdateModel } from './schedule-video-update' | ||
91 | 95 | ||
92 | enum ScopeNames { | 96 | export 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' | |||
6 | import './videos/video-comments' | 6 | import './videos/video-comments' |
7 | import './users/users-multiple-servers' | 7 | import './users/users-multiple-servers' |
8 | import './server/handle-down' | 8 | import './server/handle-down' |
9 | 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 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { VideoPrivacy } from '../../../../shared/models/videos' | ||
6 | import { | ||
7 | doubleFollow, | ||
8 | flushAndRunMultipleServers, getMyVideos, | ||
9 | getVideosList, | ||
10 | killallServers, | ||
11 | ServerInfo, | ||
12 | setAccessTokensToServers, updateVideo, | ||
13 | uploadVideo, | ||
14 | wait | ||
15 | } from '../../utils' | ||
16 | import { join } from 'path' | ||
17 | import { waitJobs } from '../../utils/server/jobs' | ||
18 | |||
19 | const expect = chai.expect | ||
20 | |||
21 | function in10Seconds () { | ||
22 | const now = new Date() | ||
23 | now.setSeconds(now.getSeconds() + 10) | ||
24 | |||
25 | return now | ||
26 | } | ||
27 | |||
28 | describe('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 | ||
40 | function getVideoCategories (url: string) { | 44 | function 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) { |