From: Chocobozzz Date: Thu, 12 Jul 2018 17:02:00 +0000 (+0200) Subject: Implement captions/subtitles X-Git-Tag: v1.0.0-beta.10.pre.1~70 X-Git-Url: https://git.librecmc.org/?a=commitdiff_plain;h=40e87e9ecc54e3513fb586928330a7855eb192c6;p=oweals%2Fpeertube.git Implement captions/subtitles --- diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index 1e5308531..97900e523 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html @@ -206,15 +206,17 @@ Check this checkbox, save the configuration and test with a video URL of your in -
Cache
+
+ Cache -
- +
+
+
+
+ + +
+ {{ formErrors.cacheCaptionsSize }} +
+
+
Customizations
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index 7b3e72803..8d476393f 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -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: { diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index 74363e6a1..3baefb6a7 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts @@ -59,6 +59,12 @@ export class ServerService { extensions: [] } }, + videoCaption: { + file: { + size: { max: 0 }, + extensions: [] + } + }, user: { videoQuota: -1 } diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts index 1b36bbc6b..0c2489a9d 100644 --- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts @@ -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: { diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts index 487683088..60d735ef7 100644 --- a/client/src/app/shared/forms/form-validators/index.ts +++ b/client/src/app/shared/forms/form-validators/index.ts @@ -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 index 000000000..d1b4667bb --- /dev/null +++ b/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts @@ -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.') + } + } + } +} diff --git a/client/src/app/shared/forms/index.ts b/client/src/app/shared/forms/index.ts index 7464bb022..41c321c4c 100644 --- a/client/src/app/shared/forms/index.ts +++ b/client/src/app/shared/forms/index.ts @@ -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 index 000000000..9fb1c9e3e --- /dev/null +++ b/client/src/app/shared/forms/reactive-file.component.html @@ -0,0 +1,14 @@ +
+
+ {{ inputLabel }} + +
+ +
(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})
+ +
{{ filename }}
+
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 index 000000000..d89844264 --- /dev/null +++ b/client/src/app/shared/forms/reactive-file.component.scss @@ -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 index 000000000..f5758b643 --- /dev/null +++ b/client/src/app/shared/forms/reactive-file.component.ts @@ -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() + + 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 + } +} diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index 53aff1b24..8381745f5 100644 --- a/client/src/app/shared/misc/utils.ts +++ b/client/src/app/shared/misc/utils.ts @@ -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 (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 } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 97e49e7ab..c3f4bf88b 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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 index 000000000..c48a70558 --- /dev/null +++ b/client/src/app/shared/video-caption/index.ts @@ -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 index 000000000..732f20158 --- /dev/null +++ b/client/src/app/shared/video-caption/video-caption-edit.model.ts @@ -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 index 000000000..4ae8ebd0a --- /dev/null +++ b/client/src/app/shared/video-caption/video-caption.service.ts @@ -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> { + return this.authHttp.get>(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[] = [] + + 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) + } +} diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index 5c820a227..6b1a299ea 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts @@ -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' diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 9498a06fe..b4c1f10f9 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts @@ -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 index 000000000..9cd303b29 --- /dev/null +++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html @@ -0,0 +1,47 @@ + 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 index 000000000..c6da1877e --- /dev/null +++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss @@ -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 index 000000000..45b8c71f8 --- /dev/null +++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts @@ -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() + + @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() + } +} diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html index 447c5ab9b..14d5f3614 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.html @@ -132,13 +132,39 @@
+ +
+ + + +
+ +
+ + + Delete +
+
+ +
+ No captions for now. +
+ +
+
+
@@ -172,3 +198,7 @@
+ + \ No newline at end of file diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss index 061eca4a7..03b8359de 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss @@ -7,6 +7,7 @@ .video-edit { height: 100%; + min-height: 300px; .form-group { margin-bottom: 25px; @@ -49,6 +50,40 @@ } } +.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; diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts index 66eb6611a..9394d7dab 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.ts @@ -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 () { diff --git a/client/src/app/videos/+video-edit/shared/video-edit.module.ts b/client/src/app/videos/+video-edit/shared/video-edit.module.ts index 6bf3e34b1..f6bd65fdc 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.module.ts +++ b/client/src/app/videos/+video-edit/shared/video-edit.module.ts @@ -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: [ diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.html b/client/src/app/videos/+video-edit/shared/video-image.component.html index e319d7ee7..c09c862c4 100644 --- a/client/src/app/videos/+video-edit/shared/video-image.component.html +++ b/client/src/app/videos/+video-edit/shared/video-image.component.html @@ -1,15 +1,8 @@
-
-
- {{ inputLabel }} - -
-
(extensions: {{ videoImageExtensions }}, max size: {{ maxVideoImageSize | bytes }})
-
+
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.scss b/client/src/app/videos/+video-edit/shared/video-image.component.scss index d4901e7ab..b63963bca 100644 --- a/client/src/app/videos/+video-edit/shared/video-image.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-image.component.scss @@ -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; diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.ts b/client/src/app/videos/+video-edit/shared/video-image.component.ts index 25955baaa..a604cde90 100644 --- a/client/src/app/videos/+video-edit/shared/video-image.component.ts +++ b/client/src/app/videos/+video-edit/shared/video-image.component.ts @@ -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 */ } diff --git a/client/src/app/videos/+video-edit/video-add.component.html b/client/src/app/videos/+video-edit/video-add.component.html index 7d9443209..9c2c01c65 100644 --- a/client/src/app/videos/+video-edit/video-add.component.html +++ b/client/src/app/videos/+video-edit/video-add.component.html @@ -46,7 +46,7 @@
diff --git a/client/src/app/videos/+video-edit/video-add.component.ts b/client/src/app/videos/+video-edit/video-add.component.ts index 7c4b6260b..8c30cedfb 100644 --- a/client/src/app/videos/+video-edit/video-add.component.ts +++ b/client/src/app/videos/+video-edit/video-add.component.ts @@ -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) + } + ) } } diff --git a/client/src/app/videos/+video-edit/video-update.component.html b/client/src/app/videos/+video-edit/video-update.component.html index 5cb16c8ab..9242c30a0 100644 --- a/client/src/app/videos/+video-edit/video-update.component.html +++ b/client/src/app/videos/+video-edit/video-update.component.html @@ -8,6 +8,7 @@
diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts index c4e6f44de..b67874401 100644 --- a/client/src/app/videos/+video-edit/video-update.component.ts +++ b/client/src/app/videos/+video-edit/video-update.component.ts @@ -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) + } + ) } diff --git a/config/default.yaml b/config/default.yaml index 9a9b5833f..d59425365 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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 diff --git a/config/production.yaml.example b/config/production.yaml.example index a4c80b1f1..98cdd7ca7 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -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: diff --git a/config/test-1.yaml b/config/test-1.yaml index cb658397c..503bbc661 100644 --- a/config/test-1.yaml +++ b/config/test-1.yaml @@ -16,6 +16,7 @@ storage: previews: 'test1/previews/' thumbnails: 'test1/thumbnails/' torrents: 'test1/torrents/' + captions: 'test1/captions/' cache: 'test1/cache/' admin: diff --git a/config/test-2.yaml b/config/test-2.yaml index 7b9787c91..8c77bf581 100644 --- a/config/test-2.yaml +++ b/config/test-2.yaml @@ -16,6 +16,7 @@ storage: previews: 'test2/previews/' thumbnails: 'test2/thumbnails/' torrents: 'test2/torrents/' + captions: 'test2/captions/' cache: 'test2/cache/' admin: diff --git a/config/test-3.yaml b/config/test-3.yaml index e7e30c07b..82d89567a 100644 --- a/config/test-3.yaml +++ b/config/test-3.yaml @@ -16,6 +16,7 @@ storage: previews: 'test3/previews/' thumbnails: 'test3/thumbnails/' torrents: 'test3/torrents/' + captions: 'test3/captions/' cache: 'test3/cache/' admin: diff --git a/config/test-4.yaml b/config/test-4.yaml index b80acd765..1aa56d041 100644 --- a/config/test-4.yaml +++ b/config/test-4.yaml @@ -16,6 +16,7 @@ storage: previews: 'test4/previews/' thumbnails: 'test4/thumbnails/' torrents: 'test4/torrents/' + captions: 'test4/captions/' cache: 'test4/cache/' admin: diff --git a/config/test-5.yaml b/config/test-5.yaml index 29d06f1da..5f1c2f583 100644 --- a/config/test-5.yaml +++ b/config/test-5.yaml @@ -16,6 +16,7 @@ storage: previews: 'test5/previews/' thumbnails: 'test5/thumbnails/' torrents: 'test5/torrents/' + captions: 'test5/captions/' cache: 'test5/cache/' admin: diff --git a/config/test-6.yaml b/config/test-6.yaml index 4fdc2402e..719629844 100644 --- a/config/test-6.yaml +++ b/config/test-6.yaml @@ -16,6 +16,7 @@ storage: previews: 'test6/previews/' thumbnails: 'test6/thumbnails/' torrents: 'test6/torrents/' + captions: 'test6/captions/' cache: 'test6/cache/' admin: diff --git a/server.ts b/server.ts index fffb8038f..a7fea34da 100644 --- 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() diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index ea8e25f68..3e6361906 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -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) diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index f678e3c4a..3788975a9 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -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 index 000000000..05412a17f --- /dev/null +++ b/server/controllers/api/videos/captions.ts @@ -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() +} diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 8c93ae89c..bbb5b8b4c 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -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) diff --git a/server/controllers/client.ts b/server/controllers/client.ts index 5413f61e8..bfdf35021 100644 --- a/server/controllers/client.ts +++ b/server/controllers/client.ts @@ -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', diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts index 1773fc71e..ff6b423d9 100644 --- a/server/controllers/feeds.ts +++ b/server/controllers/feeds.ts @@ -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 } diff --git a/server/controllers/services.ts b/server/controllers/services.ts index bd4404b62..352d0b19a 100644 --- a/server/controllers/services.ts +++ b/server/controllers/services.ts @@ -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 diff --git a/server/controllers/static.ts b/server/controllers/static.ts index 139ba67cc..679999859 100644 --- a/server/controllers/static.ts +++ b/server/controllers/static.ts @@ -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 }) diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index 37a251697..c49142a04 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts @@ -18,6 +18,7 @@ function activityPubContextify (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', diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts index 37c90a0c8..d97bbd2a9 100644 --- a/server/helpers/custom-validators/activitypub/videos.ts +++ b/server/helpers/custom-validators/activitypub/videos.ts @@ -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 index 000000000..fd4dc740b --- /dev/null +++ b/server/helpers/custom-validators/video-captions.ts @@ -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 +} diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 672f06dc0..b5cb126d9 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -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, diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index c5bc886d8..49809e64c 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -138,6 +138,7 @@ const CONFIG = { VIDEOS_DIR: buildPath(config.get('storage.videos')), THUMBNAILS_DIR: buildPath(config.get('storage.thumbnails')), PREVIEWS_DIR: buildPath(config.get('storage.previews')), + CAPTIONS_DIR: buildPath(config.get('storage.captions')), TORRENTS_DIR: buildPath(config.get('storage.torrents')), CACHE_DIR: buildPath(config.get('storage.cache')) }, @@ -183,6 +184,9 @@ const CONFIG = { CACHE: { PREVIEWS: { get SIZE () { return config.get('cache.previews.size') } + }, + VIDEO_CAPTIONS: { + get SIZE () { return config.get('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, diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 4d90c90fc..434d7ef19 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -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, diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts index 73db461c3..62791ff1b 100644 --- a/server/lib/activitypub/process/process-update.ts +++ b/server/lib/activitypub/process/process-update.ts @@ -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) diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index a16828fda..fdc082b61 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -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 index 000000000..7eeeb6b3a --- /dev/null +++ b/server/lib/cache/abstract-video-static-file-cache.ts @@ -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 { + + protected lru + + abstract getFilePath (params: T): Promise + + // Load and save the remote file, then return the local path from filesystem + protected abstract loadRemoteFile (key: string): Promise + + 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((res, rej) => { + this.lru.get(key, (err, value) => { + err ? rej(err) : res(value) + }) + }) + } + + protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) { + return new Promise((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 index 000000000..1336610b2 --- /dev/null +++ b/server/lib/cache/videos-caption-cache.ts @@ -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 { + + 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 +} diff --git a/server/lib/cache/videos-preview-cache.ts b/server/lib/cache/videos-preview-cache.ts index d09d55e11..1c0e7ed9d 100644 --- a/server/lib/cache/videos-preview-cache.ts +++ b/server/lib/cache/videos-preview-cache.ts @@ -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 { 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((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((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 index 000000000..b6d92d380 --- /dev/null +++ b/server/middlewares/validators/video-captions.ts @@ -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 +} diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts index 59d65d5a4..899def6fc 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos.ts @@ -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 index 000000000..9920dfc7c --- /dev/null +++ b/server/models/video/video-caption.ts @@ -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 { + @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()) + } +} diff --git a/server/models/video/video.ts b/server/models/video/video.ts index ab33b7c99..74a3a5d05 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -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 { }) 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 { } @BeforeDestroy - static async removeFilesAndSendDelete (instance: VideoModel) { + static async removeFiles (instance: VideoModel) { const tasks: Promise[] = [] logger.debug('Removing files of video %s.', instance.url) @@ -615,6 +627,11 @@ export class VideoModel extends Model { ] }, include: [ + { + attributes: [ 'language' ], + model: VideoCaptionModel.unscoped(), + required: false + }, { attributes: [ 'id', 'url' ], model: VideoShareModel.unscoped(), @@ -1028,15 +1045,15 @@ export class VideoModel extends Model { 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 { 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 { 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 { mediaType: 'text/markdown', content: this.getTruncatedDescription(), support: this.support, + subtitleLanguage, icon: { type: 'Image', url: this.getThumbnailUrl(baseUrlHttp), diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index 6aa31e38d..03855237f 100644 --- a/server/tests/api/check-params/config.ts +++ b/server/tests/api/check-params/config.ts @@ -35,6 +35,9 @@ describe('Test config API validators', function () { cache: { previews: { size: 2 + }, + captions: { + size: 3 } }, signup: { diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 4c3b372f5..c0e0302df 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -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 index 000000000..12f890db8 --- /dev/null +++ b/server/tests/api/check-params/video-captions.ts @@ -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() + } + }) +}) diff --git a/server/tests/api/index-fast.ts b/server/tests/api/index-fast.ts index 2454ec2f9..d530dfc06 100644 --- a/server/tests/api/index-fast.ts +++ b/server/tests/api/index-fast.ts @@ -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' diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index 4de0d6b10..79b5aaf2d 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts @@ -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 () { diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts index ce42df0a6..a19b47509 100644 --- a/server/tests/api/server/follows.ts +++ b/server/tests/api/server/follows.ts @@ -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 index 000000000..cbf5268f0 --- /dev/null +++ b/server/tests/api/videos/video-captions.ts @@ -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 index 000000000..04cd23946 --- /dev/null +++ b/server/tests/fixtures/subtitle-good1.vtt @@ -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 index 000000000..4d3256def --- /dev/null +++ b/server/tests/fixtures/subtitle-good2.vtt @@ -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 diff --git a/server/tests/utils/miscs/miscs.ts b/server/tests/utils/miscs/miscs.ts index 7ac60a983..5e46004a7 100644 --- a/server/tests/utils/miscs/miscs.ts +++ b/server/tests/utils/miscs/miscs.ts @@ -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 index 000000000..207e89632 --- /dev/null +++ b/server/tests/utils/videos/video-captions.ts @@ -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 +} diff --git a/shared/models/activitypub/objects/video-torrent-object.ts b/shared/models/activitypub/objects/video-torrent-object.ts index c4071a6d9..90de8967b 100644 --- a/shared/models/activitypub/objects/video-torrent-object.ts +++ b/shared/models/activitypub/objects/video-torrent-object.ts @@ -17,6 +17,7 @@ export interface VideoTorrentObject { category: ActivityIdentifierObject licence: ActivityIdentifierObject language: ActivityIdentifierObject + subtitleLanguage: ActivityIdentifierObject[] views: number sensitive: boolean commentsEnabled: boolean diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index a3a651cd8..9c4718e43 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -25,6 +25,10 @@ export interface CustomConfig { previews: { size: number } + + captions: { + size: number + } } signup: { diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index da0996dae..217d142cd 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -44,6 +44,15 @@ export interface ServerConfig { } } + videoCaption: { + file: { + size: { + max: number + }, + extensions: string[] + } + } + user: { videoQuota: number } diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 9edfb559a..cb9669772 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -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 index 000000000..ff5728715 --- /dev/null +++ b/shared/models/videos/video-caption-update.model.ts @@ -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 index 000000000..4695224ce --- /dev/null +++ b/shared/models/videos/video-caption.model.ts @@ -0,0 +1,6 @@ +import { VideoConstant } from './video-constant.model' + +export interface VideoCaption { + language: VideoConstant + captionPath: string +} diff --git a/shared/models/videos/video-constant.model.ts b/shared/models/videos/video-constant.model.ts new file mode 100644 index 000000000..342a7c0cf --- /dev/null +++ b/shared/models/videos/video-constant.model.ts @@ -0,0 +1,4 @@ +export interface VideoConstant { + id: T + label: string +} diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index 4e1f15ee3..f7bbaac76 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts @@ -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 { - id: T - label: string -} +import { VideoConstant } from './video-constant.model' export interface VideoFile { magnetUri: string diff --git a/support/docker/production/config/production.yaml b/support/docker/production/config/production.yaml index 64fc9e82c..ddac23c4e 100644 --- a/support/docker/production/config/production.yaml +++ b/support/docker/production/config/production.yaml @@ -38,6 +38,7 @@ storage: previews: '../data/previews/' thumbnails: '../data/thumbnails/' torrents: '../data/torrents/' + captions: '../data/captions/' cache: '../data/cache/' log: