]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability to schedule video publication
authorChocobozzz <me@florianbigard.com>
Fri, 15 Jun 2018 14:52:15 +0000 (16:52 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 15 Jun 2018 16:20:56 +0000 (18:20 +0200)
51 files changed:
client/package.json
client/src/app/+accounts/account-videos/account-videos.component.ts
client/src/app/+my-account/my-account-videos/my-account-videos.component.html
client/src/app/+my-account/my-account-videos/my-account-videos.component.scss
client/src/app/+my-account/my-account-videos/my-account-videos.component.ts
client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
client/src/app/app.component.ts
client/src/app/core/server/server.service.ts
client/src/app/shared/forms/form-validators/video-validators.service.ts
client/src/app/shared/forms/markdown-textarea.component.ts
client/src/app/shared/i18n/i18n-primeng-calendar.ts [new file with mode: 0644]
client/src/app/shared/misc/screen.service.ts [new file with mode: 0644]
client/src/app/shared/misc/utils.ts
client/src/app/shared/shared.module.ts
client/src/app/shared/video/abstract-video-list.ts
client/src/app/shared/video/video-edit.model.ts
client/src/app/shared/video/video-thumbnail.component.ts
client/src/app/shared/video/video.model.ts
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-edit/shared/video-edit.component.html
client/src/app/videos/+video-edit/shared/video-edit.component.scss
client/src/app/videos/+video-edit/shared/video-edit.component.ts
client/src/app/videos/+video-edit/shared/video-edit.module.ts
client/src/app/videos/+video-edit/video-add.component.html
client/src/app/videos/+video-edit/video-add.component.ts
client/src/app/videos/+video-edit/video-update.component.html
client/src/app/videos/+video-edit/video-update.component.ts
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/+video-watch/video-watch.component.scss
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/video-list/video-local.component.ts
client/src/app/videos/video-list/video-recently-added.component.ts
client/src/app/videos/video-list/video-search.component.ts
client/src/app/videos/video-list/video-trending.component.ts
client/src/sass/application.scss
client/src/sass/include/_mixins.scss
client/src/sass/primeng-custom.scss [new file with mode: 0644]
client/yarn.lock
server/initializers/constants.ts
server/lib/schedulers/update-videos-scheduler.ts
server/middlewares/validators/videos.ts
server/models/video/schedule-video-update.ts
server/models/video/video.ts
server/tests/api/check-params/videos.ts
server/tests/api/videos/video-schedule-update.ts
shared/models/videos/video-create.model.ts
shared/models/videos/video-schedule-update.model.ts [new file with mode: 0644]
shared/models/videos/video-update.model.ts
shared/models/videos/video.model.ts
support/doc/api/html/index.html
support/doc/api/openapi.yaml

index 22f48fe7ea781a6ac59cd7d77312781ac49b2737..ee7743ff276e2c35bedb162bbf222be21e23accb 100644 (file)
@@ -85,7 +85,7 @@
     "ngx-pipes": "^2.1.7",
     "node-sass": "^4.1.1",
     "npm-font-source-sans-pro": "^1.0.2",
-    "primeng": "^5.2.6",
+    "primeng": "^6.0.0-rc.1",
     "protractor": "^5.3.2",
     "purify-css": "^1.2.5",
     "purifycss-webpack": "^0.7.0",
index 5e3dbb6b386921d161aebe2a46e990347f443b53..d4fcd7acfb513665fdee8a9f6345ae5a21f15c14 100644 (file)
@@ -12,6 +12,7 @@ import { AccountService } from '@app/shared/account/account.service'
 import { tap } from 'rxjs/operators'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { Subscription } from 'rxjs'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-account-videos',
@@ -37,6 +38,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
     protected notificationsService: NotificationsService,
     protected confirmService: ConfirmService,
     protected location: Location,
+    protected screenService: ScreenService,
     protected i18n: I18n,
     private accountService: AccountService,
     private videoService: VideoService
index eb24de7a7ff5287ee0caf3a3ea1fe1ccfa5400d4..7ac6371dbd7a2b3d61dca7438d797b21886fcd78 100644 (file)
@@ -18,7 +18,7 @@
       <div class="video-info">
         <a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
         <span i18n class="video-info-date-views">{{ video.createdAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span>
-        <div class="video-info-private">{{ video.privacy.label }} - {{ getStateLabel(video) }}</div>
+        <div class="video-info-private">{{ video.privacy.label }}{{ getStateLabel(video) }}</div>
       </div>
 
       <!-- Display only once -->
@@ -28,9 +28,9 @@
             Cancel
           </span>
 
-          <span i18n class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
+          <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
             <span class="icon icon-delete-white"></span>
-            Delete
+            <ng-container i18n>Delete</ng-container>
           </span>
         </div>
       </div>
index f276ea389f8fe9c0857a5da1b7d33b0a69bbec42..1f22aec71bbda9a97006a59f0bf9f00de54533cb 100644 (file)
@@ -75,6 +75,7 @@
 
       color: #000;
       display: block;
+      width: fit-content;
       font-size: 16px;
       font-weight: $font-semibold;
     }
index afc01073c0c6a2d0f6d4f25aa8a84b31849e2523..e698b75ec1e256e96181a886c52c02b9edd45e24 100644 (file)
@@ -1,6 +1,6 @@
 import { from as observableFrom, Observable } from 'rxjs'
 import { concatAll, tap } from 'rxjs/operators'
-import { Component, OnDestroy, OnInit } from '@angular/core'
+import { Component, OnDestroy, OnInit, Inject, LOCALE_ID } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { Location } from '@angular/common'
 import { immutableAssign } from '@app/shared/misc/utils'
@@ -12,7 +12,8 @@ import { AbstractVideoList } from '../../shared/video/abstract-video-list'
 import { Video } from '../../shared/video/video.model'
 import { VideoService } from '../../shared/video/video.service'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoState } from '../../../../../shared/models/videos'
+import { VideoPrivacy, VideoState } from '../../../../../shared/models/videos'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-account-videos',
@@ -39,8 +40,10 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
     protected notificationsService: NotificationsService,
     protected confirmService: ConfirmService,
     protected location: Location,
+    protected screenService: ScreenService,
     protected i18n: I18n,
-    private videoService: VideoService
+    private videoService: VideoService,
+    @Inject(LOCALE_ID) private localeId: string
   ) {
     super()
 
@@ -131,12 +134,22 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
   }
 
   getStateLabel (video: Video) {
-    if (video.state.id === VideoState.PUBLISHED) return this.i18n('Published')
-
-    if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) return this.i18n('Waiting transcoding')
-    if (video.state.id === VideoState.TO_TRANSCODE) return this.i18n('To transcode')
+    let suffix: string
+
+    if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
+      suffix = this.i18n('Published')
+    } else if (video.scheduledUpdate) {
+      const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
+      suffix = this.i18n('Publication scheduled on ') + updateAt
+    } else if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
+      suffix = this.i18n('Waiting transcoding')
+    } else if (video.state.id === VideoState.TO_TRANSCODE) {
+      suffix = this.i18n('To transcode')
+    } else {
+      return ''
+    }
 
-    return this.i18n('Unknown state')
+    return ' - ' + suffix
   }
 
   protected buildVideoHeight () {
index 2d3f669948fc60c8309da3d1f6066bd787af771b..800d97b7fcbf9d6e39e8b46c13ff49f7554fed86 100644 (file)
@@ -12,6 +12,7 @@ import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
 import { tap } from 'rxjs/operators'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { Subscription } from 'rxjs'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-video-channel-videos',
@@ -37,6 +38,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
     protected notificationsService: NotificationsService,
     protected confirmService: ConfirmService,
     protected location: Location,
+    protected screenService: ScreenService,
     protected i18n: I18n,
     private videoChannelService: VideoChannelService,
     private videoService: VideoService
index 0bfe9f91685cd982d88f7999d595fe10191eab38..494cd9ea618d9c79f36c767f8933efe8ad25f7b7 100644 (file)
@@ -2,8 +2,8 @@ import { Component, OnInit } from '@angular/core'
 import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
 import { GuardsCheckStart, NavigationEnd, Router } from '@angular/router'
 import { AuthService, RedirectService, ServerService } from '@app/core'
-import { isInSmallView } from '@app/shared/misc/utils'
 import { is18nPath } from '../../../shared/models/i18n'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-app',
@@ -33,7 +33,8 @@ export class AppComponent implements OnInit {
     private authService: AuthService,
     private serverService: ServerService,
     private domSanitizer: DomSanitizer,
-    private redirectService: RedirectService
+    private redirectService: RedirectService,
+    private screenService: ScreenService
   ) { }
 
   get serverVersion () {
@@ -75,14 +76,14 @@ export class AppComponent implements OnInit {
     this.serverService.loadVideoPrivacies()
 
     // Do not display menu on small screens
-    if (isInSmallView()) {
+    if (this.screenService.isInSmallView()) {
       this.isMenuDisplayed = false
     }
 
     this.router.events.subscribe(
       e => {
         // User clicked on a link in the menu, change the page
-        if (e instanceof GuardsCheckStart && isInSmallView()) {
+        if (e instanceof GuardsCheckStart && this.screenService.isInSmallView()) {
           this.isMenuDisplayed = false
         }
       }
index 74363e6a1bac7eedeb7627c3319ae7d3f977d147..6323a7edf9b65e552a69e6b34b369048ea1655a8 100644 (file)
@@ -141,6 +141,7 @@ export class ServerService {
         )
         .subscribe(({ data, translations }) => {
           Object.keys(data)
+                .map(dataKey => parseInt(dataKey, 10))
                 .forEach(dataKey => {
                   const label = data[ dataKey ]
 
index 76fc5cf04318efa12faa86f552b05485c754c9f4..396be6f3b55d5f9d9cc5f75ba6868da2f450afa7 100644 (file)
@@ -15,6 +15,7 @@ export class VideoValidatorsService {
   readonly VIDEO_DESCRIPTION: BuildFormValidator
   readonly VIDEO_TAGS: BuildFormValidator
   readonly VIDEO_SUPPORT: BuildFormValidator
+  readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator
 
   constructor (private i18n: I18n) {
 
@@ -84,5 +85,12 @@ export class VideoValidatorsService {
         'maxlength': this.i18n('Video support cannot be more than 500 characters long.')
       }
     }
+
+    this.VIDEO_SCHEDULE_PUBLICATION_AT = {
+      VALIDATORS: [ ],
+      MESSAGES: {
+        'required': this.i18n('A date is required to schedule video update.')
+      }
+    }
   }
 }
index 8b932cd15a437f0b8f0f2b0533666ce67ff3fce3..db6f9e5c87602ed125502a4001a445124290d44b 100644 (file)
@@ -1,10 +1,10 @@
 import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
 import { Component, forwardRef, Input, OnInit } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { isInSmallView } from '@app/shared/misc/utils'
 import { MarkdownService } from '@app/videos/shared'
 import { Subject } from 'rxjs/Subject'
 import truncate from 'lodash-es/truncate'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-markdown-textarea',
@@ -35,7 +35,10 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
 
   private contentChanged = new Subject<string>()
 
-  constructor (private markdownService: MarkdownService) {}
+  constructor (
+    private screenService: ScreenService,
+    private markdownService: MarkdownService
+) {}
 
   ngOnInit () {
     this.contentChanged
@@ -76,7 +79,7 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
   }
 
   arePreviewsDisplayed () {
-    return isInSmallView() === false
+    return this.screenService.isInSmallView() === false
   }
 
   private updatePreviews () {
diff --git a/client/src/app/shared/i18n/i18n-primeng-calendar.ts b/client/src/app/shared/i18n/i18n-primeng-calendar.ts
new file mode 100644 (file)
index 0000000..b05852f
--- /dev/null
@@ -0,0 +1,94 @@
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Injectable } from '@angular/core'
+
+@Injectable()
+export class I18nPrimengCalendarService {
+  private readonly calendarLocale: any = {}
+
+  constructor (private i18n: I18n) {
+    this.calendarLocale = {
+      firstDayOfWeek: 0,
+      dayNames: [
+        this.i18n('Sunday'),
+        this.i18n('Monday'),
+        this.i18n('Tuesday'),
+        this.i18n('Wednesday'),
+        this.i18n('Thursday'),
+        this.i18n('Friday'),
+        this.i18n('Saturday')
+      ],
+
+      dayNamesShort: [
+        this.i18n({ value: 'Sun', description: 'Day name short' }),
+        this.i18n({ value: 'Mon', description: 'Day name short' }),
+        this.i18n({ value: 'Tue', description: 'Day name short' }),
+        this.i18n({ value: 'Wed', description: 'Day name short' }),
+        this.i18n({ value: 'Thu', description: 'Day name short' }),
+        this.i18n({ value: 'Fri', description: 'Day name short' }),
+        this.i18n({ value: 'Sat', description: 'Day name short' })
+      ],
+
+      dayNamesMin: [
+        this.i18n({ value: 'Su', description: 'Day name min' }),
+        this.i18n({ value: 'Mo', description: 'Day name min' }),
+        this.i18n({ value: 'Tu', description: 'Day name min' }),
+        this.i18n({ value: 'We', description: 'Day name min' }),
+        this.i18n({ value: 'Th', description: 'Day name min' }),
+        this.i18n({ value: 'Fr', description: 'Day name min' }),
+        this.i18n({ value: 'Sa', description: 'Day name min' })
+      ],
+
+      monthNames: [
+        this.i18n('January'),
+        this.i18n('February'),
+        this.i18n('March'),
+        this.i18n('April'),
+        this.i18n('May'),
+        this.i18n('June'),
+        this.i18n('July'),
+        this.i18n('August'),
+        this.i18n('September'),
+        this.i18n('October'),
+        this.i18n('November'),
+        this.i18n('December')
+      ],
+
+      monthNamesShort: [
+        this.i18n({ value: 'Jan', description: 'Month name short' }),
+        this.i18n({ value: 'Feb', description: 'Month name short' }),
+        this.i18n({ value: 'Mar', description: 'Month name short' }),
+        this.i18n({ value: 'Apr', description: 'Month name short' }),
+        this.i18n({ value: 'May', description: 'Month name short' }),
+        this.i18n({ value: 'Jun', description: 'Month name short' }),
+        this.i18n({ value: 'Jul', description: 'Month name short' }),
+        this.i18n({ value: 'Aug', description: 'Month name short' }),
+        this.i18n({ value: 'Sep', description: 'Month name short' }),
+        this.i18n({ value: 'Oct', description: 'Month name short' }),
+        this.i18n({ value: 'Nov', description: 'Month name short' }),
+        this.i18n({ value: 'Dec', description: 'Month name short' })
+      ],
+
+      today: this.i18n('Today'),
+
+      clear: this.i18n('Clear')
+    }
+  }
+
+  getCalendarLocale () {
+    return this.calendarLocale
+  }
+
+  getTimezone () {
+    const gmt = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1]
+    const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+
+    return `${timezone} - ${gmt}`
+  }
+
+  getDateFormat () {
+    return this.i18n({
+      value: 'yy-mm-dd ',
+      description: 'Date format in this locale.'
+    })
+  }
+}
diff --git a/client/src/app/shared/misc/screen.service.ts b/client/src/app/shared/misc/screen.service.ts
new file mode 100644 (file)
index 0000000..5b17a91
--- /dev/null
@@ -0,0 +1,23 @@
+import { Injectable, NgZone } from '@angular/core'
+
+@Injectable()
+export class ScreenService {
+  private windowInnerWidth: number
+
+  constructor (private zone: NgZone) {
+    this.windowInnerWidth = window.innerWidth
+
+    // Try to cache a little bit window.innerWidth
+    this.zone.runOutsideAngular(() => {
+      setInterval(() => this.windowInnerWidth = window.innerWidth, 500)
+    })
+  }
+
+  isInSmallView () {
+    return this.windowInnerWidth < 600
+  }
+
+  isInMobileView () {
+    return this.windowInnerWidth < 500
+  }
+}
index 2219ac802dd0693149c9752f4df3a958fe23dd9b..53aff1b24cf9ef781341f29ba364a561b5e6aaa5 100644 (file)
@@ -96,26 +96,12 @@ function lineFeedToHtml (obj: object, keyToNormalize: string) {
   })
 }
 
-// Try to cache a little bit window.innerWidth
-let windowInnerWidth = window.innerWidth
-setInterval(() => windowInnerWidth = window.innerWidth, 500)
-
-function isInSmallView () {
-  return windowInnerWidth < 600
-}
-
-function isInMobileView () {
-  return windowInnerWidth < 500
-}
-
 export {
   objectToUrlEncoded,
   getParameterByName,
   populateAsyncUserVideoChannels,
   getAbsoluteAPIUrl,
   dateToHuman,
-  isInSmallView,
-  isInMobileView,
   immutableAssign,
   objectToFormData,
   lineFeedToHtml
index b85445ef54e698e11b4e87aa84c3f5751dbdbe9f..97e49e7ab3ae44d31316cba36c5304000a9be8ce 100644 (file)
@@ -41,6 +41,8 @@ import {
   ResetPasswordValidatorsService,
   UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService
 } from '@app/shared/forms'
+import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @NgModule({
   imports: [
@@ -128,6 +130,9 @@ import {
     VideoCommentValidatorsService,
     VideoValidatorsService,
 
+    I18nPrimengCalendarService,
+    ScreenService,
+
     I18n
   ]
 })
index 1c84573da892d1a74179db3da3b6ab8792a25136..a468d3231fc97587126d4b4f5f771f60868a2118 100644 (file)
@@ -2,7 +2,6 @@ import { debounceTime } from 'rxjs/operators'
 import { ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { Location } from '@angular/common'
-import { isInMobileView } from '@app/shared/misc/utils'
 import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
 import { NotificationsService } from 'angular2-notifications'
 import { fromEvent, Observable, Subscription } from 'rxjs'
@@ -11,6 +10,7 @@ import { ComponentPagination } from '../rest/component-pagination.model'
 import { VideoSortField } from './sort-field.type'
 import { Video } from './video.model'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 export abstract class AbstractVideoList implements OnInit, OnDestroy {
   private static LINES_PER_PAGE = 4
@@ -41,6 +41,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
   protected abstract authService: AuthService
   protected abstract router: Router
   protected abstract route: ActivatedRoute
+  protected abstract screenService: ScreenService
   protected abstract i18n: I18n
   protected abstract location: Location
   protected abstract currentRoute: string
@@ -199,7 +200,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
   }
 
   private calcPageSizes () {
-    if (isInMobileView() || this.baseVideoWidth === -1) {
+    if (this.screenService.isInMobileView() || this.baseVideoWidth === -1) {
       this.pagination.itemsPerPage = 5
 
       // Video takes all the width
index f045a3acd294817ed4194b5875da509ca3f78356..78aed4f9f8d92523f9b9d9cd42686213233f8901 100644 (file)
@@ -1,8 +1,11 @@
 import { VideoDetails } from './video-details.model'
 import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum'
 import { VideoUpdate } from '../../../../../shared/models/videos'
+import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
 
 export class VideoEdit implements VideoUpdate {
+  static readonly SPECIAL_SCHEDULED_PRIVACY = -1
+
   category: number
   licence: number
   language: string
@@ -21,6 +24,7 @@ export class VideoEdit implements VideoUpdate {
   previewUrl: string
   uuid?: string
   id?: number
+  scheduleUpdate?: VideoScheduleUpdate
 
   constructor (videoDetails?: VideoDetails) {
     if (videoDetails) {
@@ -40,6 +44,8 @@ export class VideoEdit implements VideoUpdate {
       this.support = videoDetails.support
       this.thumbnailUrl = videoDetails.thumbnailUrl
       this.previewUrl = videoDetails.previewUrl
+
+      this.scheduleUpdate = videoDetails.scheduledUpdate
     }
   }
 
@@ -47,10 +53,22 @@ export class VideoEdit implements VideoUpdate {
     Object.keys(values).forEach((key) => {
       this[ key ] = values[ key ]
     })
+
+    // If schedule publication, the video is private and will be changed to public privacy
+    if (values['schedulePublicationAt']) {
+      const updateAt = (values['schedulePublicationAt'] as Date)
+      updateAt.setSeconds(0)
+
+      this.privacy = VideoPrivacy.PRIVATE
+      this.scheduleUpdate = {
+        updateAt: updateAt.toISOString(),
+        privacy: VideoPrivacy.PUBLIC
+      }
+    }
   }
 
-  toJSON () {
-    return {
+  toFormPatch () {
+    const json = {
       category: this.category,
       licence: this.licence,
       language: this.language,
@@ -64,5 +82,15 @@ export class VideoEdit implements VideoUpdate {
       channelId: this.channelId,
       privacy: this.privacy
     }
+
+    // Special case if we scheduled an update
+    if (this.scheduleUpdate) {
+      Object.assign(json, {
+        privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY,
+        schedulePublicationAt: new Date(this.scheduleUpdate.updateAt.toString())
+      })
+    }
+
+    return json
   }
 }
index e52f7dfb0824f2da18389a7a73a49d6a961c5a6c..86d8f6f742163f91fd8c1a486a7797162feeb2aa 100644 (file)
@@ -1,6 +1,6 @@
 import { Component, Input } from '@angular/core'
-import { isInMobileView } from '@app/shared/misc/utils'
 import { Video } from './video.model'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-video-thumbnail',
@@ -11,10 +11,12 @@ export class VideoThumbnailComponent {
   @Input() video: Video
   @Input() nsfw = false
 
+  constructor (private screenService: ScreenService) {}
+
   getImageUrl () {
     if (!this.video) return ''
 
-    if (isInMobileView()) {
+    if (this.screenService.isInMobileView()) {
       return this.video.previewUrl
     }
 
index 48a4b4260f9ff37c7381591837304d5bb959d42d..7f421dbbb68222d080abc007a47f7adc8c339c68 100644 (file)
@@ -6,6 +6,7 @@ import { getAbsoluteAPIUrl } from '../misc/utils'
 import { ServerConfig } from '../../../../../shared/models'
 import { Actor } from '@app/shared/actor/actor.model'
 import { peertubeTranslate } from '@app/shared/i18n/i18n-utils'
+import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
 
 export class Video implements VideoServerModel {
   by: string
@@ -38,6 +39,7 @@ export class Video implements VideoServerModel {
 
   waitTranscoding?: boolean
   state?: VideoConstant<VideoState>
+  scheduledUpdate?: VideoScheduleUpdate
 
   account: {
     id: number
@@ -109,6 +111,7 @@ export class Video implements VideoServerModel {
     this.language.label = peertubeTranslate(this.language.label, translations)
     this.privacy.label = peertubeTranslate(this.privacy.label, translations)
 
+    this.scheduledUpdate = hash.scheduledUpdate
     if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
   }
 
index d63915ad238b06f851ae88917f727eaaa21e3e6e..3af90e7ad63939fb2d4d333a406ffca4591bea22 100644 (file)
@@ -83,7 +83,8 @@ export class VideoService {
       waitTranscoding: video.waitTranscoding,
       commentsEnabled: video.commentsEnabled,
       thumbnailfile: video.thumbnailfile,
-      previewfile: video.previewfile
+      previewfile: video.previewfile,
+      scheduleUpdate: video.scheduleUpdate || undefined
     }
 
     const data = objectToFormData(body)
index 379cf7948d320766091f63d9fc239d1ac578f58f..447c5ab9b779b1c8fc100563e33bc345327d76d4 100644 (file)
@@ -88,6 +88,7 @@
             <select id="privacy" formControlName="privacy">
               <option></option>
               <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+              <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
             </select>
           </div>
 
           </div>
         </div>
 
+        <div *ngIf="schedulePublicationEnabled" class="form-group">
+          <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
+          <p-calendar
+            id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
+            [locale]="calendarLocale" [minDate]="minScheduledDate" [showTime]="true" [hideOnDateTimeSelect]="true"
+          >
+          </p-calendar>
+
+          <div *ngIf="formErrors.schedulePublicationAt" class="form-error">
+            {{ formErrors.schedulePublicationAt }}
+          </div>
+        </div>
+
         <div class="form-group form-group-checkbox">
           <input type="checkbox" id="nsfw" formControlName="nsfw" />
           <label for="nsfw"></label>
           <label i18n for="nsfw">This video contains mature or explicit content</label>
-          <my-help tooltipPlacement="top" helpType="custom" i18n-customHtml customHtml="Some instances do not list NSFW videos by default."></my-help>
+          <my-help
+            tooltipPlacement="top" helpType="custom" i18n-customHtml
+            customHtml="Some instances do not list videos containing mature or explicit content by default."
+          ></my-help>
         </div>
 
         <div class="form-group form-group-checkbox">
index 1295cf0987a65c9596910e0574b90c022381bb99..061eca4a79101f63db7cab1ab38f3ceddd8a4479 100644 (file)
     font-size: 15px;
   }
 
-  .root-tabset /deep/ > .nav {
-    margin-left: 15px;
-    margin-bottom: 15px;
-
-    .nav-link {
-      display: flex !important;
-      align-items: center;
-      height: 30px !important;
-      padding: 0 15px !important;
-    }
-  }
-
   .advanced-settings .form-group {
     margin-bottom: 20px;
   }
   }
 }
 
+p-calendar {
+  display: block;
+
+  /deep/ {
+    input,
+    .ui-calendar {
+      width: 100%;
+    }
+
+    input {
+      @include peertube-input-text(100%);
+      color: #000;
+    }
+  }
+}
+
 /deep/ {
+  .root-tabset > .nav {
+    margin-left: 15px;
+    margin-bottom: 15px;
+
+    .nav-link {
+      display: flex !important;
+      align-items: center;
+      height: 30px !important;
+      padding: 0 15px !important;
+    }
+  }
+
   .ng2-tag-input {
     border: none !important;
   }
index ee4fd5dc1b24a0ee9b98b041cbdd0f31694a7b3f..24418fc4f2a12e1a56682a6eaccbefaccad91802 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, Input, OnInit } from '@angular/core'
-import { FormGroup, ValidatorFn } from '@angular/forms'
+import { FormGroup, ValidatorFn, Validators } from '@angular/forms'
 import { ActivatedRoute, Router } from '@angular/router'
 import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared'
 import { NotificationsService } from 'angular2-notifications'
@@ -7,6 +7,7 @@ import { ServerService } from '../../../core/server'
 import { VideoEdit } from '../../../shared/video/video-edit.model'
 import { map } from 'rxjs/operators'
 import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
 
 @Component({
   selector: 'my-video-edit',
@@ -20,16 +21,26 @@ export class VideoEditComponent implements OnInit {
   @Input() validationMessages: FormReactiveValidationMessages = {}
   @Input() videoPrivacies = []
   @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
+  @Input() schedulePublicationPossible = true
+
+  // So that it can be accessed in the template
+  readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
 
   videoCategories = []
   videoLicences = []
   videoLanguages = []
-  video: VideoEdit
 
   tagValidators: ValidatorFn[]
   tagValidatorsMessages: { [ name: string ]: string }
 
+  schedulePublicationEnabled = false
+
   error: string = null
+  calendarLocale: any = {}
+  minScheduledDate = new Date()
+
+  calendarTimezone: string
+  calendarDateFormat: string
 
   constructor (
     private formValidatorService: FormValidatorService,
@@ -37,10 +48,15 @@ export class VideoEditComponent implements OnInit {
     private route: ActivatedRoute,
     private router: Router,
     private notificationsService: NotificationsService,
-    private serverService: ServerService
+    private serverService: ServerService,
+    private i18nPrimengCalendarService: I18nPrimengCalendarService
   ) {
     this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS
     this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
+
+    this.calendarLocale = this.i18nPrimengCalendarService.getCalendarLocale()
+    this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone()
+    this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat()
   }
 
   updateForm () {
@@ -64,7 +80,8 @@ export class VideoEditComponent implements OnInit {
       tags: null,
       thumbnailfile: null,
       previewfile: null,
-      support: this.videoValidatorsService.VIDEO_SUPPORT
+      support: this.videoValidatorsService.VIDEO_SUPPORT,
+      schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT
     }
 
     this.formValidatorService.updateForm(
@@ -75,6 +92,52 @@ export class VideoEditComponent implements OnInit {
       defaultValues
     )
 
+    this.trackChannelChange()
+    this.trackPrivacyChange()
+  }
+
+  ngOnInit () {
+    this.updateForm()
+
+    this.videoCategories = this.serverService.getVideoCategories()
+    this.videoLicences = this.serverService.getVideoLicences()
+    this.videoLanguages = this.serverService.getVideoLanguages()
+
+    setTimeout(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
+  }
+
+  private trackPrivacyChange () {
+    // We will update the "support" field depending on the channel
+    this.form.controls[ 'privacy' ]
+      .valueChanges
+      .pipe(map(res => parseInt(res.toString(), 10)))
+      .subscribe(
+        newPrivacyId => {
+          this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
+
+          // Value changed
+          const scheduleControl = this.form.get('schedulePublicationAt')
+          const waitTranscodingControl = this.form.get('waitTranscoding')
+
+          if (this.schedulePublicationEnabled) {
+            scheduleControl.setValidators([ Validators.required ])
+
+            waitTranscodingControl.disable()
+            waitTranscodingControl.setValue(false)
+          } else {
+            scheduleControl.clearValidators()
+
+            waitTranscodingControl.enable()
+            waitTranscodingControl.setValue(true)
+          }
+
+          scheduleControl.updateValueAndValidity()
+          waitTranscodingControl.updateValueAndValidity()
+        }
+      )
+  }
+
+  private trackChannelChange () {
     // We will update the "support" field depending on the channel
     this.form.controls[ 'channelId' ]
       .valueChanges
@@ -108,14 +171,6 @@ export class VideoEditComponent implements OnInit {
       )
   }
 
-  ngOnInit () {
-    this.updateForm()
-
-    this.videoCategories = this.serverService.getVideoCategories()
-    this.videoLicences = this.serverService.getVideoLicences()
-    this.videoLanguages = this.serverService.getVideoLanguages()
-  }
-
   private updateSupportField (support: string) {
     return this.form.patchValue({ support: support || '' })
   }
index 76eba9c1971f11a928647975832f4c7bae6346cc..6bf3e34b122eabca855dbb7c78631127c716a825 100644 (file)
@@ -4,10 +4,12 @@ import { TagInputModule } from 'ngx-chips'
 import { SharedModule } from '../../../shared/'
 import { VideoEditComponent } from './video-edit.component'
 import { VideoImageComponent } from './video-image.component'
+import { CalendarModule } from 'primeng/components/calendar/calendar'
 
 @NgModule({
   imports: [
     TagInputModule,
+    CalendarModule,
 
     SharedModule
   ],
@@ -20,6 +22,7 @@ import { VideoImageComponent } from './video-image.component'
   exports: [
     TagInputModule,
     TabsModule,
+    CalendarModule,
 
     VideoEditComponent
   ],
index f00cfe0162026008ded6f0af53ed2e6936e3f7df..07034e4e1deae17903887468e977b461dde350ff 100644 (file)
@@ -27,6 +27,7 @@
         <div class="peertube-select-container">
           <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
             <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+            <option [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
           </select>
         </div>
       </div>
index 85afd0caa1c2bf075c2548d712fd653c72a0a04a..3ddeda1095f87aedb1f99095513c65bf4d1baf15 100644 (file)
@@ -27,6 +27,9 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
 export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
   @ViewChild('videofileInput') videofileInput
 
+  // So that it can be accessed in the template
+  readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
+
   isUploadingVideo = false
   isUpdatingVideo = false
   videoUploaded = false
index 73b2bc08f72a15c820f04a557ccd45d3ca79b390..5cb16c8ab8111f5ed0b54b200a999e8e29a8b7f2 100644 (file)
@@ -6,7 +6,7 @@
   <form novalidate [formGroup]="form">
 
     <my-video-edit
-      [form]="form" [formErrors]="formErrors"
+      [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
       [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
     ></my-video-edit>
 
index 0266164aff350ed3cd30f43d11a08950e076568b..c4e6f44de0d5e4a51dd3c31065f8ddbf34445ba8 100644 (file)
@@ -24,6 +24,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   isUpdatingVideo = false
   videoPrivacies = []
   userVideoChannels = []
+  schedulePublicationPossible = false
 
   constructor (
     protected formValidatorService: FormValidatorService,
@@ -70,13 +71,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
             this.userVideoChannels = videoChannels
 
             // We cannot set private a video that was not private
-            if (video.privacy.id !== VideoPrivacy.PRIVATE) {
-              const newVideoPrivacies = []
-              for (const p of this.videoPrivacies) {
-                if (p.id !== VideoPrivacy.PRIVATE) newVideoPrivacies.push(p)
-              }
-
-              this.videoPrivacies = newVideoPrivacies
+            if (this.video.privacy !== VideoPrivacy.PRIVATE) {
+              this.videoPrivacies = this.videoPrivacies.filter(p => p.id !== VideoPrivacy.PRIVATE)
+            } else { // We can schedule video publication only if it it is private
+              this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
             }
 
             this.hydrateFormFromVideo()
@@ -123,7 +121,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   }
 
   private hydrateFormFromVideo () {
-    this.form.patchValue(this.video.toJSON())
+    this.form.patchValue(this.video.toFormPatch())
 
     const objects = [
       {
index 8bd5c00ff6637772ea1ce6fa8e0cdc0f26b032fe..208375e334d97bd653b717081a733448e88c41d2 100644 (file)
@@ -3,10 +3,14 @@
   <div id="video-element-wrapper">
   </div>
 
-  <div i18n id="warning-transcoding" class="alert alert-warning" *ngIf="isVideoToTranscode()">
+  <div i18n class="alert alert-warning" *ngIf="isVideoToTranscode()">
     The video is being transcoded, it may not work properly.
   </div>
 
+  <div i18n class="alert alert-info" *ngIf="hasVideoScheduledPublication()">
+    This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}
+  </div>
+
   <!-- Video information -->
   <div *ngIf="video" class="margin-content video-bottom">
     <div class="video-info">
index ae8bdccafaf0b6b836d7827eabac27341aab8d27..71770c93b9fc1effa557713310ced396bcfa3e1d 100644 (file)
@@ -28,7 +28,7 @@
   }
 }
 
-#warning-transcoding {
+.alert {
   text-align: center;
 }
 
index a760c03e85b45462cd49b80cf900fd9e8ba95876..72e96ca93862fe92a3b49eec65b5da47a86c6aad 100644 (file)
@@ -280,6 +280,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     return this.video && this.video.state.id === VideoState.TO_TRANSCODE
   }
 
+  hasVideoScheduledPublication () {
+    return this.video && this.video.scheduledUpdate !== undefined
+  }
+
   private updateVideoDescription (description: string) {
     this.video.description = description
     this.setVideoDescriptionHTML()
index 2fd82a94065d368cc9892b6a1e41237db7c21616..dbe1d937d2e4654b328b55dfbfdb9c88a0a1349c 100644 (file)
@@ -9,6 +9,7 @@ import { VideoSortField } from '../../shared/video/sort-field.type'
 import { VideoService } from '../../shared/video/video.service'
 import { VideoFilter } from '../../../../../shared/models/videos/video-query.type'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-videos-local',
@@ -28,6 +29,7 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
     protected authService: AuthService,
     protected location: Location,
     protected i18n: I18n,
+    protected screenService: ScreenService,
     private videoService: VideoService
   ) {
     super()
index 8183357f87bd0d0b450b062c153d8fe09bf79001..004a491683b9ac6dfb6e05721392952f4fa06592 100644 (file)
@@ -8,6 +8,7 @@ import { AbstractVideoList } from '../../shared/video/abstract-video-list'
 import { VideoSortField } from '../../shared/video/sort-field.type'
 import { VideoService } from '../../shared/video/video.service'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-videos-recently-added',
@@ -26,6 +27,7 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
     protected notificationsService: NotificationsService,
     protected authService: AuthService,
     protected i18n: I18n,
+    protected screenService: ScreenService,
     private videoService: VideoService
   ) {
     super()
index b6434f3476cbdead09e3e09c94ba8a24273aa8f0..33ed3f00e4c2b6d465c363121ca7de2a06010d8e 100644 (file)
@@ -9,6 +9,7 @@ import { AuthService } from '../../core/auth'
 import { AbstractVideoList } from '../../shared/video/abstract-video-list'
 import { VideoService } from '../../shared/video/video.service'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-videos-search',
@@ -32,6 +33,7 @@ export class VideoSearchComponent extends AbstractVideoList implements OnInit, O
     protected authService: AuthService,
     protected location: Location,
     protected i18n: I18n,
+    protected screenService: ScreenService,
     private videoService: VideoService,
     private redirectService: RedirectService
   ) {
index e56b749d11dc9db38ae2ca6801a985f47e0c3823..f2174aa145cb4daa1a08ada098fd75ad3764c444 100644 (file)
@@ -8,6 +8,7 @@ import { AbstractVideoList } from '../../shared/video/abstract-video-list'
 import { VideoSortField } from '../../shared/video/sort-field.type'
 import { VideoService } from '../../shared/video/video.service'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   selector: 'my-videos-trending',
@@ -25,6 +26,7 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
     protected notificationsService: NotificationsService,
     protected authService: AuthService,
     protected location: Location,
+    protected screenService: ScreenService,
     protected i18n: I18n,
     private videoService: VideoService
   ) {
index 4006c91289475ea7b186b809c7d11187c810b8e2..dae0c52c213e2b654b16ff2ab61abd9739d52865 100644 (file)
@@ -6,14 +6,14 @@ $icon-font-path: '../../node_modules/bootstrap-sass/assets/fonts/bootstrap/';
 
 @import '_fonts';
 
-@import '~primeng/resources/themes/bootstrap/theme.css';
-@import '~primeng/resources/primeng.css';
 @import '~video.js/dist/video-js.css';
 
 $assets-path: '../assets/';
 @import './player/player';
 @import './loading-bar';
 
+@import './primeng-custom';
+
 [hidden] {
   display: none !important;
 }
@@ -142,126 +142,6 @@ label {
   to { transform: scale(1) rotate(360deg);}
 }
 
-// ngprime data table customizations
-p-table {
-  font-size: 15px !important;
-
-  td {
-    border: 1px solid #E5E5E5 !important;
-    padding-left: 15px !important;
-    overflow: hidden !important;
-    text-overflow: ellipsis !important;
-    white-space: nowrap !important;
-  }
-
-  tr {
-    background-color: #fff !important;
-    height: 46px;
-  }
-
-  .ui-table-tbody {
-    tr {
-      &:hover {
-        background-color: #f0f0f0 !important;
-      }
-
-      &:not(:hover) {
-        .action-cell * {
-          display: none !important;
-        }
-      }
-
-      &:first-child td {
-        border-top: none !important;
-      }
-
-      &:last-child td {
-        border-bottom: none !important;
-      }
-    }
-
-    .expander {
-      cursor: pointer;
-      position: relative;
-      top: 1px;
-    }
-  }
-
-  th {
-    border: none !important;
-    border-bottom: 1px solid #f0f0f0 !important;
-    text-align: left !important;
-    padding: 5px 0 5px 15px !important;
-    font-weight: $font-semibold !important;
-    color: #000 !important;
-
-    &.ui-sortable-column:hover {
-      background-color: #f0f0f0 !important;
-      border: 1px solid #f0f0f0 !important;
-      border-width: 0 1px !important;
-
-      &:first-child {
-        border-width: 0 1px 0 0 !important;
-      }
-    }
-
-    &.ui-state-highlight {
-      background-color: #fff !important;
-
-      .fa {
-        @extend .glyphicon;
-        font-size: 11px;
-
-        &.fa-sort-asc {
-          @extend .glyphicon-triangle-top;
-        }
-
-        &.fa-sort-desc {
-          @extend .glyphicon-triangle-bottom;
-        }
-      }
-    }
-  }
-
-  .action-cell {
-    width: 250px !important;
-    padding: 0 !important;
-    text-align: center;
-
-    my-edit-button + my-delete-button {
-      margin-left: 5px;
-    }
-  }
-
-  p-paginator {
-    .ui-paginator-bottom {
-      position: relative;
-      border: none !important;
-      border: 1px solid #f0f0f0 !important;
-      height: 40px;
-      display: flex;
-      justify-content: center;
-      align-items: center;
-
-      a {
-        color: #000 !important;
-        font-weight: $font-semibold !important;
-        margin-right: 20px !important;
-        outline: 0 !important;
-        border-radius: 3px !important;
-        padding: 5px 2px !important;
-
-        &.ui-state-active {
-          &, &:hover, &:active, &:focus {
-            color: #fff !important;
-            background-color: $orange-color !important;
-          }
-        }
-      }
-    }
-  }
-}
-
 // Bootstrap customizations
 .dropdown-menu {
   border-radius: 3px;
@@ -352,6 +232,8 @@ tabset:not(.bootstrap) {
 }
 
 tabset.bootstrap {
+  margin-left: 0;
+
   .nav-item .nav-link {
     &, & a {
       color: #000;
index 748c98afa77ec6d831ccc6b075184203fba04f56..3904751c228c3486d6a8693943ade089dbd172e2 100644 (file)
     cursor: pointer;
     display: inline;
   }
+
+  &[disabled] + label,
+  &[disabled] + label + label{
+    opacity: 0.5;
+    cursor: default;
+  }
 }
 
 
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss
new file mode 100644 (file)
index 0000000..b28b20e
--- /dev/null
@@ -0,0 +1,174 @@
+@import '_variables';
+@import '_mixins';
+
+@import '~primeng/resources/primeng.css';
+@import '~primeng/resources/themes/bootstrap/theme.css';
+
+@mixin glyphicon-light {
+  font-family: 'Glyphicons Halflings';
+  text-decoration: none !important;
+  color: #000 !important;
+}
+
+// data table customizations
+p-table {
+  font-size: 15px !important;
+
+  td {
+    border: 1px solid #E5E5E5 !important;
+    padding-left: 15px !important;
+    overflow: hidden !important;
+    text-overflow: ellipsis !important;
+    white-space: nowrap !important;
+  }
+
+  tr {
+    background-color: #fff !important;
+    height: 46px;
+  }
+
+  .ui-table-tbody {
+    tr {
+      &:hover {
+        background-color: #f0f0f0 !important;
+      }
+
+      &:not(:hover) {
+        .action-cell * {
+          display: none !important;
+        }
+      }
+
+      &:first-child td {
+        border-top: none !important;
+      }
+
+      &:last-child td {
+        border-bottom: none !important;
+      }
+    }
+
+    .expander {
+      cursor: pointer;
+      position: relative;
+      top: 1px;
+    }
+  }
+
+  th {
+    border: none !important;
+    border-bottom: 1px solid #f0f0f0 !important;
+    text-align: left !important;
+    padding: 5px 0 5px 15px !important;
+    font-weight: $font-semibold !important;
+    color: #000 !important;
+
+    &.ui-sortable-column:hover {
+      background-color: #f0f0f0 !important;
+      border: 1px solid #f0f0f0 !important;
+      border-width: 0 1px !important;
+
+      &:first-child {
+        border-width: 0 1px 0 0 !important;
+      }
+    }
+
+    &.ui-state-highlight {
+      background-color: #fff !important;
+
+      .pi {
+        @extend .glyphicon;
+
+        color: #000;
+        font-size: 11px;
+
+        &.pi-sort-up {
+          @extend .glyphicon-triangle-top;
+        }
+
+        &.pi-sort-down {
+          @extend .glyphicon-triangle-bottom;
+        }
+      }
+    }
+  }
+
+  .action-cell {
+    width: 250px !important;
+    padding: 0 !important;
+    text-align: center;
+
+    my-edit-button + my-delete-button {
+      margin-left: 5px;
+    }
+  }
+
+  p-paginator {
+    .ui-paginator-bottom {
+      position: relative;
+      border: 1px solid #f0f0f0 !important;
+      height: 40px;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+
+      .ui-paginator-pages {
+        height: auto !important;
+
+        a {
+          color: #000 !important;
+          font-weight: $font-semibold !important;
+          margin-right: 20px !important;
+          outline: 0 !important;
+          border-radius: 3px !important;
+          padding: 5px 2px !important;
+          height: auto !important;
+
+          &.ui-state-active {
+            &, &:hover, &:active, &:focus {
+              color: #fff !important;
+              background-color: $orange-color !important;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+// PrimeNG calendar tweaks
+p-calendar .ui-datepicker {
+  a {
+    @include disable-default-a-behaviour;
+  }
+
+  .ui-datepicker-header {
+
+    .ui-datepicker-year {
+      margin-left: 5px;
+    }
+
+    .ui-datepicker-next {
+      @extend .glyphicon-chevron-right;
+      @include glyphicon-light;
+    }
+
+    .ui-datepicker-prev {
+      @extend .glyphicon-chevron-left;
+      @include glyphicon-light;
+    }
+  }
+
+  .ui-timepicker {
+
+    .pi.pi-chevron-up {
+      @extend .glyphicon-chevron-up;
+      @include glyphicon-light;
+    }
+
+    .pi.pi-chevron-down {
+      @extend .glyphicon-chevron-down;
+      @include glyphicon-light;
+    }
+  }
+}
\ No newline at end of file
index e2d0da541362c11e276036a88d818dc3e75908f8..b9b13c18cfda3d45b8479809b836eaaf021724cc 100644 (file)
@@ -7656,9 +7656,9 @@ pretty-error@^2.0.2:
     renderkid "^2.0.1"
     utila "~0.4"
 
-primeng@^5.2.6:
-  version "5.2.7"
-  resolved "https://registry.yarnpkg.com/primeng/-/primeng-5.2.7.tgz#9dcf461b6a82ea46de85751dc235ea82303e64b1"
+primeng@^6.0.0-rc.1:
+  version "6.0.0-rc.1"
+  resolved "https://registry.yarnpkg.com/primeng/-/primeng-6.0.0-rc.1.tgz#038e5657a5395e08a5c1fd9312b12cac1a44b527"
 
 private@^0.1.6, private@^0.1.8, private@~0.1.5:
   version "0.1.8"
index 1643785058098a704ea1528600ec35488b6c3902..53902071ce378bd5c8a0b12f117fb69e00f723d3 100644 (file)
@@ -8,8 +8,6 @@ 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')
@@ -98,8 +96,8 @@ const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 // 2 days
 // 1 hour
 let SCHEDULER_INTERVALS_MS = {
   badActorFollow: 60000 * 60, // 1 hour
-  removeOldJobs: 60000 * 60, // 1 jour
-  updateVideos: 60000 * 1, // 1 minute
+  removeOldJobs: 60000 * 60, // 1 hour
+  updateVideos: 60000 // 1 minute
 }
 
 // ---------------------------------------------------------------------------
index d123c3ceba6cb40f28390a00c9038ff6c1f7058f..a964648fd962a386001b306a679609f0ae055f9d 100644 (file)
@@ -33,7 +33,9 @@ export class UpdateVideosScheduler extends AbstractScheduler {
     }
   }
 
-  private updateVideos () {
+  private async updateVideos () {
+    if (!await ScheduleVideoUpdateModel.areVideosToUpdate()) return undefined
+
     return sequelizeTypescript.transaction(async t => {
       const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate(t)
 
index 9fe5a253b5d0a2f78030f5b4ad6f7d40517c240c..da17b4a68d44d5f16bce41eb7784a4e27cdeed3d 100644 (file)
@@ -223,7 +223,7 @@ const videosUpdateValidator = [
 
     if (video.privacy !== VideoPrivacy.PRIVATE && req.body.privacy === VideoPrivacy.PRIVATE) {
       return res.status(409)
-        .json({ error: 'Cannot set "private" a video that was not private anymore.' })
+        .json({ error: 'Cannot set "private" a video that was not private.' })
         .end()
     }
 
index d4e37beb5ce245eaccb95e7a3923baa9257b8a6f..3cf5f6c99b06591fe81260f6479553d348d0eedc 100644 (file)
@@ -25,7 +25,7 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
   @AllowNull(true)
   @Default(null)
   @Column
-  privacy: VideoPrivacy
+  privacy: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED
 
   @CreatedAt
   createdAt: Date
@@ -45,6 +45,21 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
   })
   Video: VideoModel
 
+  static areVideosToUpdate () {
+    const query = {
+      logging: false,
+      attributes: [ 'id' ],
+      where: {
+        updateAt: {
+          [Sequelize.Op.lte]: new Date()
+        }
+      }
+    }
+
+    return ScheduleVideoUpdateModel.findOne(query)
+      .then(res => !!res)
+  }
+
   static listVideosToUpdate (t: Transaction) {
     const query = {
       where: {
@@ -68,4 +83,10 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
     return ScheduleVideoUpdateModel.findAll(query)
   }
 
+  toFormattedJSON () {
+    return {
+      updateAt: this.updateAt,
+      privacy: this.privacy || undefined
+    }
+  }
 }
index 440f4d17161a7a1c78f4ad6fa51baec9431acd98..0041e4d387a94a6d58a8b9ff12f72659fcb08c96 100644 (file)
@@ -97,7 +97,8 @@ export enum ScopeNames {
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
   WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
   WITH_TAGS = 'WITH_TAGS',
-  WITH_FILES = 'WITH_FILES'
+  WITH_FILES = 'WITH_FILES',
+  WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE'
 }
 
 @Scopes({
@@ -286,6 +287,14 @@ export enum ScopeNames {
         required: true
       }
     ]
+  },
+  [ScopeNames.WITH_SCHEDULED_UPDATE]: {
+    include: [
+      {
+        model: () => ScheduleVideoUpdateModel.unscoped(),
+        required: false
+      }
+    ]
   }
 })
 @Table({
@@ -843,7 +852,7 @@ export class VideoModel extends Model<VideoModel> {
     }
 
     return VideoModel
-      .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
+      .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE ])
       .findById(id, options)
   }
 
@@ -869,7 +878,7 @@ export class VideoModel extends Model<VideoModel> {
     }
 
     return VideoModel
-      .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
+      .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE ])
       .findOne(options)
   }
 
@@ -1022,9 +1031,9 @@ export class VideoModel extends Model<VideoModel> {
 
   toFormattedJSON (options?: {
     additionalAttributes: {
-      state: boolean,
-      waitTranscoding: boolean,
-      scheduledUpdate: boolean
+      state?: boolean,
+      waitTranscoding?: boolean,
+      scheduledUpdate?: boolean
     }
   }): Video {
     const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
@@ -1084,18 +1093,18 @@ export class VideoModel extends Model<VideoModel> {
     }
 
     if (options) {
-      if (options.additionalAttributes.state) {
+      if (options.additionalAttributes.state === true) {
         videoObject.state = {
           id: this.state,
           label: VideoModel.getStateLabel(this.state)
         }
       }
 
-      if (options.additionalAttributes.waitTranscoding) {
+      if (options.additionalAttributes.waitTranscoding === true) {
         videoObject.waitTranscoding = this.waitTranscoding
       }
 
-      if (options.additionalAttributes.scheduledUpdate && this.ScheduleVideoUpdate) {
+      if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
         videoObject.scheduledUpdate = {
           updateAt: this.ScheduleVideoUpdate.updateAt,
           privacy: this.ScheduleVideoUpdate.privacy || undefined
@@ -1107,7 +1116,11 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   toFormattedDetailsJSON (): VideoDetails {
-    const formattedJson = this.toFormattedJSON()
+    const formattedJson = this.toFormattedJSON({
+      additionalAttributes: {
+        scheduledUpdate: true
+      }
+    })
 
     const detailsJson = {
       support: this.support,
index 04bed3b441be808f74f022b0183a48c622246852..abbea6ba34b5a49bbb4c6a76e96a1664040efc3f 100644 (file)
@@ -291,6 +291,23 @@ describe('Test videos API validator', function () {
       await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
     })
 
+    it('Should fail with a bad schedule update (miss updateAt)', async function () {
+      const fields = immutableAssign(baseCorrectParams, { 'scheduleUpdate[privacy]': VideoPrivacy.PUBLIC })
+      const attaches = baseCorrectAttaches
+
+      await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
+    })
+
+    it('Should fail with a bad schedule update (wrong updateAt)', async function () {
+      const fields = immutableAssign(baseCorrectParams, {
+        'scheduleUpdate[privacy]': VideoPrivacy.PUBLIC,
+        'scheduleUpdate[updateAt]': 'toto'
+      })
+      const attaches = baseCorrectAttaches
+
+      await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
+    })
+
     it('Should fail without an input file', async function () {
       const fields = baseCorrectParams
       const attaches = {}
@@ -494,6 +511,18 @@ describe('Test videos API validator', function () {
       await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields })
     })
 
+    it('Should fail with a bad schedule update (miss updateAt)', async function () {
+      const fields = immutableAssign(baseCorrectParams, { scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } })
+
+      await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields })
+    })
+
+    it('Should fail with a bad schedule update (wrong updateAt)', async function () {
+      const fields = immutableAssign(baseCorrectParams, { scheduleUpdate: { updateAt: 'toto', privacy: VideoPrivacy.PUBLIC } })
+
+      await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields })
+    })
+
     it('Should fail with an incorrect thumbnail file', async function () {
       const fields = baseCorrectParams
       const attaches = {
index 8b87ea8554f191c8b2377d4ca31125b804b96db8..a260fa4dacb0a0af661c5edcc76db034ca5fd964 100644 (file)
@@ -5,11 +5,14 @@ import 'mocha'
 import { VideoPrivacy } from '../../../../shared/models/videos'
 import {
   doubleFollow,
-  flushAndRunMultipleServers, getMyVideos,
+  flushAndRunMultipleServers,
+  getMyVideos,
   getVideosList,
+  getVideoWithToken,
   killallServers,
   ServerInfo,
-  setAccessTokensToServers, updateVideo,
+  setAccessTokensToServers,
+  updateVideo,
   uploadVideo,
   wait
 } from '../../utils'
@@ -69,17 +72,22 @@ describe('Test video update scheduler', 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)
+    const videoFromList = res.body.data[0]
+    const res2 = await getVideoWithToken(servers[0].url, servers[0].accessToken, videoFromList.uuid)
+    const videoFromGet = res2.body
+
+    for (const video of [ videoFromList, videoFromGet ]) {
+      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 wait(15000)
     await waitJobs(servers)
 
     for (const server of servers) {
@@ -144,7 +152,7 @@ describe('Test video update scheduler', function () {
   it('Should wait some seconds and have the updated video in public privacy', async function () {
     this.timeout(20000)
 
-    await wait(10000)
+    await wait(15000)
     await waitJobs(servers)
 
     for (const server of servers) {
index 531eafe5415c8cebf2b7689fac07d4815d70b003..190d637832719d744d45f8430b47584134ec1686 100644 (file)
@@ -1,4 +1,5 @@
 import { VideoPrivacy } from './video-privacy.enum'
+import { VideoScheduleUpdate } from './video-schedule-update.model'
 
 export interface VideoCreate {
   category?: number
@@ -13,8 +14,5 @@ export interface VideoCreate {
   tags?: string[]
   commentsEnabled?: boolean
   privacy: VideoPrivacy
-  scheduleUpdate?: {
-    updateAt: Date
-    privacy?: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED
-  }
+  scheduleUpdate?: VideoScheduleUpdate
 }
diff --git a/shared/models/videos/video-schedule-update.model.ts b/shared/models/videos/video-schedule-update.model.ts
new file mode 100644 (file)
index 0000000..b865c16
--- /dev/null
@@ -0,0 +1,6 @@
+import { VideoPrivacy } from './video-privacy.enum'
+
+export interface VideoScheduleUpdate {
+  updateAt: Date | string
+  privacy?: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED // Cannot schedule an update to PRIVATE
+}
index fc0df681085f6df80e316a6c81697dd59acc3942..ed141a824f6959e169ac2ba9c573e1f60da68b66 100644 (file)
@@ -1,4 +1,5 @@
 import { VideoPrivacy } from './video-privacy.enum'
+import { VideoScheduleUpdate } from './video-schedule-update.model'
 
 export interface VideoUpdate {
   name?: string
@@ -15,8 +16,5 @@ export interface VideoUpdate {
   channelId?: number
   thumbnailfile?: Blob
   previewfile?: Blob
-  scheduleUpdate?: {
-    updateAt: Date
-    privacy?: VideoPrivacy
-  }
+  scheduleUpdate?: VideoScheduleUpdate
 }
index 676354ce374095820d8b8292e21e3cead464f7c3..f88f381cb8d52af06f6e7d23c07867ca2af30845 100644 (file)
@@ -3,6 +3,7 @@ import { Account } from '../actors'
 import { Avatar } from '../avatars/avatar.model'
 import { VideoChannel } from './video-channel.model'
 import { VideoPrivacy } from './video-privacy.enum'
+import { VideoScheduleUpdate } from './video-schedule-update.model'
 
 export interface VideoConstant <T> {
   id: T
@@ -43,10 +44,7 @@ export interface Video {
 
   waitTranscoding?: boolean
   state?: VideoConstant<VideoState>
-  scheduledUpdate?: {
-    updateAt: Date | string
-    privacy?: VideoPrivacy
-  }
+  scheduledUpdate?: VideoScheduleUpdate
 
   account: {
     id: number
index e1bf61b06297f4f01355e504e52d3303177e063a..24017e674ad059eaf0b482b1995bf3997be69fe2 100644 (file)
           <a href="#definition-GetMeVideoRating"> GetMeVideoRating </a>
           <a href="#definition-RegisterUser"> RegisterUser </a>
           <a href="#definition-VideoChannelInput"> VideoChannelInput </a>
+          <a href="#definition-ScheduleVideoUpdate"> ScheduleVideoUpdate </a>
         </nav>
       </div>
       <div id="docs" class="row collapse expanded drawer" data-drawer>
                       <p>Video privacy</p>
                     </div>
                   </div>
+                  <div class="prop-row prop-group">
+                    <div class="prop-name">
+                      <div class="prop-title">scheduleUpdate</div>
+                      <div class="prop-subtitle"> in formData </div>
+                      <div class="prop-subtitle">
+                        <span class="json-property-type">[object Object]</span>
+                        <span class="json-property-range" title="Value limits"></span>
+                      </div>
+                    </div>
+                    <div class="prop-value">
+                      <p class="no-description">(no description)</p>
+                    </div>
+                  </div>
                 </section>
               </div>
               <div class="doc-examples"></div>
                       <span class="json-property-required"></span>
                       <div class="prop-subtitle"> in formData </div>
                       <div class="prop-subtitle">
-                        <span class="json-property-type">string</span>
-                        <span class="json-property-enum" title="Possible values">
-                          <span class="json-property-enum-item">Public</span>,
-                          <span class="json-property-enum-item">Unlisted</span>,
-                          <span class="json-property-enum-item">Private</span>
-                        </span>
+                        <span class="json-property-type">[object Object]</span>
                         <span class="json-property-range" title="Value limits"></span>
                       </div>
                     </div>
                       <p>Video privacy</p>
                     </div>
                   </div>
+                  <div class="prop-row prop-group">
+                    <div class="prop-name">
+                      <div class="prop-title">scheduleUpdate</div>
+                      <div class="prop-subtitle"> in formData </div>
+                      <div class="prop-subtitle">
+                        <span class="json-property-type">[object Object]</span>
+                        <span class="json-property-range" title="Value limits"></span>
+                      </div>
+                    </div>
+                    <div class="prop-value">
+                      <p class="no-description">(no description)</p>
+                    </div>
+                  </div>
                 </section>
               </div>
               <div class="doc-examples"></div>
   <span class="hljs-attr">&quot;name&quot;</span>: <span class="hljs-string">&quot;string&quot;</span>,
   <span class="hljs-attr">&quot;description&quot;</span>: <span class="hljs-string">&quot;string&quot;</span>
 }
+</code></pre>
+                  <!-- </div> -->
+                </section>
+              </div>
+            </div>
+          </div>
+          <div id="definition-ScheduleVideoUpdate" class="definition panel" data-traverse-target="definition-ScheduleVideoUpdate">
+            <h2 class="panel-title">
+              <a name="/definitions/ScheduleVideoUpdate"></a>ScheduleVideoUpdate:
+              <!-- <span class="json-property-type"><span class="json-property-type">object</span>
+              <span class="json-property-range" title="Value limits"></span>
+              
+              
+              </span> -->
+            </h2>
+            <div class="doc-row">
+              <div class="doc-copy">
+                <section class="json-schema-properties">
+                  <dl>
+                    <dt data-property-name="updateAt" class="has-description">
+                      <span class="json-property-name">updateAt:</span>
+                      <span class="json-property-type">dateTime</span>
+                      <span class="json-property-range" title="Value limits"></span>
+                    </dt>
+                    <dd>
+                      <p>When to update the video</p>
+                    </dd>
+                    <dt data-property-name="privacy">
+                      <span class="json-property-name">privacy:</span>
+                      <span class="json-property-type">
+                        <span class="">
+                          <a class="json-schema-ref" href="#/definitions/VideoPrivacy">VideoPrivacy</a>
+                        </span>
+                      </span>
+                      <span class="json-property-range" title="Value limits"></span>
+                    </dt>
+                  </dl>
+                </section>
+              </div>
+              <div class="doc-examples">
+                <section>
+                  <h5>Example</h5>
+                  <!-- <div class="hljs"> --><pre><code class="hljs lang-json">{
+  <span class="hljs-attr">&quot;updateAt&quot;</span>: <span class="hljs-string">&quot;dateTime&quot;</span>,
+  <span class="hljs-attr">&quot;privacy&quot;</span>: <span class="hljs-string">&quot;string&quot;</span>
+}
 </code></pre>
                   <!-- </div> -->
                 </section>
index be40af570d2c2742c1d96d96b9dc9d32f8557438..4b7bc23b4a2811c07a2a434497a4dd436e3b9098 100644 (file)
@@ -711,6 +711,12 @@ paths:
           type: string
           enum: [Public, Unlisted]
           description: 'Video privacy'
+        - name: scheduleUpdate
+          in: formData
+          required: false
+          description: 'Schedule an update at a specific datetime'
+          type:
+            $ref: '#/definitions/ScheduleVideoUpdate'
       responses:
         '200':
           description: successful operation
@@ -864,9 +870,15 @@ paths:
         - name: privacy
           in: formData
           required: true
-          type: string
-          enum: [Public, Unlisted, Private]
+          type:
+            $ref: '#/definitions/VideoPrivacy'
           description: 'Video privacy'
+        - name: scheduleUpdate
+          in: formData
+          required: false
+          description: 'Schedule an update at a specific datetime'
+          type:
+            $ref: '#/definitions/ScheduleVideoUpdate'
       responses:
         '200':
           description: successful operation
@@ -1709,3 +1721,12 @@ definitions:
         type: string
       description:
         type: string
+  ScheduleVideoUpdate:
+    properties:
+      updateAt:
+        type: dateTime
+        description: 'When to update the video'
+        required: true
+      privacy:
+        $ref: '#/definitions/VideoPrivacy'
+        required: false
\ No newline at end of file