Implement captions/subtitles
authorChocobozzz <me@florianbigard.com>
Thu, 12 Jul 2018 17:02:00 +0000 (19:02 +0200)
committerChocobozzz <me@florianbigard.com>
Mon, 16 Jul 2018 09:50:08 +0000 (11:50 +0200)
83 files changed:
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/core/server/server.service.ts
client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
client/src/app/shared/forms/form-validators/index.ts
client/src/app/shared/forms/form-validators/video-captions-validators.service.ts [new file with mode: 0644]
client/src/app/shared/forms/index.ts
client/src/app/shared/forms/reactive-file.component.html [new file with mode: 0644]
client/src/app/shared/forms/reactive-file.component.scss [new file with mode: 0644]
client/src/app/shared/forms/reactive-file.component.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-caption/index.ts [new file with mode: 0644]
client/src/app/shared/video-caption/video-caption-edit.model.ts [new file with mode: 0644]
client/src/app/shared/video-caption/video-caption.service.ts [new file with mode: 0644]
client/src/app/shared/video/video.model.ts
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html [new file with mode: 0644]
client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss [new file with mode: 0644]
client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts [new file with mode: 0644]
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/shared/video-image.component.html
client/src/app/videos/+video-edit/shared/video-image.component.scss
client/src/app/videos/+video-edit/shared/video-image.component.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
config/default.yaml
config/production.yaml.example
config/test-1.yaml
config/test-2.yaml
config/test-3.yaml
config/test-4.yaml
config/test-5.yaml
config/test-6.yaml
server.ts
server/controllers/activitypub/client.ts
server/controllers/api/config.ts
server/controllers/api/videos/captions.ts [new file with mode: 0644]
server/controllers/api/videos/index.ts
server/controllers/client.ts
server/controllers/feeds.ts
server/controllers/services.ts
server/controllers/static.ts
server/helpers/activitypub.ts
server/helpers/custom-validators/activitypub/videos.ts
server/helpers/custom-validators/video-captions.ts [new file with mode: 0644]
server/helpers/custom-validators/videos.ts
server/initializers/constants.ts
server/initializers/database.ts
server/lib/activitypub/process/process-update.ts
server/lib/activitypub/videos.ts
server/lib/cache/abstract-video-static-file-cache.ts [new file with mode: 0644]
server/lib/cache/videos-caption-cache.ts [new file with mode: 0644]
server/lib/cache/videos-preview-cache.ts
server/middlewares/validators/video-captions.ts [new file with mode: 0644]
server/middlewares/validators/videos.ts
server/models/video/video-caption.ts [new file with mode: 0644]
server/models/video/video.ts
server/tests/api/check-params/config.ts
server/tests/api/check-params/index.ts
server/tests/api/check-params/video-captions.ts [new file with mode: 0644]
server/tests/api/index-fast.ts
server/tests/api/server/config.ts
server/tests/api/server/follows.ts
server/tests/api/videos/video-captions.ts [new file with mode: 0644]
server/tests/fixtures/subtitle-good1.vtt [new file with mode: 0644]
server/tests/fixtures/subtitle-good2.vtt [new file with mode: 0644]
server/tests/utils/miscs/miscs.ts
server/tests/utils/videos/video-captions.ts [new file with mode: 0644]
shared/models/activitypub/objects/video-torrent-object.ts
shared/models/server/custom-config.model.ts
shared/models/server/server-config.model.ts
shared/models/videos/index.ts
shared/models/videos/video-caption-update.model.ts [new file with mode: 0644]
shared/models/videos/video-caption.model.ts [new file with mode: 0644]
shared/models/videos/video-constant.model.ts [new file with mode: 0644]
shared/models/videos/video.model.ts
support/docker/production/config/production.yaml

index 1e5308531ee79ac8248c0c5aa97dbb0daab8f273..97900e5238441456064fd43ce675b87384b813a8 100644 (file)
@@ -206,15 +206,17 @@ Check this checkbox, save the configuration and test with a video URL of your in
         </div>
       </ng-template>
 
-      <div i18n class="inner-form-title">Cache</div>
+      <div i18n class="inner-form-title">
+        Cache
 
-      <div class="form-group">
-        <label i18n for="cachePreviewsSize">Previews cache size</label>
         <my-help
           helpType="custom" i18n-customHtml
-          customHtml="Previews are not federated. We fetch them directly from the origin instance and cache them."
+          customHtml="Some files are not federated (previews, captions). We fetch them directly from the origin instance and cache them."
         ></my-help>
+      </div>
 
+      <div class="form-group">
+        <label i18n for="cachePreviewsSize">Previews cache size</label>
         <input
           type="text" id="cachePreviewsSize"
           formControlName="cachePreviewsSize" [ngClass]="{ 'input-error': formErrors['cachePreviewsSize'] }"
@@ -224,6 +226,17 @@ Check this checkbox, save the configuration and test with a video URL of your in
         </div>
       </div>
 
+      <div class="form-group">
+        <label i18n for="cachePreviewsSize">Video captions cache size</label>
+        <input
+          type="text" id="cacheCaptionsSize"
+          formControlName="cacheCaptionsSize" [ngClass]="{ 'input-error': formErrors['cacheCaptionsSize'] }"
+        >
+        <div *ngIf="formErrors.cacheCaptionsSize" class="form-error">
+          {{ formErrors.cacheCaptionsSize }}
+        </div>
+      </div>
+
       <div i18n class="inner-form-title">Customizations</div>
 
       <div class="form-group">
index 7b3e72803592f51c95955bf570df15f9a20acd64..8d476393f39c47fd867cf484b15d221e3c18165d 100644 (file)
@@ -67,6 +67,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
       servicesTwitterUsername: this.customConfigValidatorsService.SERVICES_TWITTER_USERNAME,
       servicesTwitterWhitelisted: null,
       cachePreviewsSize: this.customConfigValidatorsService.CACHE_PREVIEWS_SIZE,
+      cacheCaptionsSize: this.customConfigValidatorsService.CACHE_CAPTIONS_SIZE,
       signupEnabled: null,
       signupLimit: this.customConfigValidatorsService.SIGNUP_LIMIT,
       adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL,
@@ -156,6 +157,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
       cache: {
         previews: {
           size: this.form.value['cachePreviewsSize']
+        },
+        captions: {
+          size: this.form.value['cacheCaptionsSize']
         }
       },
       signup: {
index 74363e6a1bac7eedeb7627c3319ae7d3f977d147..3baefb6a76414aa4df3ee723e2964864733e2ead 100644 (file)
@@ -59,6 +59,12 @@ export class ServerService {
         extensions: []
       }
     },
+    videoCaption: {
+      file: {
+        size: { max: 0 },
+        extensions: []
+      }
+    },
     user: {
       videoQuota: -1
     }
index 1b36bbc6b0ae7fa19c742eb1e5292d7f38eb733c..0c2489a9d8fb07de38450dc55dfcc19c0d7dadb2 100644 (file)
@@ -9,6 +9,7 @@ export class CustomConfigValidatorsService {
   readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator
   readonly SERVICES_TWITTER_USERNAME: BuildFormValidator
   readonly CACHE_PREVIEWS_SIZE: BuildFormValidator
+  readonly CACHE_CAPTIONS_SIZE: BuildFormValidator
   readonly SIGNUP_LIMIT: BuildFormValidator
   readonly ADMIN_EMAIL: BuildFormValidator
   readonly TRANSCODING_THREADS: BuildFormValidator
@@ -44,6 +45,15 @@ export class CustomConfigValidatorsService {
       }
     }
 
+    this.CACHE_CAPTIONS_SIZE = {
+      VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
+      MESSAGES: {
+        'required': this.i18n('Captions cache size is required.'),
+        'min': this.i18n('Captions cache size must be greater than 1.'),
+        'pattern': this.i18n('Captions cache size must be a number.')
+      }
+    }
+
     this.SIGNUP_LIMIT = {
       VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
       MESSAGES: {
index 4876830884d77c59a76390ec238c72c12a31ca97..60d735ef705b7ac8e19d89a9eddfa97b252c2277 100644 (file)
@@ -8,3 +8,4 @@ export * from './video-abuse-validators.service'
 export * from './video-channel-validators.service'
 export * from './video-comment-validators.service'
 export * from './video-validators.service'
+export * from './video-captions-validators.service'
diff --git a/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts b/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts
new file mode 100644 (file)
index 0000000..d1b4667
--- /dev/null
@@ -0,0 +1,27 @@
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from '@app/shared'
+
+@Injectable()
+export class VideoCaptionsValidatorsService {
+  readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator
+  readonly VIDEO_CAPTION_FILE: BuildFormValidator
+
+  constructor (private i18n: I18n) {
+
+    this.VIDEO_CAPTION_LANGUAGE = {
+      VALIDATORS: [ Validators.required ],
+      MESSAGES: {
+        'required': this.i18n('Video caption language is required.')
+      }
+    }
+
+    this.VIDEO_CAPTION_FILE = {
+      VALIDATORS: [ Validators.required ],
+      MESSAGES: {
+        'required': this.i18n('Video caption file is required.')
+      }
+    }
+  }
+}
index 7464bb0222f91bf4c334c8e5dc48bb6e98ebf7f9..41c321c4c51b9713af9a2c2789258ff924fcbadc 100644 (file)
@@ -1,2 +1,3 @@
 export * from './form-validators'
 export * from './form-reactive'
+export * from './reactive-file.component'
diff --git a/client/src/app/shared/forms/reactive-file.component.html b/client/src/app/shared/forms/reactive-file.component.html
new file mode 100644 (file)
index 0000000..9fb1c9e
--- /dev/null
@@ -0,0 +1,14 @@
+<div class="root">
+  <div class="button-file">
+    <span>{{ inputLabel }}</span>
+    <input
+      type="file"
+      [name]="inputName" [id]="inputName" [accept]="extensions"
+      (change)="fileChange($event)"
+    />
+  </div>
+
+  <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})</div>
+
+  <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
+</div>
diff --git a/client/src/app/shared/forms/reactive-file.component.scss b/client/src/app/shared/forms/reactive-file.component.scss
new file mode 100644 (file)
index 0000000..d898442
--- /dev/null
@@ -0,0 +1,24 @@
+@import '_variables';
+@import '_mixins';
+
+.root {
+  height: auto;
+  display: flex;
+  align-items: center;
+
+  .button-file {
+    @include peertube-button-file(auto);
+
+    min-width: 190px;
+  }
+
+  .file-constraints {
+    margin-left: 5px;
+    font-size: 13px;
+  }
+
+  .filename {
+    font-weight: $font-semibold;
+    margin-left: 5px;
+  }
+}
diff --git a/client/src/app/shared/forms/reactive-file.component.ts b/client/src/app/shared/forms/reactive-file.component.ts
new file mode 100644 (file)
index 0000000..f5758b6
--- /dev/null
@@ -0,0 +1,75 @@
+import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { NotificationsService } from 'angular2-notifications'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+  selector: 'my-reactive-file',
+  styleUrls: [ './reactive-file.component.scss' ],
+  templateUrl: './reactive-file.component.html',
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => ReactiveFileComponent),
+      multi: true
+    }
+  ]
+})
+export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
+  @Input() inputLabel: string
+  @Input() inputName: string
+  @Input() extensions: string[] = []
+  @Input() maxFileSize: number
+  @Input() displayFilename = false
+
+  @Output() fileChanged = new EventEmitter<Blob>()
+
+  allowedExtensionsMessage = ''
+
+  private file: File
+
+  constructor (
+    private notificationsService: NotificationsService,
+    private i18n: I18n
+  ) {}
+
+  get filename () {
+    if (!this.file) return ''
+
+    return this.file.name
+  }
+
+  ngOnInit () {
+    this.allowedExtensionsMessage = this.extensions.join(', ')
+  }
+
+  fileChange (event: any) {
+    if (event.target.files && event.target.files.length) {
+      const [ file ] = event.target.files
+
+      if (file.size > this.maxFileSize) {
+        this.notificationsService.error(this.i18n('Error'), this.i18n('This file is too large.'))
+        return
+      }
+
+      this.file = file
+
+      this.propagateChange(this.file)
+      this.fileChanged.emit(this.file)
+    }
+  }
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (file: any) {
+    this.file = file
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+}
index 53aff1b24cf9ef781341f29ba364a561b5e6aaa5..8381745f5fd4ad8394ea49949e64b269ab9d42d9 100644 (file)
@@ -81,7 +81,7 @@ function objectToFormData (obj: any, form?: FormData, namespace?: string) {
     }
 
     if (obj[key] !== null && typeof obj[ key ] === 'object' && !(obj[ key ] instanceof File)) {
-      objectToFormData(obj[ key ], fd, key)
+      objectToFormData(obj[ key ], fd, formKey)
     } else {
       fd.append(formKey, obj[ key ])
     }
@@ -96,6 +96,11 @@ function lineFeedToHtml (obj: object, keyToNormalize: string) {
   })
 }
 
+function removeElementFromArray <T> (arr: T[], elem: T) {
+  const index = arr.indexOf(elem)
+  if (index !== -1) arr.splice(index, 1)
+}
+
 export {
   objectToUrlEncoded,
   getParameterByName,
@@ -104,5 +109,6 @@ export {
   dateToHuman,
   immutableAssign,
   objectToFormData,
-  lineFeedToHtml
+  lineFeedToHtml,
+  removeElementFromArray
 }
index 97e49e7ab3ae44d31316cba36c5304000a9be8ce..c3f4bf88b3e7e5cc4adba45981c679c8a9cb5470 100644 (file)
@@ -37,12 +37,14 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
 import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
 import {
   CustomConfigValidatorsService,
-  LoginValidatorsService,
+  LoginValidatorsService, ReactiveFileComponent,
   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'
+import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
+import { VideoCaptionService } from '@app/shared/video-caption'
 
 @NgModule({
   imports: [
@@ -74,7 +76,8 @@ import { ScreenService } from '@app/shared/misc/screen.service'
     FromNowPipe,
     MarkdownTextareaComponent,
     InfiniteScrollerDirective,
-    HelpComponent
+    HelpComponent,
+    ReactiveFileComponent
   ],
 
   exports: [
@@ -102,6 +105,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
     MarkdownTextareaComponent,
     InfiniteScrollerDirective,
     HelpComponent,
+    ReactiveFileComponent,
 
     NumberFormatterPipe,
     ObjectLengthPipe,
@@ -119,6 +123,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
     AccountService,
     MarkdownService,
     VideoChannelService,
+    VideoCaptionService,
 
     FormValidatorService,
     CustomConfigValidatorsService,
@@ -129,6 +134,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
     VideoChannelValidatorsService,
     VideoCommentValidatorsService,
     VideoValidatorsService,
+    VideoCaptionsValidatorsService,
 
     I18nPrimengCalendarService,
     ScreenService,
diff --git a/client/src/app/shared/video-caption/index.ts b/client/src/app/shared/video-caption/index.ts
new file mode 100644 (file)
index 0000000..c48a705
--- /dev/null
@@ -0,0 +1 @@
+export * from './video-caption.service'
diff --git a/client/src/app/shared/video-caption/video-caption-edit.model.ts b/client/src/app/shared/video-caption/video-caption-edit.model.ts
new file mode 100644 (file)
index 0000000..732f201
--- /dev/null
@@ -0,0 +1,9 @@
+export interface VideoCaptionEdit {
+  language: {
+    id: string
+    label?: string
+  }
+
+  action?: 'CREATE' | 'REMOVE'
+  captionfile?: any
+}
diff --git a/client/src/app/shared/video-caption/video-caption.service.ts b/client/src/app/shared/video-caption/video-caption.service.ts
new file mode 100644 (file)
index 0000000..4ae8ebd
--- /dev/null
@@ -0,0 +1,61 @@
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { forkJoin, Observable } from 'rxjs'
+import { ResultList } from '../../../../../shared'
+import { RestExtractor, RestService } from '../rest'
+import { VideoCaption } from '../../../../../shared/models/videos/video-caption.model'
+import { VideoService } from '@app/shared/video/video.service'
+import { objectToFormData } from '@app/shared/misc/utils'
+import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+
+@Injectable()
+export class VideoCaptionService {
+  constructor (
+    private authHttp: HttpClient,
+    private restService: RestService,
+    private restExtractor: RestExtractor
+  ) {}
+
+  listCaptions (videoId: number | string): Observable<ResultList<VideoCaption>> {
+    return this.authHttp.get<ResultList<VideoCaption>>(VideoService.BASE_VIDEO_URL + videoId + '/captions')
+               .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  removeCaption (videoId: number | string, language: string) {
+    return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(res => this.restExtractor.handleError(res))
+               )
+  }
+
+  addCaption (videoId: number | string, language: string, captionfile: File) {
+    const body = { captionfile }
+    const data = objectToFormData(body)
+
+    return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(res => this.restExtractor.handleError(res))
+               )
+  }
+
+  updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) {
+    const observables: Observable<any>[] = []
+
+    for (const videoCaption of videoCaptions) {
+      if (videoCaption.action === 'CREATE') {
+        observables.push(
+          this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)
+        )
+      } else if (videoCaption.action === 'REMOVE') {
+        observables.push(
+          this.removeCaption(videoId, videoCaption.language.id)
+        )
+      }
+    }
+
+    return forkJoin(observables)
+  }
+}
index 5c820a22712bce7b8e3b645a087503b715cde41a..6b1a299ea8c231d3555334ca5415c8aca5ddb2c2 100644 (file)
@@ -1,7 +1,7 @@
 import { User } from '../'
 import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
 import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { VideoConstant } from '../../../../../shared/models/videos/video.model'
+import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model'
 import { getAbsoluteAPIUrl } from '../misc/utils'
 import { ServerConfig } from '../../../../../shared/models'
 import { Actor } from '@app/shared/actor/actor.model'
index 9498a06fe7903df5bc21ed3889971ea71bd41f90..b4c1f10f93dd96834e54f41ed4ae9f7878f982b4 100644 (file)
@@ -28,8 +28,8 @@ import { ServerService } from '@app/core'
 
 @Injectable()
 export class VideoService {
-  private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
-  private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
+  static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
+  static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
 
   constructor (
     private authHttp: HttpClient,
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html
new file mode 100644 (file)
index 0000000..9cd303b
--- /dev/null
@@ -0,0 +1,47 @@
+<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
+  <div class="modal-dialog">
+    <div class="modal-content" [formGroup]="form">
+
+      <div class="modal-header">
+        <span class="close" aria-hidden="true" (click)="hide()"></span>
+        <h4 i18n class="modal-title">Add caption</h4>
+      </div>
+
+      <div class="modal-body">
+        <label i18n for="language">Language</label>
+        <div class="peertube-select-container">
+          <select id="language" formControlName="language">
+            <option></option>
+            <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option>
+          </select>
+        </div>
+
+        <div *ngIf="formErrors.language" class="form-error">
+          {{ formErrors.language }}
+        </div>
+
+        <div class="caption-file">
+          <my-reactive-file
+            formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file"
+            [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true"
+          ></my-reactive-file>
+        </div>
+
+        <div *ngIf="isReplacingExistingCaption()" class="warning-replace-caption" i18n>
+          This will replace an existing caption!
+        </div>
+
+        <div class="form-group inputs">
+          <span i18n class="action-button action-button-cancel" (click)="hide()">
+            Cancel
+          </span>
+
+          <input
+            type="submit" i18n-value value="Add this caption" class="action-button-submit"
+            [disabled]="!form.valid" (click)="addCaption()"
+          >
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss
new file mode 100644 (file)
index 0000000..c6da187
--- /dev/null
@@ -0,0 +1,15 @@
+@import '_variables';
+@import '_mixins';
+
+.peertube-select-container {
+  @include peertube-select-container(auto);
+}
+
+.caption-file {
+  margin-top: 20px;
+}
+
+.warning-replace-caption {
+  color: red;
+  margin-top: 10px;
+}
\ No newline at end of file
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts
new file mode 100644 (file)
index 0000000..45b8c71
--- /dev/null
@@ -0,0 +1,80 @@
+import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { ModalDirective } from 'ngx-bootstrap/modal'
+import { FormReactive } from '@app/shared'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
+import { ServerService } from '@app/core'
+import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+
+@Component({
+  selector: 'my-video-caption-add-modal',
+  styleUrls: [ './video-caption-add-modal.component.scss' ],
+  templateUrl: './video-caption-add-modal.component.html'
+})
+
+export class VideoCaptionAddModalComponent extends FormReactive implements OnInit {
+  @Input() existingCaptions: string[]
+
+  @Output() captionAdded = new EventEmitter<VideoCaptionEdit>()
+
+  @ViewChild('modal') modal: ModalDirective
+
+  videoCaptionLanguages = []
+
+  private closingModal = false
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private serverService: ServerService,
+    private videoCaptionsValidatorsService: VideoCaptionsValidatorsService
+  ) {
+    super()
+  }
+
+  get videoCaptionExtensions () {
+    return this.serverService.getConfig().videoCaption.file.extensions
+  }
+
+  get videoCaptionMaxSize () {
+    return this.serverService.getConfig().videoCaption.file.size.max
+  }
+
+  ngOnInit () {
+    this.videoCaptionLanguages = this.serverService.getVideoLanguages()
+
+    this.buildForm({
+      language: this.videoCaptionsValidatorsService.VIDEO_CAPTION_LANGUAGE,
+      captionfile: this.videoCaptionsValidatorsService.VIDEO_CAPTION_FILE
+    })
+  }
+
+  show () {
+    this.modal.show()
+  }
+
+  hide () {
+    this.modal.hide()
+  }
+
+  isReplacingExistingCaption () {
+    if (this.closingModal === true) return false
+
+    const languageId = this.form.value[ 'language' ]
+
+    return languageId && this.existingCaptions.indexOf(languageId) !== -1
+  }
+
+  async addCaption () {
+    this.closingModal = true
+
+    const languageId = this.form.value[ 'language' ]
+    const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId)
+
+    this.captionAdded.emit({
+      language: languageObject,
+      captionfile: this.form.value['captionfile']
+    })
+
+    this.hide()
+  }
+}
index 447c5ab9b779b1c8fc100563e33bc345327d76d4..14d5f36144ed4174edd517ffd87fbcceba5ef447 100644 (file)
           <label i18n for="waitTranscoding">Wait transcoding before publishing the video</label>
           <my-help
             tooltipPlacement="top" helpType="custom" i18n-customHtml
-            customHtml="If you decide to not wait transcoding before publishing the video, it can be unplayable until it transcoding ends."
+            customHtml="If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends."
           ></my-help>
         </div>
 
       </div>
     </tab>
 
+    <tab i18n-heading heading="Captions">
+      <div class="col-md-12 captions">
+
+        <div class="captions-header">
+          <a (click)="openAddCaptionModal()" class="create-caption">
+            <span class="icon icon-add"></span>
+            <ng-container i18n>Add another caption</ng-container>
+          </a>
+        </div>
+
+        <div class="form-group" *ngFor="let videoCaption of videoCaptions">
+
+          <div class="caption-entry">
+            <div class="caption-entry-label">{{ videoCaption.language.label }}</div>
+
+            <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span>
+          </div>
+        </div>
+
+        <div class="no-caption" *ngIf="videoCaptions?.length === 0">
+          No captions for now.
+        </div>
+
+      </div>
+    </tab>
+
     <tab i18n-heading heading="Advanced settings">
       <div class="col-md-12 advanced-settings">
         <div class="form-group">
   </tabset>
 
 </div>
+
+<my-video-caption-add-modal
+  #videoCaptionAddModal [existingCaptions]="getExistingCaptions()" (captionAdded)="onCaptionAdded($event)"
+></my-video-caption-add-modal>
\ No newline at end of file
index 061eca4a79101f63db7cab1ab38f3ceddd8a4479..03b8359dedbbfe3207b9016edc65c1e22f6f5dc8 100644 (file)
@@ -7,6 +7,7 @@
 
 .video-edit {
   height: 100%;
+  min-height: 300px;
 
   .form-group {
     margin-bottom: 25px;
   }
 }
 
+.captions {
+
+  .captions-header {
+    text-align: right;
+
+    .create-caption {
+      @include create-button('../../../../assets/images/global/add.svg');
+    }
+  }
+
+  .caption-entry {
+    display: flex;
+    height: 40px;
+    align-items: center;
+
+    .caption-entry-label {
+      font-size: 15px;
+      font-weight: bold;
+
+      margin-right: 20px;
+    }
+
+    .caption-entry-delete {
+      @include peertube-button;
+      @include grey-button;
+    }
+  }
+
+  .no-caption {
+    text-align: center;
+    font-size: 15px;
+  }
+}
+
 .submit-container {
   text-align: right;
 
index 66eb6611a78096869805da41574ba0748a335953..9394d7dab9a513f882129e19db9cd839c9907eb0 100644 (file)
@@ -1,5 +1,5 @@
-import { Component, Input, OnInit } from '@angular/core'
-import { FormGroup, ValidatorFn, Validators } from '@angular/forms'
+import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
 import { ActivatedRoute, Router } from '@angular/router'
 import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared'
 import { NotificationsService } from 'angular2-notifications'
@@ -8,6 +8,10 @@ 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'
+import { VideoCaptionService } from '@app/shared/video-caption'
+import { VideoCaptionAddModalComponent } from '@app/videos/+video-edit/shared/video-caption-add-modal.component'
+import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+import { removeElementFromArray } from '@app/shared/misc/utils'
 
 @Component({
   selector: 'my-video-edit',
@@ -15,13 +19,16 @@ import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calend
   templateUrl: './video-edit.component.html'
 })
 
-export class VideoEditComponent implements OnInit {
+export class VideoEditComponent implements OnInit, OnDestroy {
   @Input() form: FormGroup
   @Input() formErrors: { [ id: string ]: string } = {}
   @Input() validationMessages: FormReactiveValidationMessages = {}
   @Input() videoPrivacies = []
   @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
   @Input() schedulePublicationPossible = true
+  @Input() videoCaptions: VideoCaptionEdit[] = []
+
+  @ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent
 
   // So that it can be accessed in the template
   readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
@@ -41,9 +48,12 @@ export class VideoEditComponent implements OnInit {
   calendarTimezone: string
   calendarDateFormat: string
 
+  private schedulerInterval
+
   constructor (
     private formValidatorService: FormValidatorService,
     private videoValidatorsService: VideoValidatorsService,
+    private videoCaptionService: VideoCaptionService,
     private route: ActivatedRoute,
     private router: Router,
     private notificationsService: NotificationsService,
@@ -91,6 +101,13 @@ export class VideoEditComponent implements OnInit {
       defaultValues
     )
 
+    this.form.addControl('captions', new FormArray([
+      new FormGroup({
+        language: new FormControl(),
+        captionfile: new FormControl()
+      })
+    ]))
+
     this.trackChannelChange()
     this.trackPrivacyChange()
   }
@@ -102,7 +119,35 @@ export class VideoEditComponent implements OnInit {
     this.videoLicences = this.serverService.getVideoLicences()
     this.videoLanguages = this.serverService.getVideoLanguages()
 
-    setTimeout(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
+    this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
+  }
+
+  ngOnDestroy () {
+    if (this.schedulerInterval) clearInterval(this.schedulerInterval)
+  }
+
+  getExistingCaptions () {
+    return this.videoCaptions.map(c => c.language.id)
+  }
+
+  onCaptionAdded (caption: VideoCaptionEdit) {
+    this.videoCaptions.push(
+      Object.assign(caption, { action: 'CREATE' as 'CREATE' })
+    )
+  }
+
+  deleteCaption (caption: VideoCaptionEdit) {
+    // This caption is not on the server, just remove it from our array
+    if (caption.action === 'CREATE') {
+      removeElementFromArray(this.videoCaptions, caption)
+      return
+    }
+
+    caption.action = 'REMOVE' as 'REMOVE'
+  }
+
+  openAddCaptionModal () {
+    this.videoCaptionAddModal.show()
   }
 
   private trackPrivacyChange () {
index 6bf3e34b122eabca855dbb7c78631127c716a825..f6bd65fdcaf801658bbdc274b83ad5d4623499a2 100644 (file)
@@ -5,6 +5,7 @@ import { SharedModule } from '../../../shared/'
 import { VideoEditComponent } from './video-edit.component'
 import { VideoImageComponent } from './video-image.component'
 import { CalendarModule } from 'primeng/components/calendar/calendar'
+import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
 
 @NgModule({
   imports: [
@@ -16,7 +17,8 @@ import { CalendarModule } from 'primeng/components/calendar/calendar'
 
   declarations: [
     VideoEditComponent,
-    VideoImageComponent
+    VideoImageComponent,
+    VideoCaptionAddModalComponent
   ],
 
   exports: [
index e319d7ee79f353ef2f38bed70ee3ba92ea366d8d..c09c862c4167d2b79aa88e73a2786f0591f39ca9 100644 (file)
@@ -1,15 +1,8 @@
 <div class="root">
-  <div>
-    <div class="button-file">
-      <span>{{ inputLabel }}</span>
-      <input
-        type="file"
-        [name]="inputName" [id]="inputName" [accept]="videoImageExtensions"
-        (change)="fileChange($event)"
-      />
-    </div>
-    <div i18n class="image-constraints">(extensions: {{ videoImageExtensions }}, max size: {{ maxVideoImageSize | bytes }})</div>
-  </div>
+  <my-reactive-file
+    [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
+    (fileChanged)="onFileChanged($event)"
+  ></my-reactive-file>
 
   <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
   <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
index d4901e7abf37b4d733db31004b8fb9fb1fa6d359..b63963bcadce2f01bf9aa4808ecfdb05964f6a66 100644 (file)
@@ -6,16 +6,6 @@
   display: flex;
   align-items: center;
 
-  .button-file {
-    @include peertube-button-file(auto);
-
-    min-width: 190px;
-  }
-
-  .image-constraints {
-    font-size: 13px;
-  }
-
   .preview {
     border: 2px solid grey;
     border-radius: 4px;
index 25955baaaabd6a50443da67696479dd03840e6ef..a604cde90c13f80801022174c3dc83d8933f301a 100644 (file)
@@ -2,8 +2,6 @@ import { Component, forwardRef, Input } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
 import { ServerService } from '@app/core'
-import { NotificationsService } from 'angular2-notifications'
-import { I18n } from '@ngx-translate/i18n-polyfill'
 
 @Component({
   selector: 'my-video-image',
@@ -25,36 +23,26 @@ export class VideoImageComponent implements ControlValueAccessor {
 
   imageSrc: SafeResourceUrl
 
-  private file: Blob
+  private file: File
 
   constructor (
     private sanitizer: DomSanitizer,
-    private serverService: ServerService,
-    private notificationsService: NotificationsService,
-    private i18n: I18n
+    private serverService: ServerService
   ) {}
 
   get videoImageExtensions () {
-    return this.serverService.getConfig().video.image.extensions.join(',')
+    return this.serverService.getConfig().video.image.extensions
   }
 
   get maxVideoImageSize () {
     return this.serverService.getConfig().video.image.size.max
   }
 
-  fileChange (event: any) {
-    if (event.target.files && event.target.files.length) {
-      const [ file ] = event.target.files
-
-      if (file.size > this.maxVideoImageSize) {
-        this.notificationsService.error(this.i18n('Error'), this.i18n('This image is too large.'))
-        return
-      }
+  onFileChanged (file: File) {
+    this.file = file
 
-      this.file = file
-      this.propagateChange(this.file)
-      this.updatePreview()
-    }
+    this.propagateChange(this.file)
+    this.updatePreview()
   }
 
   propagateChange = (_: any) => { /* empty */ }
index 7d9443209c0e0f084014eee2e422d0a9ef24fa68..9c2c01c65c38b23af1712eb7ede614d3d45140cf 100644 (file)
@@ -46,7 +46,7 @@
   <!-- Hidden because we want to load the component -->
   <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
     <my-video-edit
-      [form]="form" [formErrors]="formErrors"
+      [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
       [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
     ></my-video-edit>
 
index 7c4b6260ba2dfa7b5df0e16bd0ff937c4ce3fd5b..8c30cedfb0a2792f05a738fe1de3533b9d5137ea 100644 (file)
@@ -15,6 +15,8 @@ import { VideoEdit } from '../../shared/video/video-edit.model'
 import { VideoService } from '../../shared/video/video.service'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { switchMap } from 'rxjs/operators'
+import { VideoCaptionService } from '@app/shared/video-caption'
 
 @Component({
   selector: 'my-videos-add',
@@ -46,6 +48,7 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
   videoPrivacies = []
   firstStepPrivacyId = 0
   firstStepChannelId = 0
+  videoCaptions = []
 
   constructor (
     protected formValidatorService: FormValidatorService,
@@ -56,7 +59,8 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
     private serverService: ServerService,
     private videoService: VideoService,
     private loadingBar: LoadingBarService,
-    private i18n: I18n
+    private i18n: I18n,
+    private videoCaptionService: VideoCaptionService
   ) {
     super()
   }
@@ -159,11 +163,8 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
     let name: string
 
     // If the name of the file is very small, keep the extension
-    if (nameWithoutExtension.length < 3) {
-      name = videofile.name
-    } else {
-      name = nameWithoutExtension
-    }
+    if (nameWithoutExtension.length < 3) name = videofile.name
+    else name = nameWithoutExtension
 
     const privacy = this.firstStepPrivacyId.toString()
     const nsfw = false
@@ -225,22 +226,25 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
     this.isUpdatingVideo = true
     this.loadingBar.start()
     this.videoService.updateVideo(video)
-      .subscribe(
-        () => {
-          this.isUpdatingVideo = false
-          this.isUploadingVideo = false
-          this.loadingBar.complete()
-
-          this.notificationsService.success(this.i18n('Success'), this.i18n('Video published.'))
-          this.router.navigate([ '/videos/watch', video.uuid ])
-        },
-
-        err => {
-          this.isUpdatingVideo = false
-          this.notificationsService.error(this.i18n('Error'), err.message)
-          console.error(err)
-        }
-      )
-
+        .pipe(
+          // Then update captions
+          switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions))
+        )
+        .subscribe(
+          () => {
+            this.isUpdatingVideo = false
+            this.isUploadingVideo = false
+            this.loadingBar.complete()
+
+            this.notificationsService.success(this.i18n('Success'), this.i18n('Video published.'))
+            this.router.navigate([ '/videos/watch', video.uuid ])
+          },
+
+          err => {
+            this.isUpdatingVideo = false
+            this.notificationsService.error(this.i18n('Error'), err.message)
+            console.error(err)
+          }
+        )
   }
 }
index 5cb16c8ab8111f5ed0b54b200a999e8e29a8b7f2..9242c30a02cef81af675a092660fec3bf2d004b1 100644 (file)
@@ -8,6 +8,7 @@
     <my-video-edit
       [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
       [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
+      [videoCaptions]="videoCaptions"
     ></my-video-edit>
 
     <div class="submit-container">
index c4e6f44de0d5e4a51dd3c31065f8ddbf34445ba8..b678744019fb7c202e13eba54dae976b90cb7d56 100644 (file)
@@ -12,6 +12,7 @@ import { VideoService } from '../../shared/video/video.service'
 import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { VideoCaptionService } from '@app/shared/video-caption'
 
 @Component({
   selector: 'my-videos-update',
@@ -25,6 +26,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   videoPrivacies = []
   userVideoChannels = []
   schedulePublicationPossible = false
+  videoCaptions = []
 
   constructor (
     protected formValidatorService: FormValidatorService,
@@ -36,6 +38,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
     private authService: AuthService,
     private loadingBar: LoadingBarService,
     private videoChannelService: VideoChannelService,
+    private videoCaptionService: VideoCaptionService,
     private i18n: I18n
   ) {
     super()
@@ -63,12 +66,21 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
                          map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))),
                          map(videoChannels => ({ video, videoChannels }))
                        )
+          }),
+          switchMap(({ video, videoChannels }) => {
+            return this.videoCaptionService
+                       .listCaptions(video.id)
+                       .pipe(
+                         map(result => result.data),
+                         map(videoCaptions => ({ video, videoChannels, videoCaptions }))
+                       )
           })
         )
         .subscribe(
-          ({ video, videoChannels }) => {
+          ({ video, videoChannels, videoCaptions }) => {
             this.video = new VideoEdit(video)
             this.userVideoChannels = videoChannels
+            this.videoCaptions = videoCaptions
 
             // We cannot set private a video that was not private
             if (this.video.privacy !== VideoPrivacy.PRIVATE) {
@@ -102,21 +114,27 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
 
     this.loadingBar.start()
     this.isUpdatingVideo = true
+
+    // Update the video
     this.videoService.updateVideo(this.video)
-                     .subscribe(
-                       () => {
-                         this.isUpdatingVideo = false
-                         this.loadingBar.complete()
-                         this.notificationsService.success(this.i18n('Success'), this.i18n('Video updated.'))
-                         this.router.navigate([ '/videos/watch', this.video.uuid ])
-                       },
-
-                       err => {
-                         this.isUpdatingVideo = false
-                         this.notificationsService.error(this.i18n('Error'), err.message)
-                         console.error(err)
-                       }
-                      )
+        .pipe(
+          // Then update captions
+          switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
+        )
+        .subscribe(
+          () => {
+            this.isUpdatingVideo = false
+            this.loadingBar.complete()
+            this.notificationsService.success(this.i18n('Success'), this.i18n('Video updated.'))
+            this.router.navigate([ '/videos/watch', this.video.uuid ])
+          },
+
+          err => {
+            this.isUpdatingVideo = false
+            this.notificationsService.error(this.i18n('Error'), err.message)
+            console.error(err)
+          }
+        )
 
   }
 
index 9a9b5833f7f4d8d8f66afc16567173bacfa50da1..d59425365d182741752f2fc642f0d2e064ac7737 100644 (file)
@@ -49,6 +49,7 @@ storage:
   previews: 'storage/previews/'
   thumbnails: 'storage/thumbnails/'
   torrents: 'storage/torrents/'
+  captions: 'storage/captions/'
   cache: 'storage/cache/'
 
 log:
@@ -57,6 +58,8 @@ log:
 cache:
   previews:
     size: 1 # Max number of previews you want to cache
+  captions:
+    size: 1 # Max number of video captions/subtitles you want to cache
 
 admin:
   email: 'admin@example.com' # Your personal email as administrator
index a4c80b1f1b6b32052708248d3a59da9c4f8f3ae8..98cdd7ca720a349dd4ce5c21ce76a67fca08f02e 100644 (file)
@@ -50,6 +50,7 @@ storage:
   previews: '/var/www/peertube/storage/previews/'
   thumbnails: '/var/www/peertube/storage/thumbnails/'
   torrents: '/var/www/peertube/storage/torrents/'
+  captions: '/var/www/peertube/storage/captions/'
   cache: '/var/www/peertube/storage/cache/'
 
 log:
index cb658397c5efaff873c5e2548152b6bf0d944b41..503bbc6610632fb813d7591c3f764ef8f0b08ad3 100644 (file)
@@ -16,6 +16,7 @@ storage:
   previews: 'test1/previews/'
   thumbnails: 'test1/thumbnails/'
   torrents: 'test1/torrents/'
+  captions: 'test1/captions/'
   cache: 'test1/cache/'
 
 admin:
index 7b9787c912e1bfdbb7abfc5c4d549f08213975e0..8c77bf58107bb36dd7c1588341612a3cb0ee7e10 100644 (file)
@@ -16,6 +16,7 @@ storage:
   previews: 'test2/previews/'
   thumbnails: 'test2/thumbnails/'
   torrents: 'test2/torrents/'
+  captions: 'test2/captions/'
   cache: 'test2/cache/'
 
 admin:
index e7e30c07bdbb9e64f67ca0b95c2247c108404ded..82d89567a7e09551fa30677029180ec7f937e8a6 100644 (file)
@@ -16,6 +16,7 @@ storage:
   previews: 'test3/previews/'
   thumbnails: 'test3/thumbnails/'
   torrents: 'test3/torrents/'
+  captions: 'test3/captions/'
   cache: 'test3/cache/'
 
 admin:
index b80acd7650abf7b2e9a020881d3931adb1215cb3..1aa56d041c665962e1e43488ee6d8378389709d9 100644 (file)
@@ -16,6 +16,7 @@ storage:
   previews: 'test4/previews/'
   thumbnails: 'test4/thumbnails/'
   torrents: 'test4/torrents/'
+  captions: 'test4/captions/'
   cache: 'test4/cache/'
 
 admin:
index 29d06f1da5f06c14e1e8ad3be6bb2686ee7170d0..5f1c2f583cce4635da722209352414b09dbfc40c 100644 (file)
@@ -16,6 +16,7 @@ storage:
   previews: 'test5/previews/'
   thumbnails: 'test5/thumbnails/'
   torrents: 'test5/torrents/'
+  captions: 'test5/captions/'
   cache: 'test5/cache/'
 
 admin:
index 4fdc2402e403953559a9a4d7a47d3d65234df92f..719629844b162ad795ddb5df82e7e1c133ddefdf 100644 (file)
@@ -16,6 +16,7 @@ storage:
   previews: 'test6/previews/'
   thumbnails: 'test6/thumbnails/'
   torrents: 'test6/torrents/'
+  captions: 'test6/captions/'
   cache: 'test6/cache/'
 
 admin:
index fffb8038f3475a0a1ff6d55f2bd1b3b8d82831de..a7fea34da83d07599131f7f3d18968ef87a6db6a 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -1,4 +1,6 @@
 // FIXME: https://github.com/nodejs/node/pull/16853
+import { VideosCaptionCache } from './server/lib/cache/videos-caption-cache'
+
 require('tls').DEFAULT_ECDH_CURVE = 'auto'
 
 import { isTestInstance } from './server/helpers/core-utils'
@@ -181,6 +183,7 @@ async function startApplication () {
 
   // Caches initializations
   VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE)
+  VideosCaptionCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE)
 
   // Enable Schedulers
   BadActorFollowScheduler.Instance.enable()
index ea8e25f6853c033e70bd6ac64656bebbcfe42c85..3e636190676471a67715eebddc624d59f0d5df59 100644 (file)
@@ -25,6 +25,8 @@ import {
   getVideoLikesActivityPubUrl,
   getVideoSharesActivityPubUrl
 } from '../../lib/activitypub'
+import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
+import { VideoCaptionModel } from '../../models/video/video-caption'
 
 const activityPubClientRouter = express.Router()
 
@@ -123,6 +125,9 @@ async function accountFollowingController (req: express.Request, res: express.Re
 async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) {
   const video: VideoModel = res.locals.video
 
+  // We need captions to render AP object
+  video.VideoCaptions = await VideoCaptionModel.listVideoCaptions(video.id)
+
   const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
   const videoObject = audiencify(video.toActivityPubObject(), audience)
 
index f678e3c4a2b5042b5b94bcb41480565cd1ff6abf..3788975a997c896f7c6321d11ecfc1e596e4e7db 100644 (file)
@@ -80,6 +80,14 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
         extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
       }
     },
+    videoCaption: {
+      file: {
+        size: {
+          max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
+        },
+        extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
+      }
+    },
     user: {
       videoQuota: CONFIG.USER.VIDEO_QUOTA
     }
@@ -122,12 +130,13 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
 
   // Force number conversion
   toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10)
+  toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10)
   toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10)
   toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10)
   toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
 
   // camelCase to snake_case key
-  const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription')
+  const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription', 'cache.videoCaptions')
   toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
   toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
   toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
@@ -172,6 +181,9 @@ function customConfig (): CustomConfig {
     cache: {
       previews: {
         size: CONFIG.CACHE.PREVIEWS.SIZE
+      },
+      captions: {
+        size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
       }
     },
     signup: {
diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts
new file mode 100644 (file)
index 0000000..05412a1
--- /dev/null
@@ -0,0 +1,100 @@
+import * as express from 'express'
+import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
+import {
+  addVideoCaptionValidator,
+  deleteVideoCaptionValidator,
+  listVideoCaptionsValidator
+} from '../../../middlewares/validators/video-captions'
+import { createReqFiles } from '../../../helpers/express-utils'
+import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers'
+import { getFormattedObjects } from '../../../helpers/utils'
+import { VideoCaptionModel } from '../../../models/video/video-caption'
+import { renamePromise } from '../../../helpers/core-utils'
+import { join } from 'path'
+import { VideoModel } from '../../../models/video/video'
+import { logger } from '../../../helpers/logger'
+import { federateVideoIfNeeded } from '../../../lib/activitypub'
+
+const reqVideoCaptionAdd = createReqFiles(
+  [ 'captionfile' ],
+  VIDEO_CAPTIONS_MIMETYPE_EXT,
+  {
+    captionfile: CONFIG.STORAGE.CAPTIONS_DIR
+  }
+)
+
+const videoCaptionsRouter = express.Router()
+
+videoCaptionsRouter.get('/:videoId/captions',
+  asyncMiddleware(listVideoCaptionsValidator),
+  asyncMiddleware(listVideoCaptions)
+)
+videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
+  authenticate,
+  reqVideoCaptionAdd,
+  asyncMiddleware(addVideoCaptionValidator),
+  asyncRetryTransactionMiddleware(addVideoCaption)
+)
+videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
+  authenticate,
+  asyncMiddleware(deleteVideoCaptionValidator),
+  asyncRetryTransactionMiddleware(deleteVideoCaption)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  videoCaptionsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listVideoCaptions (req: express.Request, res: express.Response) {
+  const data = await VideoCaptionModel.listVideoCaptions(res.locals.video.id)
+
+  return res.json(getFormattedObjects(data, data.length))
+}
+
+async function addVideoCaption (req: express.Request, res: express.Response) {
+  const videoCaptionPhysicalFile = req.files['captionfile'][0]
+  const video = res.locals.video as VideoModel
+
+  const videoCaption = new VideoCaptionModel({
+    videoId: video.id,
+    language: req.params.captionLanguage
+  })
+  videoCaption.Video = video
+
+  // Move physical file
+  const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR
+  const destination = join(videoCaptionsDir, videoCaption.getCaptionName())
+  await renamePromise(videoCaptionPhysicalFile.path, destination)
+  // This is important in case if there is another attempt in the retry process
+  videoCaptionPhysicalFile.filename = videoCaption.getCaptionName()
+  videoCaptionPhysicalFile.path = destination
+
+  await sequelizeTypescript.transaction(async t => {
+    await VideoCaptionModel.insertOrReplaceLanguage(video.id, req.params.captionLanguage, t)
+
+    // Update video update
+    await federateVideoIfNeeded(video, false, t)
+  })
+
+  return res.status(204).end()
+}
+
+async function deleteVideoCaption (req: express.Request, res: express.Response) {
+  const video = res.locals.video as VideoModel
+  const videoCaption = res.locals.videoCaption as VideoCaptionModel
+
+  await sequelizeTypescript.transaction(async t => {
+    await videoCaption.destroy({ transaction: t })
+
+    // Send video update
+    await federateVideoIfNeeded(video, false, t)
+  })
+
+  logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid)
+
+  return res.type('json').status(204).end()
+}
index 8c93ae89c2e234a88859fe2eb875f36f59c94ad3..bbb5b8b4cc223c560eaf716a46aeb5aa36e96844 100644 (file)
@@ -53,6 +53,7 @@ import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
 import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
 import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
+import { videoCaptionsRouter } from './captions'
 
 const videosRouter = express.Router()
 
@@ -78,6 +79,7 @@ videosRouter.use('/', abuseVideoRouter)
 videosRouter.use('/', blacklistRouter)
 videosRouter.use('/', rateVideoRouter)
 videosRouter.use('/', videoCommentRouter)
+videosRouter.use('/', videoCaptionsRouter)
 
 videosRouter.get('/categories', listVideoCategories)
 videosRouter.get('/licences', listVideoLicences)
index 5413f61e8ba36acb33a23d2ea226024a283fde63..bfdf35021c0c1d1d32db351f4cf0bc9aa6415643 100644 (file)
@@ -118,7 +118,7 @@ function addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
 
   const videoNameEscaped = escapeHTML(video.name)
   const videoDescriptionEscaped = escapeHTML(video.description)
-  const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedPath()
+  const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedStaticPath()
 
   const openGraphMetaTags = {
     'og:type': 'video',
index 1773fc71e2a38eed02a72d5d2fc4314e8819eb36..ff6b423d97cfc607cced424839dd4efb09d504f7 100644 (file)
@@ -129,7 +129,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response, n
       torrent: torrents,
       thumbnail: [
         {
-          url: CONFIG.WEBSERVER.URL + video.getThumbnailPath(),
+          url: CONFIG.WEBSERVER.URL + video.getThumbnailStaticPath(),
           height: THUMBNAILS_SIZE.height,
           width: THUMBNAILS_SIZE.width
         }
index bd4404b6222879c20e3104087c49af3c3e258b72..352d0b19a8bb096c7e26d800fe5be499c4044573 100644 (file)
@@ -29,8 +29,8 @@ function generateOEmbed (req: express.Request, res: express.Response, next: expr
   const maxHeight = parseInt(req.query.maxheight, 10)
   const maxWidth = parseInt(req.query.maxwidth, 10)
 
-  const embedUrl = webserverUrl + video.getEmbedPath()
-  let thumbnailUrl = webserverUrl + video.getPreviewPath()
+  const embedUrl = webserverUrl + video.getEmbedStaticPath()
+  let thumbnailUrl = webserverUrl + video.getPreviewStaticPath()
   let embedWidth = EMBED_SIZE.width
   let embedHeight = EMBED_SIZE.height
 
index 139ba67cc290b09db1c9867911f14f43669ee04b..679999859d70baf5bc95c73d939cefdafc003187 100644 (file)
@@ -4,6 +4,7 @@ import { CONFIG, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../
 import { VideosPreviewCache } from '../lib/cache'
 import { asyncMiddleware, videosGetValidator } from '../middlewares'
 import { VideoModel } from '../models/video/video'
+import { VideosCaptionCache } from '../lib/cache/videos-caption-cache'
 
 const staticRouter = express.Router()
 
@@ -49,12 +50,18 @@ staticRouter.use(
   express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE })
 )
 
-// Video previews path for express
+// We don't have video previews, fetch them from the origin instance
 staticRouter.use(
   STATIC_PATHS.PREVIEWS + ':uuid.jpg',
   asyncMiddleware(getPreview)
 )
 
+// We don't have video captions, fetch them from the origin instance
+staticRouter.use(
+  STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt',
+  asyncMiddleware(getVideoCaption)
+)
+
 // robots.txt service
 staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => {
   res.type('text/plain')
@@ -70,7 +77,17 @@ export {
 // ---------------------------------------------------------------------------
 
 async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) {
-  const path = await VideosPreviewCache.Instance.getPreviewPath(req.params.uuid)
+  const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
+  if (!path) return res.sendStatus(404)
+
+  return res.sendFile(path, { maxAge: STATIC_MAX_AGE })
+}
+
+async function getVideoCaption (req: express.Request, res: express.Response) {
+  const path = await VideosCaptionCache.Instance.getFilePath({
+    videoId: req.params.videoId,
+    language: req.params.captionLanguage
+  })
   if (!path) return res.sendStatus(404)
 
   return res.sendFile(path, { maxAge: STATIC_MAX_AGE })
index 37a251697ac63da9a7a0ec9f07aa7291ed80661b..c49142a0462f522798812719c6d2d0abff3987ee 100644 (file)
@@ -18,6 +18,7 @@ function activityPubContextify <T> (data: T) {
         uuid: 'http://schema.org/identifier',
         category: 'http://schema.org/category',
         licence: 'http://schema.org/license',
+        subtitleLanguage: 'http://schema.org/subtitleLanguage',
         sensitive: 'as:sensitive',
         language: 'http://schema.org/inLanguage',
         views: 'http://schema.org/Number',
index 37c90a0c85f18b5847ef8316c09a1b31b431274c..d97bbd2a94636e6560593dfa2ec83a1c979712b8 100644 (file)
@@ -51,6 +51,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
   if (!setValidRemoteVideoUrls(video)) return false
   if (!setRemoteVideoTruncatedContent(video)) return false
   if (!setValidAttributedTo(video)) return false
+  if (!setValidRemoteCaptions(video)) return false
 
   // Default attributes
   if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
@@ -98,6 +99,18 @@ function setValidRemoteTags (video: any) {
   return true
 }
 
+function setValidRemoteCaptions (video: any) {
+  if (!video.subtitleLanguage) video.subtitleLanguage = []
+
+  if (Array.isArray(video.subtitleLanguage) === false) return false
+
+  video.subtitleLanguage = video.subtitleLanguage.filter(caption => {
+    return isRemoteStringIdentifierValid(caption)
+  })
+
+  return true
+}
+
 function isRemoteNumberIdentifierValid (data: any) {
   return validator.isInt(data.identifier, { min: 0 })
 }
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
new file mode 100644 (file)
index 0000000..fd4dc74
--- /dev/null
@@ -0,0 +1,41 @@
+import { CONSTRAINTS_FIELDS, VIDEO_LANGUAGES } from '../../initializers'
+import { exists, isFileValid } from './misc'
+import { Response } from 'express'
+import { VideoModel } from '../../models/video/video'
+import { VideoCaptionModel } from '../../models/video/video-caption'
+
+function isVideoCaptionLanguageValid (value: any) {
+  return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined
+}
+
+const videoCaptionTypes = CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
+                                          .map(v => v.replace('.', ''))
+                                          .join('|')
+const videoCaptionsTypesRegex = `text/(${videoCaptionTypes})`
+
+function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
+  return isFileValid(files, videoCaptionsTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
+}
+
+async function isVideoCaptionExist (video: VideoModel, language: string, res: Response) {
+  const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, language)
+
+  if (!videoCaption) {
+    res.status(404)
+       .json({ error: 'Video caption not found' })
+       .end()
+
+    return false
+  }
+
+  res.locals.videoCaption = videoCaption
+  return true
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isVideoCaptionFile,
+  isVideoCaptionLanguageValid,
+  isVideoCaptionExist
+}
index 672f06dc00950f9c6bbcc021b1fd1392297fa115..b5cb126d9d3367984623937fb2befe1eef9b5c76 100644 (file)
@@ -126,6 +126,29 @@ function isVideoFileSizeValid (value: string) {
   return exists(value) && validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.FILE_SIZE)
 }
 
+function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: Response) {
+  // Retrieve the user who did the request
+  if (video.isOwned() === false) {
+    res.status(403)
+       .json({ error: 'Cannot manage a video of another server.' })
+       .end()
+    return false
+  }
+
+  // Check if the user can delete the video
+  // The user can delete it if he has the right
+  // Or if s/he is the video's account
+  const account = video.VideoChannel.Account
+  if (user.hasRight(right) === false && account.userId !== user.id) {
+    res.status(403)
+       .json({ error: 'Cannot manage a video of another user.' })
+       .end()
+    return false
+  }
+
+  return true
+}
+
 async function isVideoExist (id: string, res: Response) {
   let video: VideoModel
 
@@ -179,6 +202,7 @@ async function isVideoChannelOfAccountExist (channelId: number, user: UserModel,
 
 export {
   isVideoCategoryValid,
+  checkUserCanManageVideo,
   isVideoLicenceValid,
   isVideoLanguageValid,
   isVideoTruncatedDescriptionValid,
index c5bc886d8d498fb553540d15cb51884ea5117221..49809e64ce45364a66bb3c9222728e162e43a684 100644 (file)
@@ -138,6 +138,7 @@ const CONFIG = {
     VIDEOS_DIR: buildPath(config.get<string>('storage.videos')),
     THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
     PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
+    CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
     TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')),
     CACHE_DIR: buildPath(config.get<string>('storage.cache'))
   },
@@ -183,6 +184,9 @@ const CONFIG = {
   CACHE: {
     PREVIEWS: {
       get SIZE () { return config.get<number>('cache.previews.size') }
+    },
+    VIDEO_CAPTIONS: {
+      get SIZE () { return config.get<number>('cache.captions.size') }
     }
   },
   INSTANCE: {
@@ -225,6 +229,14 @@ const CONSTRAINTS_FIELDS = {
     SUPPORT: { min: 3, max: 500 }, // Length
     URL: { min: 3, max: 2000 } // Length
   },
+  VIDEO_CAPTIONS: {
+    CAPTION_FILE: {
+      EXTNAME: [ '.vtt' ],
+      FILE_SIZE: {
+        max: 2 * 1024 * 1024 // 2MB
+      }
+    }
+  },
   VIDEOS: {
     NAME: { min: 3, max: 120 }, // Length
     LANGUAGE: { min: 1, max: 10 }, // Length
@@ -351,6 +363,10 @@ const IMAGE_MIMETYPE_EXT = {
   'image/jpeg': '.jpg'
 }
 
+const VIDEO_CAPTIONS_MIMETYPE_EXT = {
+  'text/vtt': '.vtt'
+}
+
 // ---------------------------------------------------------------------------
 
 const SERVER_ACTOR_NAME = 'peertube'
@@ -403,7 +419,8 @@ const STATIC_PATHS = {
   THUMBNAILS: '/static/thumbnails/',
   TORRENTS: '/static/torrents/',
   WEBSEED: '/static/webseed/',
-  AVATARS: '/static/avatars/'
+  AVATARS: '/static/avatars/',
+  VIDEO_CAPTIONS: '/static/video-captions/'
 }
 const STATIC_DOWNLOAD_PATHS = {
   TORRENTS: '/download/torrents/',
@@ -435,7 +452,8 @@ const EMBED_SIZE = {
 // Sub folders of cache directory
 const CACHE = {
   DIRECTORIES: {
-    PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews')
+    PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
+    VIDEO_CAPTIONS: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions')
   }
 }
 
@@ -490,6 +508,7 @@ updateWebserverConfig()
 
 export {
   API_VERSION,
+  VIDEO_CAPTIONS_MIMETYPE_EXT,
   AVATARS_SIZE,
   ACCEPT_HEADERS,
   BCRYPT_SALT_SIZE,
index 4d90c90fc084c4524d40c645aa70ec8edbf71180..434d7ef19aca7cdc035975f0dc84baa8a2bf12a2 100644 (file)
@@ -23,6 +23,7 @@ import { VideoShareModel } from '../models/video/video-share'
 import { VideoTagModel } from '../models/video/video-tag'
 import { CONFIG } from './constants'
 import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
+import { VideoCaptionModel } from '../models/video/video-caption'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -71,6 +72,7 @@ async function initDatabaseModels (silent: boolean) {
     VideoChannelModel,
     VideoShareModel,
     VideoFileModel,
+    VideoCaptionModel,
     VideoBlacklistModel,
     VideoTagModel,
     VideoModel,
index 73db461c30c7962766d45d16ec79d8c3f87e6f19..62791ff1b7edbbb5bae8b08243b46db8839f2772 100644 (file)
@@ -19,6 +19,7 @@ import {
   videoFileActivityUrlToDBAttributes
 } from '../videos'
 import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
+import { VideoCaptionModel } from '../../../models/video/video-caption'
 
 async function processUpdateActivity (activity: ActivityUpdate) {
   const actor = await getOrCreateActorAndServerAndModel(activity.actor)
@@ -110,9 +111,18 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
       const tasks = videoFileAttributes.map(f => VideoFileModel.create(f))
       await Promise.all(tasks)
 
-      const tags = videoObject.tag.map(t => t.name)
+      // Update Tags
+      const tags = videoObject.tag.map(tag => tag.name)
       const tagInstances = await TagModel.findOrCreateTags(tags, t)
       await videoInstance.$set('Tags', tagInstances, sequelizeOptions)
+
+      // Update captions
+      await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoInstance.id, t)
+
+      const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
+        return VideoCaptionModel.insertOrReplaceLanguage(videoInstance.id, c.identifier, t)
+      })
+      await Promise.all(videoCaptionsPromises)
     })
 
     logger.info('Remote video with uuid %s updated', videoObject.uuid)
index a16828fda22aa5fa6845bca7ce5b765a68869e81..fdc082b6155150bdefdc545e2f633516abf424ed 100644 (file)
@@ -24,10 +24,20 @@ import { addVideoComments } from './video-comments'
 import { crawlCollectionPage } from './crawl'
 import { sendCreateVideo, sendUpdateVideo } from './send'
 import { shareVideoByServerAndChannel } from './index'
+import { isArray } from '../../helpers/custom-validators/misc'
+import { VideoCaptionModel } from '../../models/video/video-caption'
 
 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
   // If the video is not private and published, we federate it
   if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
+    // Fetch more attributes that we will need to serialize in AP object
+    if (isArray(video.VideoCaptions) === false) {
+      video.VideoCaptions = await video.$get('VideoCaptions', {
+        attributes: [ 'language' ],
+        transaction
+      }) as VideoCaptionModel[]
+    }
+
     if (isNewVideo === true) {
       // Now we'll add the video's meta data to our followers
       await sendCreateVideo(video, transaction)
@@ -38,9 +48,8 @@ async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, tr
   }
 }
 
-function fetchRemoteVideoPreview (video: VideoModel, reject: Function) {
+function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
   const host = video.VideoChannel.Account.Actor.Server.host
-  const path = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
 
   // We need to provide a callback, if no we could have an uncaught exception
   return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
@@ -179,24 +188,32 @@ async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor:
     const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
     const video = VideoModel.build(videoData)
 
-    // Don't block on request
+    // Don't block on remote HTTP request (we are in a transaction!)
     generateThumbnailFromUrl(video, videoObject.icon)
       .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
 
     const videoCreated = await video.save(sequelizeOptions)
 
+    // Process files
     const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
     if (videoFileAttributes.length === 0) {
       throw new Error('Cannot find valid files for video %s ' + videoObject.url)
     }
 
-    const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
-    await Promise.all(tasks)
+    const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
+    await Promise.all(videoFilePromises)
 
+    // Process tags
     const tags = videoObject.tag.map(t => t.name)
     const tagInstances = await TagModel.findOrCreateTags(tags, t)
     await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
 
+    // Process captions
+    const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
+      return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
+    })
+    await Promise.all(videoCaptionsPromises)
+
     logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
 
     videoCreated.VideoChannel = channelActor.VideoChannel
@@ -328,7 +345,7 @@ export {
   federateVideoIfNeeded,
   fetchRemoteVideo,
   getOrCreateAccountAndVideoAndChannel,
-  fetchRemoteVideoPreview,
+  fetchRemoteVideoStaticFile,
   fetchRemoteVideoDescription,
   generateThumbnailFromUrl,
   videoActivityObjectToDBAttributes,
diff --git a/server/lib/cache/abstract-video-static-file-cache.ts b/server/lib/cache/abstract-video-static-file-cache.ts
new file mode 100644 (file)
index 0000000..7eeeb6b
--- /dev/null
@@ -0,0 +1,54 @@
+import * as AsyncLRU from 'async-lru'
+import { createWriteStream } from 'fs'
+import { join } from 'path'
+import { unlinkPromise } from '../../helpers/core-utils'
+import { logger } from '../../helpers/logger'
+import { CACHE, CONFIG } from '../../initializers'
+import { VideoModel } from '../../models/video/video'
+import { fetchRemoteVideoStaticFile } from '../activitypub'
+import { VideoCaptionModel } from '../../models/video/video-caption'
+
+export abstract class AbstractVideoStaticFileCache <T> {
+
+  protected lru
+
+  abstract getFilePath (params: T): Promise<string>
+
+  // Load and save the remote file, then return the local path from filesystem
+  protected abstract loadRemoteFile (key: string): Promise<string>
+
+  init (max: number) {
+    this.lru = new AsyncLRU({
+      max,
+      load: (key, cb) => {
+        this.loadRemoteFile(key)
+          .then(res => cb(null, res))
+          .catch(err => cb(err))
+      }
+    })
+
+    this.lru.on('evict', (obj: { key: string, value: string }) => {
+      unlinkPromise(obj.value).then(() => logger.debug('%s evicted from %s', obj.value, this.constructor.name))
+    })
+  }
+
+  protected loadFromLRU (key: string) {
+    return new Promise<string>((res, rej) => {
+      this.lru.get(key, (err, value) => {
+        err ? rej(err) : res(value)
+      })
+    })
+  }
+
+  protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {
+    return new Promise<string>((res, rej) => {
+      const req = fetchRemoteVideoStaticFile(video, remoteStaticPath, rej)
+
+      const stream = createWriteStream(destPath)
+
+      req.pipe(stream)
+         .on('error', (err) => rej(err))
+         .on('finish', () => res(destPath))
+    })
+  }
+}
diff --git a/server/lib/cache/videos-caption-cache.ts b/server/lib/cache/videos-caption-cache.ts
new file mode 100644 (file)
index 0000000..1336610
--- /dev/null
@@ -0,0 +1,53 @@
+import { join } from 'path'
+import { CACHE, CONFIG } from '../../initializers'
+import { VideoModel } from '../../models/video/video'
+import { VideoCaptionModel } from '../../models/video/video-caption'
+import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
+
+type GetPathParam = { videoId: string, language: string }
+
+class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
+
+  private static readonly KEY_DELIMITER = '%'
+  private static instance: VideosCaptionCache
+
+  private constructor () {
+    super()
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+
+  async getFilePath (params: GetPathParam) {
+    const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
+    if (!videoCaption) return undefined
+
+    if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
+
+    const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language
+    return this.loadFromLRU(key)
+  }
+
+  protected async loadRemoteFile (key: string) {
+    const [ videoId, language ] = key.split(VideosCaptionCache.KEY_DELIMITER)
+
+    const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(videoId, language)
+    if (!videoCaption) return undefined
+
+    if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
+
+    // Used to fetch the path
+    const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
+    if (!video) return undefined
+
+    const remoteStaticPath = videoCaption.getCaptionStaticPath()
+    const destPath = join(CACHE.DIRECTORIES.VIDEO_CAPTIONS, videoCaption.getCaptionName())
+
+    return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
+  }
+}
+
+export {
+  VideosCaptionCache
+}
index d09d55e111704db11c16f490d40ece8819d630f9..1c0e7ed9d65a5627dbad7f6a9160cf7af49d252d 100644 (file)
@@ -1,71 +1,39 @@
-import * as asyncLRU from 'async-lru'
-import { createWriteStream } from 'fs'
 import { join } from 'path'
-import { unlinkPromise } from '../../helpers/core-utils'
-import { logger } from '../../helpers/logger'
-import { CACHE, CONFIG } from '../../initializers'
+import { CACHE, CONFIG, STATIC_PATHS } from '../../initializers'
 import { VideoModel } from '../../models/video/video'
-import { fetchRemoteVideoPreview } from '../activitypub'
+import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
 
-class VideosPreviewCache {
+class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
 
   private static instance: VideosPreviewCache
 
-  private lru
-
-  private constructor () { }
+  private constructor () {
+    super()
+  }
 
   static get Instance () {
     return this.instance || (this.instance = new this())
   }
 
-  init (max: number) {
-    this.lru = new asyncLRU({
-      max,
-      load: (key, cb) => {
-        this.loadPreviews(key)
-          .then(res => cb(null, res))
-          .catch(err => cb(err))
-      }
-    })
-
-    this.lru.on('evict', (obj: { key: string, value: string }) => {
-      unlinkPromise(obj.value).then(() => logger.debug('%s evicted from VideosPreviewCache', obj.value))
-    })
-  }
-
-  async getPreviewPath (key: string) {
-    const video = await VideoModel.loadByUUID(key)
+  async getFilePath (videoUUID: string) {
+    const video = await VideoModel.loadByUUID(videoUUID)
     if (!video) return undefined
 
     if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
 
-    return new Promise<string>((res, rej) => {
-      this.lru.get(key, (err, value) => {
-        err ? rej(err) : res(value)
-      })
-    })
+    return this.loadFromLRU(videoUUID)
   }
 
-  private async loadPreviews (key: string) {
+  protected async loadRemoteFile (key: string) {
     const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
     if (!video) return undefined
 
-    if (video.isOwned()) throw new Error('Cannot load preview of owned video.')
-
-    return this.saveRemotePreviewAndReturnPath(video)
-  }
+    if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
 
-  private saveRemotePreviewAndReturnPath (video: VideoModel) {
-    return new Promise<string>((res, rej) => {
-      const req = fetchRemoteVideoPreview(video, rej)
-      const path = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
-      const stream = createWriteStream(path)
+    const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
+    const destPath = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
 
-      req.pipe(stream)
-        .on('error', (err) => rej(err))
-        .on('finish', () => res(path))
-    })
+    return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
   }
 }
 
diff --git a/server/middlewares/validators/video-captions.ts b/server/middlewares/validators/video-captions.ts
new file mode 100644 (file)
index 0000000..b6d92d3
--- /dev/null
@@ -0,0 +1,70 @@
+import * as express from 'express'
+import { areValidationErrors } from './utils'
+import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos'
+import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
+import { body, param } from 'express-validator/check'
+import { CONSTRAINTS_FIELDS } from '../../initializers'
+import { UserRight } from '../../../shared'
+import { logger } from '../../helpers/logger'
+import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
+
+const addVideoCaptionValidator = [
+  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
+  param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
+  body('captionfile')
+    .custom((value, { req }) => isVideoCaptionFile(req.files, 'captionfile')).withMessage(
+    'This caption file is not supported or too large. Please, make sure it is of the following type : '
+    + CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME.join(', ')
+  ),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking addVideoCaption parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    if (!await isVideoExist(req.params.videoId, res)) return
+
+    // Check if the user who did the request is able to update the video
+    const user = res.locals.oauth.token.User
+    if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
+
+    return next()
+  }
+]
+
+const deleteVideoCaptionValidator = [
+  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
+  param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+    if (!await isVideoExist(req.params.videoId, res)) return
+    if (!await isVideoCaptionExist(res.locals.video, req.params.captionLanguage, res)) return
+
+    // Check if the user who did the request is able to update the video
+    const user = res.locals.oauth.token.User
+    if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
+
+    return next()
+  }
+]
+
+const listVideoCaptionsValidator = [
+  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+    if (!await isVideoExist(req.params.videoId, res)) return
+
+    return next()
+  }
+]
+
+export {
+  addVideoCaptionValidator,
+  listVideoCaptionsValidator,
+  deleteVideoCaptionValidator
+}
index 59d65d5a401fbfb631846f986726367488d99619..899def6fccacacce85e19af1c193b0399a70c0b9 100644 (file)
@@ -12,6 +12,7 @@ import {
   toValueOrNull
 } from '../../helpers/custom-validators/misc'
 import {
+  checkUserCanManageVideo,
   isScheduleVideoUpdatePrivacyValid,
   isVideoAbuseReasonValid,
   isVideoCategoryValid,
@@ -31,8 +32,6 @@ import {
 import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils'
 import { logger } from '../../helpers/logger'
 import { CONSTRAINTS_FIELDS } from '../../initializers'
-import { UserModel } from '../../models/account/user'
-import { VideoModel } from '../../models/video/video'
 import { VideoShareModel } from '../../models/video/video-share'
 import { authenticate } from '../oauth'
 import { areValidationErrors } from './utils'
@@ -40,17 +39,17 @@ import { areValidationErrors } from './utils'
 const videosAddValidator = [
   body('videofile')
     .custom((value, { req }) => isVideoFile(req.files)).withMessage(
-      'This file is not supported or too large. Please, make sure it is of the following type : '
+      'This file is not supported or too large. Please, make sure it is of the following type: '
       + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
     ),
   body('thumbnailfile')
     .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
-      'This thumbnail file is not supported or too large. Please, make sure it is of the following type : '
+      'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
       + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
     ),
   body('previewfile')
     .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
-      'This preview file is not supported or too large. Please, make sure it is of the following type : '
+      'This preview file is not supported or too large. Please, make sure it is of the following type: '
       + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
     ),
   body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
@@ -152,12 +151,12 @@ const videosUpdateValidator = [
   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
   body('thumbnailfile')
     .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
-      'This thumbnail file is not supported or too large. Please, make sure it is of the following type : '
+      'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
       + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
     ),
   body('previewfile')
     .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
-      'This preview file is not supported or too large. Please, make sure it is of the following type : '
+      'This preview file is not supported or too large. Please, make sure it is of the following type: '
       + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
     ),
   body('name')
@@ -373,29 +372,6 @@ export {
 
 // ---------------------------------------------------------------------------
 
-function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: express.Response) {
-  // Retrieve the user who did the request
-  if (video.isOwned() === false) {
-    res.status(403)
-              .json({ error: 'Cannot manage a video of another server.' })
-              .end()
-    return false
-  }
-
-  // Check if the user can delete the video
-  // The user can delete it if he has the right
-  // Or if s/he is the video's account
-  const account = video.VideoChannel.Account
-  if (user.hasRight(right) === false && account.userId !== user.id) {
-    res.status(403)
-              .json({ error: 'Cannot manage a video of another user.' })
-              .end()
-    return false
-  }
-
-  return true
-}
-
 function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) {
   // Files are optional
   if (!req.files) return false
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
new file mode 100644 (file)
index 0000000..9920dfc
--- /dev/null
@@ -0,0 +1,173 @@
+import * as Sequelize from 'sequelize'
+import {
+  AllowNull,
+  BeforeDestroy,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  ForeignKey,
+  Is,
+  Model,
+  Scopes,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { throwIfNotValid } from '../utils'
+import { VideoModel } from './video'
+import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
+import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
+import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers'
+import { join } from 'path'
+import { logger } from '../../helpers/logger'
+import { unlinkPromise } from '../../helpers/core-utils'
+
+export enum ScopeNames {
+  WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
+}
+
+@Scopes({
+  [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
+    include: [
+      {
+        attributes: [ 'uuid', 'remote' ],
+        model: () => VideoModel.unscoped(),
+        required: true
+      }
+    ]
+  }
+})
+
+@Table({
+  tableName: 'videoCaption',
+  indexes: [
+    {
+      fields: [ 'videoId' ]
+    },
+    {
+      fields: [ 'videoId', 'language' ],
+      unique: true
+    }
+  ]
+})
+export class VideoCaptionModel extends Model<VideoCaptionModel> {
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AllowNull(false)
+  @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
+  @Column
+  language: string
+
+  @ForeignKey(() => VideoModel)
+  @Column
+  videoId: number
+
+  @BelongsTo(() => VideoModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  Video: VideoModel
+
+  @BeforeDestroy
+  static async removeFiles (instance: VideoCaptionModel) {
+
+    if (instance.isOwned()) {
+      if (!instance.Video) {
+        instance.Video = await instance.$get('Video') as VideoModel
+      }
+
+      logger.debug('Removing captions %s of video %s.', instance.Video.uuid, instance.language)
+      return instance.removeCaptionFile()
+    }
+
+    return undefined
+  }
+
+  static loadByVideoIdAndLanguage (videoId: string | number, language: string) {
+    const videoInclude = {
+      model: VideoModel.unscoped(),
+      attributes: [ 'id', 'remote', 'uuid' ],
+      where: { }
+    }
+
+    if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId
+    else videoInclude.where['id'] = videoId
+
+    const query = {
+      where: {
+        language
+      },
+      include: [
+        videoInclude
+      ]
+    }
+
+    return VideoCaptionModel.findOne(query)
+  }
+
+  static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) {
+    const values = {
+      videoId,
+      language
+    }
+
+    return VideoCaptionModel.upsert(values, { transaction })
+  }
+
+  static listVideoCaptions (videoId: number) {
+    const query = {
+      order: [ [ 'language', 'ASC' ] ],
+      where: {
+        videoId
+      }
+    }
+
+    return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
+  }
+
+  static getLanguageLabel (language: string) {
+    return VIDEO_LANGUAGES[language] || 'Unknown'
+  }
+
+  static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) {
+    const query = {
+      where: {
+        videoId
+      },
+      transaction
+    }
+
+    return VideoCaptionModel.destroy(query)
+  }
+
+  isOwned () {
+    return this.Video.remote === false
+  }
+
+  toFormattedJSON (): VideoCaption {
+    return {
+      language: {
+        id: this.language,
+        label: VideoCaptionModel.getLanguageLabel(this.language)
+      },
+      captionPath: this.getCaptionStaticPath()
+    }
+  }
+
+  getCaptionStaticPath () {
+    return join(STATIC_PATHS.VIDEO_CAPTIONS, this.getCaptionName())
+  }
+
+  getCaptionName () {
+    return `${this.Video.uuid}-${this.language}.vtt`
+  }
+
+  removeCaptionFile () {
+    return unlinkPromise(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName())
+  }
+}
index ab33b7c99054b76d27764c6cedd1636c8bdde9fa..74a3a5d05a939d4961e6e437b91f8a21c7da4499 100644 (file)
@@ -92,6 +92,7 @@ import { VideoFileModel } from './video-file'
 import { VideoShareModel } from './video-share'
 import { VideoTagModel } from './video-tag'
 import { ScheduleVideoUpdateModel } from './schedule-video-update'
+import { VideoCaptionModel } from './video-caption'
 
 export enum ScopeNames {
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
@@ -526,6 +527,17 @@ export class VideoModel extends Model<VideoModel> {
   })
   ScheduleVideoUpdate: ScheduleVideoUpdateModel
 
+  @HasMany(() => VideoCaptionModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade',
+    hooks: true,
+    ['separate' as any]: true
+  })
+  VideoCaptions: VideoCaptionModel[]
+
   @BeforeDestroy
   static async sendDelete (instance: VideoModel, options) {
     if (instance.isOwned()) {
@@ -550,7 +562,7 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   @BeforeDestroy
-  static async removeFilesAndSendDelete (instance: VideoModel) {
+  static async removeFiles (instance: VideoModel) {
     const tasks: Promise<any>[] = []
 
     logger.debug('Removing files of video %s.', instance.url)
@@ -615,6 +627,11 @@ export class VideoModel extends Model<VideoModel> {
         ]
       },
       include: [
+        {
+          attributes: [ 'language' ],
+          model: VideoCaptionModel.unscoped(),
+          required: false
+        },
         {
           attributes: [ 'id', 'url' ],
           model: VideoShareModel.unscoped(),
@@ -1028,15 +1045,15 @@ export class VideoModel extends Model<VideoModel> {
     videoFile.infoHash = parsedTorrent.infoHash
   }
 
-  getEmbedPath () {
+  getEmbedStaticPath () {
     return '/videos/embed/' + this.uuid
   }
 
-  getThumbnailPath () {
+  getThumbnailStaticPath () {
     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
   }
 
-  getPreviewPath () {
+  getPreviewStaticPath () {
     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
   }
 
@@ -1077,9 +1094,9 @@ export class VideoModel extends Model<VideoModel> {
       views: this.views,
       likes: this.likes,
       dislikes: this.dislikes,
-      thumbnailPath: this.getThumbnailPath(),
-      previewPath: this.getPreviewPath(),
-      embedPath: this.getEmbedPath(),
+      thumbnailPath: this.getThumbnailStaticPath(),
+      previewPath: this.getPreviewStaticPath(),
+      embedPath: this.getEmbedStaticPath(),
       createdAt: this.createdAt,
       updatedAt: this.updatedAt,
       publishedAt: this.publishedAt,
@@ -1247,6 +1264,14 @@ export class VideoModel extends Model<VideoModel> {
       href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
     })
 
+    const subtitleLanguage = []
+    for (const caption of this.VideoCaptions) {
+      subtitleLanguage.push({
+        identifier: caption.language,
+        name: VideoCaptionModel.getLanguageLabel(caption.language)
+      })
+    }
+
     return {
       type: 'Video' as 'Video',
       id: this.url,
@@ -1267,6 +1292,7 @@ export class VideoModel extends Model<VideoModel> {
       mediaType: 'text/markdown',
       content: this.getTruncatedDescription(),
       support: this.support,
+      subtitleLanguage,
       icon: {
         type: 'Image',
         url: this.getThumbnailUrl(baseUrlHttp),
index 6aa31e38d649859c0fb06062efbd1586024cd3f3..03855237fa4ea3a110832d964bdc4e0df8cdf418 100644 (file)
@@ -35,6 +35,9 @@ describe('Test config API validators', function () {
     cache: {
       previews: {
         size: 2
+      },
+      captions: {
+        size: 3
       }
     },
     signup: {
index 4c3b372f563a37f664bf8544064f99f899747014..c0e0302df82fa870188569c7e99f671a5ea1673e 100644 (file)
@@ -6,6 +6,7 @@ import './services'
 import './users'
 import './video-abuses'
 import './video-blacklist'
+import './video-captions'
 import './video-channels'
 import './video-comments'
 import './videos'
diff --git a/server/tests/api/check-params/video-captions.ts b/server/tests/api/check-params/video-captions.ts
new file mode 100644 (file)
index 0000000..12f890d
--- /dev/null
@@ -0,0 +1,223 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+  createUser,
+  flushTests,
+  killallServers,
+  makeDeleteRequest,
+  makeGetRequest,
+  makeUploadRequest,
+  runServer,
+  ServerInfo,
+  setAccessTokensToServers,
+  uploadVideo,
+  userLogin
+} from '../../utils'
+import { join } from 'path'
+
+describe('Test video captions API validator', function () {
+  const path = '/api/v1/videos/'
+
+  let server: ServerInfo
+  let userAccessToken: string
+  let videoUUID: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    await flushTests()
+
+    server = await runServer(1)
+
+    await setAccessTokensToServers([ server ])
+
+    {
+      const res = await uploadVideo(server.url, server.accessToken, {})
+      videoUUID = res.body.video.uuid
+    }
+
+    {
+      const user = {
+        username: 'user1',
+        password: 'my super password'
+      }
+      await createUser(server.url, server.accessToken, user.username, user.password)
+      userAccessToken = await userLogin(server, user)
+    }
+  })
+
+  describe('When adding video caption', function () {
+    const fields = { }
+    const attaches = {
+      'captionfile': join(__dirname, '..', '..', 'fixtures', 'subtitle-good1.vtt')
+    }
+
+    it('Should fail without a valid uuid', async function () {
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions',
+        token: server.accessToken,
+        fields,
+        attaches
+      })
+    })
+
+    it('Should fail with an unknown id', async function () {
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions',
+        token: server.accessToken,
+        fields,
+        attaches
+      })
+    })
+
+    it('Should fail with a missing language in path', async function () {
+      const captionPath = path + videoUUID + '/captions'
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: captionPath,
+        token: server.accessToken,
+        fields,
+        attaches
+      })
+    })
+
+    it('Should fail with an unknown language', async function () {
+      const captionPath = path + videoUUID + '/captions/15'
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: captionPath,
+        token: server.accessToken,
+        fields,
+        attaches
+      })
+    })
+
+    it('Should fail without access token', async function () {
+      const captionPath = path + videoUUID + '/captions/fr'
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: captionPath,
+        fields,
+        attaches,
+        statusCodeExpected: 401
+      })
+    })
+
+    it('Should fail with a bad access token', async function () {
+      const captionPath = path + videoUUID + '/captions/fr'
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: captionPath,
+        token: 'blabla',
+        fields,
+        attaches,
+        statusCodeExpected: 401
+      })
+    })
+
+    it('Should success with the correct parameters', async function () {
+      const captionPath = path + videoUUID + '/captions/fr'
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: captionPath,
+        token: server.accessToken,
+        fields,
+        attaches,
+        statusCodeExpected: 204
+      })
+    })
+  })
+
+  describe('When listing video captions', function () {
+    it('Should fail without a valid uuid', async function () {
+      await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions' })
+    })
+
+    it('Should fail with an unknown id', async function () {
+      await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions', statusCodeExpected: 404 })
+    })
+
+    it('Should success with the correct parameters', async function () {
+      await makeGetRequest({ url: server.url, path: path + videoUUID + '/captions', statusCodeExpected: 200 })
+    })
+  })
+
+  describe('When deleting video caption', function () {
+    it('Should fail without a valid uuid', async function () {
+      await makeDeleteRequest({
+        url: server.url,
+        path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions/fr',
+        token: server.accessToken
+      })
+    })
+
+    it('Should fail with an unknown id', async function () {
+      await makeDeleteRequest({
+        url: server.url,
+        path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/fr',
+        token: server.accessToken,
+        statusCodeExpected: 404
+      })
+    })
+
+    it('Should fail with an invalid language', async function () {
+      await makeDeleteRequest({
+        url: server.url,
+        path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/16',
+        token: server.accessToken
+      })
+    })
+
+    it('Should fail with a missing language', async function () {
+      const captionPath = path + videoUUID + '/captions'
+      await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
+    })
+
+    it('Should fail with an unknown language', async function () {
+      const captionPath = path + videoUUID + '/captions/15'
+      await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
+    })
+
+    it('Should fail without access token', async function () {
+      const captionPath = path + videoUUID + '/captions/fr'
+      await makeDeleteRequest({ url: server.url, path: captionPath, statusCodeExpected: 401 })
+    })
+
+    it('Should fail with a bad access token', async function () {
+      const captionPath = path + videoUUID + '/captions/fr'
+      await makeDeleteRequest({ url: server.url, path: captionPath, token: 'coucou', statusCodeExpected: 401 })
+    })
+
+    it('Should fail with another user', async function () {
+      const captionPath = path + videoUUID + '/captions/fr'
+      await makeDeleteRequest({ url: server.url, path: captionPath, token: userAccessToken, statusCodeExpected: 403 })
+    })
+
+    it('Should success with the correct parameters', async function () {
+      const captionPath = path + videoUUID + '/captions/fr'
+      await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken, statusCodeExpected: 204 })
+    })
+  })
+
+  after(async function () {
+    killallServers([ server ])
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
index 2454ec2f9d11c029f1ed8efd2f199a9b3986cc9f..d530dfc06640df40ed9979e4fc969fa24be8e713 100644 (file)
@@ -4,6 +4,7 @@ import './check-params'
 import './users/users'
 import './videos/single-server'
 import './videos/video-abuse'
+import './videos/video-captions'
 import './videos/video-blacklist'
 import './videos/video-blacklist-management'
 import './videos/video-description'
index 4de0d6b1042dc3196ddfcef3f4764cfe0d0ccb3d..79b5aaf2dd0a140c920b50d1a7e23b9219249c57 100644 (file)
@@ -14,6 +14,61 @@ import {
   registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig
 } from '../../utils/index'
 
+function checkInitialConfig (data: CustomConfig) {
+  expect(data.instance.name).to.equal('PeerTube')
+  expect(data.instance.shortDescription).to.equal(
+    'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
+    'with WebTorrent and Angular.'
+  )
+  expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
+  expect(data.instance.terms).to.equal('No terms for now.')
+  expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
+  expect(data.instance.defaultNSFWPolicy).to.equal('display')
+  expect(data.instance.customizations.css).to.be.empty
+  expect(data.instance.customizations.javascript).to.be.empty
+  expect(data.services.twitter.username).to.equal('@Chocobozzz')
+  expect(data.services.twitter.whitelisted).to.be.false
+  expect(data.cache.previews.size).to.equal(1)
+  expect(data.cache.captions.size).to.equal(1)
+  expect(data.signup.enabled).to.be.true
+  expect(data.signup.limit).to.equal(4)
+  expect(data.admin.email).to.equal('admin1@example.com')
+  expect(data.user.videoQuota).to.equal(5242880)
+  expect(data.transcoding.enabled).to.be.false
+  expect(data.transcoding.threads).to.equal(2)
+  expect(data.transcoding.resolutions['240p']).to.be.true
+  expect(data.transcoding.resolutions['360p']).to.be.true
+  expect(data.transcoding.resolutions['480p']).to.be.true
+  expect(data.transcoding.resolutions['720p']).to.be.true
+  expect(data.transcoding.resolutions['1080p']).to.be.true
+}
+
+function checkUpdatedConfig (data: CustomConfig) {
+  expect(data.instance.name).to.equal('PeerTube updated')
+  expect(data.instance.shortDescription).to.equal('my short description')
+  expect(data.instance.description).to.equal('my super description')
+  expect(data.instance.terms).to.equal('my super terms')
+  expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
+  expect(data.instance.defaultNSFWPolicy).to.equal('blur')
+  expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
+  expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
+  expect(data.services.twitter.username).to.equal('@Kuja')
+  expect(data.services.twitter.whitelisted).to.be.true
+  expect(data.cache.previews.size).to.equal(2)
+  expect(data.cache.captions.size).to.equal(3)
+  expect(data.signup.enabled).to.be.false
+  expect(data.signup.limit).to.equal(5)
+  expect(data.admin.email).to.equal('superadmin1@example.com')
+  expect(data.user.videoQuota).to.equal(5242881)
+  expect(data.transcoding.enabled).to.be.true
+  expect(data.transcoding.threads).to.equal(1)
+  expect(data.transcoding.resolutions['240p']).to.be.false
+  expect(data.transcoding.resolutions['360p']).to.be.true
+  expect(data.transcoding.resolutions['480p']).to.be.true
+  expect(data.transcoding.resolutions['720p']).to.be.false
+  expect(data.transcoding.resolutions['1080p']).to.be.false
+}
+
 describe('Test config', function () {
   let server = null
 
@@ -51,35 +106,11 @@ describe('Test config', function () {
     const res = await getCustomConfig(server.url, server.accessToken)
     const data = res.body as CustomConfig
 
-    expect(data.instance.name).to.equal('PeerTube')
-    expect(data.instance.shortDescription).to.equal(
-      'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
-      'with WebTorrent and Angular.'
-    )
-    expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
-    expect(data.instance.terms).to.equal('No terms for now.')
-    expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
-    expect(data.instance.defaultNSFWPolicy).to.equal('display')
-    expect(data.instance.customizations.css).to.be.empty
-    expect(data.instance.customizations.javascript).to.be.empty
-    expect(data.services.twitter.username).to.equal('@Chocobozzz')
-    expect(data.services.twitter.whitelisted).to.be.false
-    expect(data.cache.previews.size).to.equal(1)
-    expect(data.signup.enabled).to.be.true
-    expect(data.signup.limit).to.equal(4)
-    expect(data.admin.email).to.equal('admin1@example.com')
-    expect(data.user.videoQuota).to.equal(5242880)
-    expect(data.transcoding.enabled).to.be.false
-    expect(data.transcoding.threads).to.equal(2)
-    expect(data.transcoding.resolutions['240p']).to.be.true
-    expect(data.transcoding.resolutions['360p']).to.be.true
-    expect(data.transcoding.resolutions['480p']).to.be.true
-    expect(data.transcoding.resolutions['720p']).to.be.true
-    expect(data.transcoding.resolutions['1080p']).to.be.true
+    checkInitialConfig(data)
   })
 
   it('Should update the customized configuration', async function () {
-    const newCustomConfig = {
+    const newCustomConfig: CustomConfig = {
       instance: {
         name: 'PeerTube updated',
         shortDescription: 'my short description',
@@ -101,6 +132,9 @@ describe('Test config', function () {
       cache: {
         previews: {
           size: 2
+        },
+        captions: {
+          size: 3
         }
       },
       signup: {
@@ -130,28 +164,7 @@ describe('Test config', function () {
     const res = await getCustomConfig(server.url, server.accessToken)
     const data = res.body
 
-    expect(data.instance.name).to.equal('PeerTube updated')
-    expect(data.instance.shortDescription).to.equal('my short description')
-    expect(data.instance.description).to.equal('my super description')
-    expect(data.instance.terms).to.equal('my super terms')
-    expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
-    expect(data.instance.defaultNSFWPolicy).to.equal('blur')
-    expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
-    expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
-    expect(data.services.twitter.username).to.equal('@Kuja')
-    expect(data.services.twitter.whitelisted).to.be.true
-    expect(data.cache.previews.size).to.equal(2)
-    expect(data.signup.enabled).to.be.false
-    expect(data.signup.limit).to.equal(5)
-    expect(data.admin.email).to.equal('superadmin1@example.com')
-    expect(data.user.videoQuota).to.equal(5242881)
-    expect(data.transcoding.enabled).to.be.true
-    expect(data.transcoding.threads).to.equal(1)
-    expect(data.transcoding.resolutions['240p']).to.be.false
-    expect(data.transcoding.resolutions['360p']).to.be.true
-    expect(data.transcoding.resolutions['480p']).to.be.true
-    expect(data.transcoding.resolutions['720p']).to.be.false
-    expect(data.transcoding.resolutions['1080p']).to.be.false
+    checkUpdatedConfig(data)
   })
 
   it('Should have the configuration updated after a restart', async function () {
@@ -164,28 +177,7 @@ describe('Test config', function () {
     const res = await getCustomConfig(server.url, server.accessToken)
     const data = res.body
 
-    expect(data.instance.name).to.equal('PeerTube updated')
-    expect(data.instance.shortDescription).to.equal('my short description')
-    expect(data.instance.description).to.equal('my super description')
-    expect(data.instance.terms).to.equal('my super terms')
-    expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
-    expect(data.instance.defaultNSFWPolicy).to.equal('blur')
-    expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
-    expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
-    expect(data.services.twitter.username).to.equal('@Kuja')
-    expect(data.services.twitter.whitelisted).to.be.true
-    expect(data.cache.previews.size).to.equal(2)
-    expect(data.signup.enabled).to.be.false
-    expect(data.signup.limit).to.equal(5)
-    expect(data.admin.email).to.equal('superadmin1@example.com')
-    expect(data.user.videoQuota).to.equal(5242881)
-    expect(data.transcoding.enabled).to.be.true
-    expect(data.transcoding.threads).to.equal(1)
-    expect(data.transcoding.resolutions['240p']).to.be.false
-    expect(data.transcoding.resolutions['360p']).to.be.true
-    expect(data.transcoding.resolutions['480p']).to.be.true
-    expect(data.transcoding.resolutions['720p']).to.be.false
-    expect(data.transcoding.resolutions['1080p']).to.be.false
+    checkUpdatedConfig(data)
   })
 
   it('Should fetch the about information', async function () {
@@ -206,31 +198,7 @@ describe('Test config', function () {
     const res = await getCustomConfig(server.url, server.accessToken)
     const data = res.body
 
-    expect(data.instance.name).to.equal('PeerTube')
-    expect(data.instance.shortDescription).to.equal(
-      'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
-      'with WebTorrent and Angular.'
-    )
-    expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
-    expect(data.instance.terms).to.equal('No terms for now.')
-    expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
-    expect(data.instance.defaultNSFWPolicy).to.equal('display')
-    expect(data.instance.customizations.css).to.be.empty
-    expect(data.instance.customizations.javascript).to.be.empty
-    expect(data.services.twitter.username).to.equal('@Chocobozzz')
-    expect(data.services.twitter.whitelisted).to.be.false
-    expect(data.cache.previews.size).to.equal(1)
-    expect(data.signup.enabled).to.be.true
-    expect(data.signup.limit).to.equal(4)
-    expect(data.admin.email).to.equal('admin1@example.com')
-    expect(data.user.videoQuota).to.equal(5242880)
-    expect(data.transcoding.enabled).to.be.false
-    expect(data.transcoding.threads).to.equal(2)
-    expect(data.transcoding.resolutions['240p']).to.be.true
-    expect(data.transcoding.resolutions['360p']).to.be.true
-    expect(data.transcoding.resolutions['480p']).to.be.true
-    expect(data.transcoding.resolutions['720p']).to.be.true
-    expect(data.transcoding.resolutions['1080p']).to.be.true
+    checkInitialConfig(data)
   })
 
   after(async function () {
index ce42df0a612317a25e8bbf5d6800fe285bb87342..a19b47509792d6fb359c0f3be3b6b816e28d44e2 100644 (file)
@@ -26,6 +26,8 @@ import {
 } from '../../utils/videos/video-comments'
 import { rateVideo } from '../../utils/videos/videos'
 import { waitJobs } from '../../utils/server/jobs'
+import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
+import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
 
 const expect = chai.expect
 
@@ -244,6 +246,16 @@ describe('Test follows', function () {
           const text3 = 'my second answer to thread 1'
           await addVideoCommentReply(servers[ 2 ].url, servers[ 2 ].accessToken, video4.id, threadId, text3)
         }
+
+        {
+          await createVideoCaption({
+            url: servers[2].url,
+            accessToken: servers[2].accessToken,
+            language: 'ar',
+            videoId: video4.id,
+            fixture: 'subtitle-good2.vtt'
+          })
+        }
       }
 
       await waitJobs(servers)
@@ -266,7 +278,7 @@ describe('Test follows', function () {
       await expectAccountFollows(servers[2].url, 'peertube@localhost:9003', 1, 0)
     })
 
-    it('Should propagate videos', async function () {
+    it('Should have propagated videos', async function () {
       const res = await getVideosList(servers[ 0 ].url)
       expect(res.body.total).to.equal(7)
 
@@ -314,7 +326,7 @@ describe('Test follows', function () {
       await completeVideoCheck(servers[ 0 ].url, video4, checkAttributes)
     })
 
-    it('Should propagate comments', async function () {
+    it('Should have propagated comments', async function () {
       const res1 = await getVideoCommentThreads(servers[0].url, video4.id, 0, 5)
 
       expect(res1.body.total).to.equal(1)
@@ -353,6 +365,18 @@ describe('Test follows', function () {
       expect(secondChild.children).to.have.lengthOf(0)
     })
 
+    it('Should have propagated captions', async function () {
+      const res = await listVideoCaptions(servers[0].url, video4.id)
+      expect(res.body.total).to.equal(1)
+      expect(res.body.data).to.have.lengthOf(1)
+
+      const caption1: VideoCaption = res.body.data[0]
+      expect(caption1.language.id).to.equal('ar')
+      expect(caption1.language.label).to.equal('Arabic')
+      expect(caption1.captionPath).to.equal('/static/video-captions/' + video4.uuid + '-ar.vtt')
+      await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.')
+    })
+
     it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
       this.timeout(5000)
 
diff --git a/server/tests/api/videos/video-captions.ts b/server/tests/api/videos/video-captions.ts
new file mode 100644 (file)
index 0000000..cbf5268
--- /dev/null
@@ -0,0 +1,139 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { doubleFollow, flushAndRunMultipleServers, uploadVideo } from '../../utils'
+import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
+import { waitJobs } from '../../utils/server/jobs'
+import { createVideoCaption, deleteVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
+import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
+
+const expect = chai.expect
+
+describe('Test video captions', function () {
+  let servers: ServerInfo[]
+  let videoUUID: string
+
+  before(async function () {
+    this.timeout(30000)
+
+    await flushTests()
+
+    servers = await flushAndRunMultipleServers(2)
+
+    await setAccessTokensToServers(servers)
+    await doubleFollow(servers[0], servers[1])
+
+    await waitJobs(servers)
+
+    const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'my video name' })
+    videoUUID = res.body.video.uuid
+
+    await waitJobs(servers)
+  })
+
+  it('Should list the captions and return an empty list', async function () {
+    for (const server of servers) {
+      const res = await listVideoCaptions(server.url, videoUUID)
+      expect(res.body.total).to.equal(0)
+      expect(res.body.data).to.have.lengthOf(0)
+    }
+  })
+
+  it('Should create two new captions', async function () {
+    this.timeout(30000)
+
+    await createVideoCaption({
+      url: servers[0].url,
+      accessToken: servers[0].accessToken,
+      language: 'ar',
+      videoId: videoUUID,
+      fixture: 'subtitle-good1.vtt'
+    })
+
+    await createVideoCaption({
+      url: servers[0].url,
+      accessToken: servers[0].accessToken,
+      language: 'zh',
+      videoId: videoUUID,
+      fixture: 'subtitle-good2.vtt'
+    })
+
+    await waitJobs(servers)
+  })
+
+  it('Should list these uploaded captions', async function () {
+    for (const server of servers) {
+      const res = await listVideoCaptions(server.url, videoUUID)
+      expect(res.body.total).to.equal(2)
+      expect(res.body.data).to.have.lengthOf(2)
+
+      const caption1: VideoCaption = res.body.data[0]
+      expect(caption1.language.id).to.equal('ar')
+      expect(caption1.language.label).to.equal('Arabic')
+      expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
+      await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.')
+
+      const caption2: VideoCaption = res.body.data[1]
+      expect(caption2.language.id).to.equal('zh')
+      expect(caption2.language.label).to.equal('Chinese')
+      expect(caption2.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
+      await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.')
+    }
+  })
+
+  it('Should replace an existing caption', async function () {
+    this.timeout(30000)
+
+    await createVideoCaption({
+      url: servers[0].url,
+      accessToken: servers[0].accessToken,
+      language: 'ar',
+      videoId: videoUUID,
+      fixture: 'subtitle-good2.vtt'
+    })
+
+    await waitJobs(servers)
+  })
+
+  it('Should have this caption updated', async function () {
+    for (const server of servers) {
+      const res = await listVideoCaptions(server.url, videoUUID)
+      expect(res.body.total).to.equal(2)
+      expect(res.body.data).to.have.lengthOf(2)
+
+      const caption1: VideoCaption = res.body.data[0]
+      expect(caption1.language.id).to.equal('ar')
+      expect(caption1.language.label).to.equal('Arabic')
+      expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
+      await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.')
+    }
+  })
+
+  it('Should remove one caption', async function () {
+    this.timeout(30000)
+
+    await deleteVideoCaption(servers[0].url, servers[0].accessToken, videoUUID, 'ar')
+
+    await waitJobs(servers)
+  })
+
+  it('Should only list the caption that was not deleted', async function () {
+    for (const server of servers) {
+      const res = await listVideoCaptions(server.url, videoUUID)
+      expect(res.body.total).to.equal(1)
+      expect(res.body.data).to.have.lengthOf(1)
+
+      const caption: VideoCaption = res.body.data[0]
+
+      expect(caption.language.id).to.equal('zh')
+      expect(caption.language.label).to.equal('Chinese')
+      expect(caption.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
+      await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.')
+    }
+  })
+
+  after(async function () {
+    killallServers(servers)
+  })
+})
diff --git a/server/tests/fixtures/subtitle-good1.vtt b/server/tests/fixtures/subtitle-good1.vtt
new file mode 100644 (file)
index 0000000..04cd239
--- /dev/null
@@ -0,0 +1,8 @@
+WEBVTT
+
+00:01.000 --> 00:04.000
+Subtitle good 1.
+
+00:05.000 --> 00:09.000
+- It will perforate your stomach.
+- You could die.
\ No newline at end of file
diff --git a/server/tests/fixtures/subtitle-good2.vtt b/server/tests/fixtures/subtitle-good2.vtt
new file mode 100644 (file)
index 0000000..4d3256d
--- /dev/null
@@ -0,0 +1,8 @@
+WEBVTT
+
+00:01.000 --> 00:04.000
+Subtitle good 2.
+
+00:05.000 --> 00:09.000
+- It will perforate your stomach.
+- You could die.
\ No newline at end of file
index 7ac60a983b5faf68358ced7c13bd47a48f260b22..5e46004a7f48f8a24cac8bfc3961a7268f983483 100644 (file)
@@ -5,7 +5,6 @@ import { isAbsolute, join } from 'path'
 import * as request from 'supertest'
 import * as WebTorrent from 'webtorrent'
 import { readFileBufferPromise } from '../../../helpers/core-utils'
-import { ServerInfo } from '..'
 
 const expect = chai.expect
 let webtorrent = new WebTorrent()
diff --git a/server/tests/utils/videos/video-captions.ts b/server/tests/utils/videos/video-captions.ts
new file mode 100644 (file)
index 0000000..207e896
--- /dev/null
@@ -0,0 +1,66 @@
+import { makeDeleteRequest, makeGetRequest } from '../'
+import { buildAbsoluteFixturePath, makeUploadRequest } from '../index'
+import * as request from 'supertest'
+import * as chai from 'chai'
+
+const expect = chai.expect
+
+function createVideoCaption (args: {
+  url: string,
+  accessToken: string
+  videoId: string | number
+  language: string
+  fixture: string
+}) {
+  const path = '/api/v1/videos/' + args.videoId + '/captions/' + args.language
+
+  return makeUploadRequest({
+    method: 'PUT',
+    url: args.url,
+    path,
+    token: args.accessToken,
+    fields: {},
+    attaches: {
+      captionfile: buildAbsoluteFixturePath(args.fixture)
+    },
+    statusCodeExpected: 204
+  })
+}
+
+function listVideoCaptions (url: string, videoId: string | number) {
+  const path = '/api/v1/videos/' + videoId + '/captions'
+
+  return makeGetRequest({
+    url,
+    path,
+    statusCodeExpected: 200
+  })
+}
+
+function deleteVideoCaption (url: string, token: string, videoId: string | number, language: string) {
+  const path = '/api/v1/videos/' + videoId + '/captions/' + language
+
+  return makeDeleteRequest({
+    url,
+    token,
+    path,
+    statusCodeExpected: 204
+  })
+}
+
+async function testCaptionFile (url: string, captionPath: string, containsString: string) {
+  const res = await request(url)
+    .get(captionPath)
+    .expect(200)
+
+  expect(res.text).to.contain(containsString)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  createVideoCaption,
+  listVideoCaptions,
+  testCaptionFile,
+  deleteVideoCaption
+}
index c4071a6d99c2496b1b186c023d0470cf007185c8..90de8967b251c08bca479ce5d79ff572a53abc6f 100644 (file)
@@ -17,6 +17,7 @@ export interface VideoTorrentObject {
   category: ActivityIdentifierObject
   licence: ActivityIdentifierObject
   language: ActivityIdentifierObject
+  subtitleLanguage: ActivityIdentifierObject[]
   views: number
   sensitive: boolean
   commentsEnabled: boolean
index a3a651cd88aa1bc952b9314718b1509b43b4aae7..9c4718e43ed78efc9e8896ea86d60e0c4b9ddb21 100644 (file)
@@ -25,6 +25,10 @@ export interface CustomConfig {
     previews: {
       size: number
     }
+
+    captions: {
+      size: number
+    }
   }
 
   signup: {
index da0996dae76bc81dfd53fafd37480ee80e9c1ca7..217d142cdc17a7f5cfd08d635654ca166b4c7c98 100644 (file)
@@ -44,6 +44,15 @@ export interface ServerConfig {
     }
   }
 
+  videoCaption: {
+    file: {
+      size: {
+        max: number
+      },
+      extensions: string[]
+    }
+  }
+
   user: {
     videoQuota: number
   }
index 9edfb559a099f775fc2e479e60dae924f9f0c4f9..cb9669772a92e16374b50f3bf55260f48d58199c 100644 (file)
@@ -14,3 +14,5 @@ export * from './video-resolution.enum'
 export * from './video-update.model'
 export * from './video.model'
 export * from './video-state.enum'
+export * from './video-caption-update.model'
+export { VideoConstant } from './video-constant.model'
diff --git a/shared/models/videos/video-caption-update.model.ts b/shared/models/videos/video-caption-update.model.ts
new file mode 100644 (file)
index 0000000..ff57287
--- /dev/null
@@ -0,0 +1,4 @@
+export interface VideoCaptionUpdate {
+  language: string
+  captionfile: Blob
+}
diff --git a/shared/models/videos/video-caption.model.ts b/shared/models/videos/video-caption.model.ts
new file mode 100644 (file)
index 0000000..4695224
--- /dev/null
@@ -0,0 +1,6 @@
+import { VideoConstant } from './video-constant.model'
+
+export interface VideoCaption {
+  language: VideoConstant<string>
+  captionPath: string
+}
diff --git a/shared/models/videos/video-constant.model.ts b/shared/models/videos/video-constant.model.ts
new file mode 100644 (file)
index 0000000..342a7c0
--- /dev/null
@@ -0,0 +1,4 @@
+export interface VideoConstant<T> {
+  id: T
+  label: string
+}
index 4e1f15ee30cc77e923af08f7c06d16aa6ac45167..f7bbaac76bd13d7b0e6fcddde2e0eadea5b05dcb 100644 (file)
@@ -4,11 +4,7 @@ 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
-  label: string
-}
+import { VideoConstant } from './video-constant.model'
 
 export interface VideoFile {
   magnetUri: string
index 64fc9e82c6000f810a5795f12f67e55490b93b0c..ddac23c4ebc441e666791b21f687e14c6113ea70 100644 (file)
@@ -38,6 +38,7 @@ storage:
   previews: '../data/previews/'
   thumbnails: '../data/thumbnails/'
   torrents: '../data/torrents/'
+  captions: '../data/captions/'
   cache: '../data/cache/'
 
 log: