</div>
</ng-template>
- <div i18n class="inner-form-title">Cache</div>
+ <div i18n class="inner-form-title">
+ Cache
- <div class="form-group">
- <label i18n for="cachePreviewsSize">Previews cache size</label>
<my-help
helpType="custom" i18n-customHtml
- customHtml="Previews are not federated. We fetch them directly from the origin instance and cache them."
+ customHtml="Some files are not federated (previews, captions). We fetch them directly from the origin instance and cache them."
></my-help>
+ </div>
+ <div class="form-group">
+ <label i18n for="cachePreviewsSize">Previews cache size</label>
<input
type="text" id="cachePreviewsSize"
formControlName="cachePreviewsSize" [ngClass]="{ 'input-error': formErrors['cachePreviewsSize'] }"
</div>
</div>
+ <div class="form-group">
+ <label i18n for="cachePreviewsSize">Video captions cache size</label>
+ <input
+ type="text" id="cacheCaptionsSize"
+ formControlName="cacheCaptionsSize" [ngClass]="{ 'input-error': formErrors['cacheCaptionsSize'] }"
+ >
+ <div *ngIf="formErrors.cacheCaptionsSize" class="form-error">
+ {{ formErrors.cacheCaptionsSize }}
+ </div>
+ </div>
+
<div i18n class="inner-form-title">Customizations</div>
<div class="form-group">
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,
cache: {
previews: {
size: this.form.value['cachePreviewsSize']
+ },
+ captions: {
+ size: this.form.value['cacheCaptionsSize']
}
},
signup: {
extensions: []
}
},
+ videoCaption: {
+ file: {
+ size: { max: 0 },
+ extensions: []
+ }
+ },
user: {
videoQuota: -1
}
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
}
}
+ 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: {
export * from './video-channel-validators.service'
export * from './video-comment-validators.service'
export * from './video-validators.service'
+export * from './video-captions-validators.service'
--- /dev/null
+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.')
+ }
+ }
+ }
+}
export * from './form-validators'
export * from './form-reactive'
+export * from './reactive-file.component'
--- /dev/null
+<div class="root">
+ <div class="button-file">
+ <span>{{ inputLabel }}</span>
+ <input
+ type="file"
+ [name]="inputName" [id]="inputName" [accept]="extensions"
+ (change)="fileChange($event)"
+ />
+ </div>
+
+ <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})</div>
+
+ <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
+</div>
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { NotificationsService } from 'angular2-notifications'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'my-reactive-file',
+ styleUrls: [ './reactive-file.component.scss' ],
+ templateUrl: './reactive-file.component.html',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => ReactiveFileComponent),
+ multi: true
+ }
+ ]
+})
+export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
+ @Input() inputLabel: string
+ @Input() inputName: string
+ @Input() extensions: string[] = []
+ @Input() maxFileSize: number
+ @Input() displayFilename = false
+
+ @Output() fileChanged = new EventEmitter<Blob>()
+
+ allowedExtensionsMessage = ''
+
+ private file: File
+
+ constructor (
+ private notificationsService: NotificationsService,
+ private i18n: I18n
+ ) {}
+
+ get filename () {
+ if (!this.file) return ''
+
+ return this.file.name
+ }
+
+ ngOnInit () {
+ this.allowedExtensionsMessage = this.extensions.join(', ')
+ }
+
+ fileChange (event: any) {
+ if (event.target.files && event.target.files.length) {
+ const [ file ] = event.target.files
+
+ if (file.size > this.maxFileSize) {
+ this.notificationsService.error(this.i18n('Error'), this.i18n('This file is too large.'))
+ return
+ }
+
+ this.file = file
+
+ this.propagateChange(this.file)
+ this.fileChanged.emit(this.file)
+ }
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (file: any) {
+ this.file = file
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+}
}
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 ])
}
})
}
+function removeElementFromArray <T> (arr: T[], elem: T) {
+ const index = arr.indexOf(elem)
+ if (index !== -1) arr.splice(index, 1)
+}
+
export {
objectToUrlEncoded,
getParameterByName,
dateToHuman,
immutableAssign,
objectToFormData,
- lineFeedToHtml
+ lineFeedToHtml,
+ removeElementFromArray
}
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: [
FromNowPipe,
MarkdownTextareaComponent,
InfiniteScrollerDirective,
- HelpComponent
+ HelpComponent,
+ ReactiveFileComponent
],
exports: [
MarkdownTextareaComponent,
InfiniteScrollerDirective,
HelpComponent,
+ ReactiveFileComponent,
NumberFormatterPipe,
ObjectLengthPipe,
AccountService,
MarkdownService,
VideoChannelService,
+ VideoCaptionService,
FormValidatorService,
CustomConfigValidatorsService,
VideoChannelValidatorsService,
VideoCommentValidatorsService,
VideoValidatorsService,
+ VideoCaptionsValidatorsService,
I18nPrimengCalendarService,
ScreenService,
--- /dev/null
+export * from './video-caption.service'
--- /dev/null
+export interface VideoCaptionEdit {
+ language: {
+ id: string
+ label?: string
+ }
+
+ action?: 'CREATE' | 'REMOVE'
+ captionfile?: any
+}
--- /dev/null
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { forkJoin, Observable } from 'rxjs'
+import { ResultList } from '../../../../../shared'
+import { RestExtractor, RestService } from '../rest'
+import { VideoCaption } from '../../../../../shared/models/videos/video-caption.model'
+import { VideoService } from '@app/shared/video/video.service'
+import { objectToFormData } from '@app/shared/misc/utils'
+import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+
+@Injectable()
+export class VideoCaptionService {
+ constructor (
+ private authHttp: HttpClient,
+ private restService: RestService,
+ private restExtractor: RestExtractor
+ ) {}
+
+ listCaptions (videoId: number | string): Observable<ResultList<VideoCaption>> {
+ return this.authHttp.get<ResultList<VideoCaption>>(VideoService.BASE_VIDEO_URL + videoId + '/captions')
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
+ removeCaption (videoId: number | string, language: string) {
+ return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ addCaption (videoId: number | string, language: string, captionfile: File) {
+ const body = { captionfile }
+ const data = objectToFormData(body)
+
+ return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) {
+ const observables: Observable<any>[] = []
+
+ for (const videoCaption of videoCaptions) {
+ if (videoCaption.action === 'CREATE') {
+ observables.push(
+ this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)
+ )
+ } else if (videoCaption.action === 'REMOVE') {
+ observables.push(
+ this.removeCaption(videoId, videoCaption.language.id)
+ )
+ }
+ }
+
+ return forkJoin(observables)
+ }
+}
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'
@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,
--- /dev/null
+<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content" [formGroup]="form">
+
+ <div class="modal-header">
+ <span class="close" aria-hidden="true" (click)="hide()"></span>
+ <h4 i18n class="modal-title">Add caption</h4>
+ </div>
+
+ <div class="modal-body">
+ <label i18n for="language">Language</label>
+ <div class="peertube-select-container">
+ <select id="language" formControlName="language">
+ <option></option>
+ <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option>
+ </select>
+ </div>
+
+ <div *ngIf="formErrors.language" class="form-error">
+ {{ formErrors.language }}
+ </div>
+
+ <div class="caption-file">
+ <my-reactive-file
+ formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file"
+ [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true"
+ ></my-reactive-file>
+ </div>
+
+ <div *ngIf="isReplacingExistingCaption()" class="warning-replace-caption" i18n>
+ This will replace an existing caption!
+ </div>
+
+ <div class="form-group inputs">
+ <span i18n class="action-button action-button-cancel" (click)="hide()">
+ Cancel
+ </span>
+
+ <input
+ type="submit" i18n-value value="Add this caption" class="action-button-submit"
+ [disabled]="!form.valid" (click)="addCaption()"
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+@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
--- /dev/null
+import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { ModalDirective } from 'ngx-bootstrap/modal'
+import { FormReactive } from '@app/shared'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
+import { ServerService } from '@app/core'
+import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+
+@Component({
+ selector: 'my-video-caption-add-modal',
+ styleUrls: [ './video-caption-add-modal.component.scss' ],
+ templateUrl: './video-caption-add-modal.component.html'
+})
+
+export class VideoCaptionAddModalComponent extends FormReactive implements OnInit {
+ @Input() existingCaptions: string[]
+
+ @Output() captionAdded = new EventEmitter<VideoCaptionEdit>()
+
+ @ViewChild('modal') modal: ModalDirective
+
+ videoCaptionLanguages = []
+
+ private closingModal = false
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private serverService: ServerService,
+ private videoCaptionsValidatorsService: VideoCaptionsValidatorsService
+ ) {
+ super()
+ }
+
+ get videoCaptionExtensions () {
+ return this.serverService.getConfig().videoCaption.file.extensions
+ }
+
+ get videoCaptionMaxSize () {
+ return this.serverService.getConfig().videoCaption.file.size.max
+ }
+
+ ngOnInit () {
+ this.videoCaptionLanguages = this.serverService.getVideoLanguages()
+
+ this.buildForm({
+ language: this.videoCaptionsValidatorsService.VIDEO_CAPTION_LANGUAGE,
+ captionfile: this.videoCaptionsValidatorsService.VIDEO_CAPTION_FILE
+ })
+ }
+
+ show () {
+ this.modal.show()
+ }
+
+ hide () {
+ this.modal.hide()
+ }
+
+ isReplacingExistingCaption () {
+ if (this.closingModal === true) return false
+
+ const languageId = this.form.value[ 'language' ]
+
+ return languageId && this.existingCaptions.indexOf(languageId) !== -1
+ }
+
+ async addCaption () {
+ this.closingModal = true
+
+ const languageId = this.form.value[ 'language' ]
+ const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId)
+
+ this.captionAdded.emit({
+ language: languageObject,
+ captionfile: this.form.value['captionfile']
+ })
+
+ this.hide()
+ }
+}
<label i18n for="waitTranscoding">Wait transcoding before publishing the video</label>
<my-help
tooltipPlacement="top" helpType="custom" i18n-customHtml
- customHtml="If you decide to not wait transcoding before publishing the video, it can be unplayable until it transcoding ends."
+ customHtml="If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends."
></my-help>
</div>
</div>
</tab>
+ <tab i18n-heading heading="Captions">
+ <div class="col-md-12 captions">
+
+ <div class="captions-header">
+ <a (click)="openAddCaptionModal()" class="create-caption">
+ <span class="icon icon-add"></span>
+ <ng-container i18n>Add another caption</ng-container>
+ </a>
+ </div>
+
+ <div class="form-group" *ngFor="let videoCaption of videoCaptions">
+
+ <div class="caption-entry">
+ <div class="caption-entry-label">{{ videoCaption.language.label }}</div>
+
+ <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span>
+ </div>
+ </div>
+
+ <div class="no-caption" *ngIf="videoCaptions?.length === 0">
+ No captions for now.
+ </div>
+
+ </div>
+ </tab>
+
<tab i18n-heading heading="Advanced settings">
<div class="col-md-12 advanced-settings">
<div class="form-group">
</tabset>
</div>
+
+<my-video-caption-add-modal
+ #videoCaptionAddModal [existingCaptions]="getExistingCaptions()" (captionAdded)="onCaptionAdded($event)"
+></my-video-caption-add-modal>
\ No newline at end of file
.video-edit {
height: 100%;
+ min-height: 300px;
.form-group {
margin-bottom: 25px;
}
}
+.captions {
+
+ .captions-header {
+ text-align: right;
+
+ .create-caption {
+ @include create-button('../../../../assets/images/global/add.svg');
+ }
+ }
+
+ .caption-entry {
+ display: flex;
+ height: 40px;
+ align-items: center;
+
+ .caption-entry-label {
+ font-size: 15px;
+ font-weight: bold;
+
+ margin-right: 20px;
+ }
+
+ .caption-entry-delete {
+ @include peertube-button;
+ @include grey-button;
+ }
+ }
+
+ .no-caption {
+ text-align: center;
+ font-size: 15px;
+ }
+}
+
.submit-container {
text-align: right;
-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'
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',
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
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,
defaultValues
)
+ this.form.addControl('captions', new FormArray([
+ new FormGroup({
+ language: new FormControl(),
+ captionfile: new FormControl()
+ })
+ ]))
+
this.trackChannelChange()
this.trackPrivacyChange()
}
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 () {
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: [
declarations: [
VideoEditComponent,
- VideoImageComponent
+ VideoImageComponent,
+ VideoCaptionAddModalComponent
],
exports: [
<div class="root">
- <div>
- <div class="button-file">
- <span>{{ inputLabel }}</span>
- <input
- type="file"
- [name]="inputName" [id]="inputName" [accept]="videoImageExtensions"
- (change)="fileChange($event)"
- />
- </div>
- <div i18n class="image-constraints">(extensions: {{ videoImageExtensions }}, max size: {{ maxVideoImageSize | bytes }})</div>
- </div>
+ <my-reactive-file
+ [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
+ (fileChanged)="onFileChanged($event)"
+ ></my-reactive-file>
<img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
<div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
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;
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',
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 */ }
<!-- Hidden because we want to load the component -->
<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
<my-video-edit
- [form]="form" [formErrors]="formErrors"
+ [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
[validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
></my-video-edit>
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',
videoPrivacies = []
firstStepPrivacyId = 0
firstStepChannelId = 0
+ videoCaptions = []
constructor (
protected formValidatorService: FormValidatorService,
private serverService: ServerService,
private videoService: VideoService,
private loadingBar: LoadingBarService,
- private i18n: I18n
+ private i18n: I18n,
+ private videoCaptionService: VideoCaptionService
) {
super()
}
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
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)
+ }
+ )
}
}
<my-video-edit
[form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
[validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
+ [videoCaptions]="videoCaptions"
></my-video-edit>
<div class="submit-container">
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',
videoPrivacies = []
userVideoChannels = []
schedulePublicationPossible = false
+ videoCaptions = []
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
private loadingBar: LoadingBarService,
private videoChannelService: VideoChannelService,
+ private videoCaptionService: VideoCaptionService,
private i18n: I18n
) {
super()
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) {
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)
+ }
+ )
}
previews: 'storage/previews/'
thumbnails: 'storage/thumbnails/'
torrents: 'storage/torrents/'
+ captions: 'storage/captions/'
cache: 'storage/cache/'
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
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:
previews: 'test1/previews/'
thumbnails: 'test1/thumbnails/'
torrents: 'test1/torrents/'
+ captions: 'test1/captions/'
cache: 'test1/cache/'
admin:
previews: 'test2/previews/'
thumbnails: 'test2/thumbnails/'
torrents: 'test2/torrents/'
+ captions: 'test2/captions/'
cache: 'test2/cache/'
admin:
previews: 'test3/previews/'
thumbnails: 'test3/thumbnails/'
torrents: 'test3/torrents/'
+ captions: 'test3/captions/'
cache: 'test3/cache/'
admin:
previews: 'test4/previews/'
thumbnails: 'test4/thumbnails/'
torrents: 'test4/torrents/'
+ captions: 'test4/captions/'
cache: 'test4/cache/'
admin:
previews: 'test5/previews/'
thumbnails: 'test5/thumbnails/'
torrents: 'test5/torrents/'
+ captions: 'test5/captions/'
cache: 'test5/cache/'
admin:
previews: 'test6/previews/'
thumbnails: 'test6/thumbnails/'
torrents: 'test6/torrents/'
+ captions: 'test6/captions/'
cache: 'test6/cache/'
admin:
// 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'
// Caches initializations
VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE)
+ VideosCaptionCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE)
// Enable Schedulers
BadActorFollowScheduler.Instance.enable()
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()
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)
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
}
// 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
cache: {
previews: {
size: CONFIG.CACHE.PREVIEWS.SIZE
+ },
+ captions: {
+ size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
}
},
signup: {
--- /dev/null
+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()
+}
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()
videosRouter.use('/', blacklistRouter)
videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoCommentRouter)
+videosRouter.use('/', videoCaptionsRouter)
videosRouter.get('/categories', listVideoCategories)
videosRouter.get('/licences', listVideoLicences)
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',
torrent: torrents,
thumbnail: [
{
- url: CONFIG.WEBSERVER.URL + video.getThumbnailPath(),
+ url: CONFIG.WEBSERVER.URL + video.getThumbnailStaticPath(),
height: THUMBNAILS_SIZE.height,
width: THUMBNAILS_SIZE.width
}
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
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()
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')
// ---------------------------------------------------------------------------
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 })
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',
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
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 })
}
--- /dev/null
+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
+}
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
export {
isVideoCategoryValid,
+ checkUserCanManageVideo,
isVideoLicenceValid,
isVideoLanguageValid,
isVideoTruncatedDescriptionValid,
VIDEOS_DIR: buildPath(config.get<string>('storage.videos')),
THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
+ CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')),
CACHE_DIR: buildPath(config.get<string>('storage.cache'))
},
CACHE: {
PREVIEWS: {
get SIZE () { return config.get<number>('cache.previews.size') }
+ },
+ VIDEO_CAPTIONS: {
+ get SIZE () { return config.get<number>('cache.captions.size') }
}
},
INSTANCE: {
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
'image/jpeg': '.jpg'
}
+const VIDEO_CAPTIONS_MIMETYPE_EXT = {
+ 'text/vtt': '.vtt'
+}
+
// ---------------------------------------------------------------------------
const SERVER_ACTOR_NAME = 'peertube'
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/',
// 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')
}
}
export {
API_VERSION,
+ VIDEO_CAPTIONS_MIMETYPE_EXT,
AVATARS_SIZE,
ACCEPT_HEADERS,
BCRYPT_SALT_SIZE,
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
VideoChannelModel,
VideoShareModel,
VideoFileModel,
+ VideoCaptionModel,
VideoBlacklistModel,
VideoTagModel,
VideoModel,
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)
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)
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)
}
}
-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 => {
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
federateVideoIfNeeded,
fetchRemoteVideo,
getOrCreateAccountAndVideoAndChannel,
- fetchRemoteVideoPreview,
+ fetchRemoteVideoStaticFile,
fetchRemoteVideoDescription,
generateThumbnailFromUrl,
videoActivityObjectToDBAttributes,
--- /dev/null
+import * as AsyncLRU from 'async-lru'
+import { createWriteStream } from 'fs'
+import { join } from 'path'
+import { unlinkPromise } from '../../helpers/core-utils'
+import { logger } from '../../helpers/logger'
+import { CACHE, CONFIG } from '../../initializers'
+import { VideoModel } from '../../models/video/video'
+import { fetchRemoteVideoStaticFile } from '../activitypub'
+import { VideoCaptionModel } from '../../models/video/video-caption'
+
+export abstract class AbstractVideoStaticFileCache <T> {
+
+ protected lru
+
+ abstract getFilePath (params: T): Promise<string>
+
+ // Load and save the remote file, then return the local path from filesystem
+ protected abstract loadRemoteFile (key: string): Promise<string>
+
+ init (max: number) {
+ this.lru = new AsyncLRU({
+ max,
+ load: (key, cb) => {
+ this.loadRemoteFile(key)
+ .then(res => cb(null, res))
+ .catch(err => cb(err))
+ }
+ })
+
+ this.lru.on('evict', (obj: { key: string, value: string }) => {
+ unlinkPromise(obj.value).then(() => logger.debug('%s evicted from %s', obj.value, this.constructor.name))
+ })
+ }
+
+ protected loadFromLRU (key: string) {
+ return new Promise<string>((res, rej) => {
+ this.lru.get(key, (err, value) => {
+ err ? rej(err) : res(value)
+ })
+ })
+ }
+
+ protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {
+ return new Promise<string>((res, rej) => {
+ const req = fetchRemoteVideoStaticFile(video, remoteStaticPath, rej)
+
+ const stream = createWriteStream(destPath)
+
+ req.pipe(stream)
+ .on('error', (err) => rej(err))
+ .on('finish', () => res(destPath))
+ })
+ }
+}
--- /dev/null
+import { join } from 'path'
+import { CACHE, CONFIG } from '../../initializers'
+import { VideoModel } from '../../models/video/video'
+import { VideoCaptionModel } from '../../models/video/video-caption'
+import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
+
+type GetPathParam = { videoId: string, language: string }
+
+class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
+
+ private static readonly KEY_DELIMITER = '%'
+ private static instance: VideosCaptionCache
+
+ private constructor () {
+ super()
+ }
+
+ static get Instance () {
+ return this.instance || (this.instance = new this())
+ }
+
+ async getFilePath (params: GetPathParam) {
+ const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
+ if (!videoCaption) return undefined
+
+ if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
+
+ const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language
+ return this.loadFromLRU(key)
+ }
+
+ protected async loadRemoteFile (key: string) {
+ const [ videoId, language ] = key.split(VideosCaptionCache.KEY_DELIMITER)
+
+ const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(videoId, language)
+ if (!videoCaption) return undefined
+
+ if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
+
+ // Used to fetch the path
+ const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
+ if (!video) return undefined
+
+ const remoteStaticPath = videoCaption.getCaptionStaticPath()
+ const destPath = join(CACHE.DIRECTORIES.VIDEO_CAPTIONS, videoCaption.getCaptionName())
+
+ return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
+ }
+}
+
+export {
+ VideosCaptionCache
+}
-import * as asyncLRU from 'async-lru'
-import { createWriteStream } from 'fs'
import { join } from 'path'
-import { unlinkPromise } from '../../helpers/core-utils'
-import { logger } from '../../helpers/logger'
-import { CACHE, CONFIG } from '../../initializers'
+import { CACHE, CONFIG, STATIC_PATHS } from '../../initializers'
import { VideoModel } from '../../models/video/video'
-import { fetchRemoteVideoPreview } from '../activitypub'
+import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
-class VideosPreviewCache {
+class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
private static instance: VideosPreviewCache
- private lru
-
- private constructor () { }
+ private constructor () {
+ super()
+ }
static get Instance () {
return this.instance || (this.instance = new this())
}
- init (max: number) {
- this.lru = new asyncLRU({
- max,
- load: (key, cb) => {
- this.loadPreviews(key)
- .then(res => cb(null, res))
- .catch(err => cb(err))
- }
- })
-
- this.lru.on('evict', (obj: { key: string, value: string }) => {
- unlinkPromise(obj.value).then(() => logger.debug('%s evicted from VideosPreviewCache', obj.value))
- })
- }
-
- async getPreviewPath (key: string) {
- const video = await VideoModel.loadByUUID(key)
+ async getFilePath (videoUUID: string) {
+ const video = await VideoModel.loadByUUID(videoUUID)
if (!video) return undefined
if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
- return new Promise<string>((res, rej) => {
- this.lru.get(key, (err, value) => {
- err ? rej(err) : res(value)
- })
- })
+ return this.loadFromLRU(videoUUID)
}
- private async loadPreviews (key: string) {
+ protected async loadRemoteFile (key: string) {
const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
if (!video) return undefined
- if (video.isOwned()) throw new Error('Cannot load preview of owned video.')
-
- return this.saveRemotePreviewAndReturnPath(video)
- }
+ if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
- private saveRemotePreviewAndReturnPath (video: VideoModel) {
- return new Promise<string>((res, rej) => {
- const req = fetchRemoteVideoPreview(video, rej)
- const path = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
- const stream = createWriteStream(path)
+ const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
+ const destPath = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
- req.pipe(stream)
- .on('error', (err) => rej(err))
- .on('finish', () => res(path))
- })
+ return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
}
}
--- /dev/null
+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
+}
toValueOrNull
} from '../../helpers/custom-validators/misc'
import {
+ checkUserCanManageVideo,
isScheduleVideoUpdatePrivacyValid,
isVideoAbuseReasonValid,
isVideoCategoryValid,
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'
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'),
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')
// ---------------------------------------------------------------------------
-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
--- /dev/null
+import * as Sequelize from 'sequelize'
+import {
+ AllowNull,
+ BeforeDestroy,
+ BelongsTo,
+ Column,
+ CreatedAt,
+ ForeignKey,
+ Is,
+ Model,
+ Scopes,
+ Table,
+ UpdatedAt
+} from 'sequelize-typescript'
+import { throwIfNotValid } from '../utils'
+import { VideoModel } from './video'
+import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
+import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
+import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers'
+import { join } from 'path'
+import { logger } from '../../helpers/logger'
+import { unlinkPromise } from '../../helpers/core-utils'
+
+export enum ScopeNames {
+ WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
+}
+
+@Scopes({
+ [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
+ include: [
+ {
+ attributes: [ 'uuid', 'remote' ],
+ model: () => VideoModel.unscoped(),
+ required: true
+ }
+ ]
+ }
+})
+
+@Table({
+ tableName: 'videoCaption',
+ indexes: [
+ {
+ fields: [ 'videoId' ]
+ },
+ {
+ fields: [ 'videoId', 'language' ],
+ unique: true
+ }
+ ]
+})
+export class VideoCaptionModel extends Model<VideoCaptionModel> {
+ @CreatedAt
+ createdAt: Date
+
+ @UpdatedAt
+ updatedAt: Date
+
+ @AllowNull(false)
+ @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
+ @Column
+ language: string
+
+ @ForeignKey(() => VideoModel)
+ @Column
+ videoId: number
+
+ @BelongsTo(() => VideoModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'CASCADE'
+ })
+ Video: VideoModel
+
+ @BeforeDestroy
+ static async removeFiles (instance: VideoCaptionModel) {
+
+ if (instance.isOwned()) {
+ if (!instance.Video) {
+ instance.Video = await instance.$get('Video') as VideoModel
+ }
+
+ logger.debug('Removing captions %s of video %s.', instance.Video.uuid, instance.language)
+ return instance.removeCaptionFile()
+ }
+
+ return undefined
+ }
+
+ static loadByVideoIdAndLanguage (videoId: string | number, language: string) {
+ const videoInclude = {
+ model: VideoModel.unscoped(),
+ attributes: [ 'id', 'remote', 'uuid' ],
+ where: { }
+ }
+
+ if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId
+ else videoInclude.where['id'] = videoId
+
+ const query = {
+ where: {
+ language
+ },
+ include: [
+ videoInclude
+ ]
+ }
+
+ return VideoCaptionModel.findOne(query)
+ }
+
+ static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) {
+ const values = {
+ videoId,
+ language
+ }
+
+ return VideoCaptionModel.upsert(values, { transaction })
+ }
+
+ static listVideoCaptions (videoId: number) {
+ const query = {
+ order: [ [ 'language', 'ASC' ] ],
+ where: {
+ videoId
+ }
+ }
+
+ return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
+ }
+
+ static getLanguageLabel (language: string) {
+ return VIDEO_LANGUAGES[language] || 'Unknown'
+ }
+
+ static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) {
+ const query = {
+ where: {
+ videoId
+ },
+ transaction
+ }
+
+ return VideoCaptionModel.destroy(query)
+ }
+
+ isOwned () {
+ return this.Video.remote === false
+ }
+
+ toFormattedJSON (): VideoCaption {
+ return {
+ language: {
+ id: this.language,
+ label: VideoCaptionModel.getLanguageLabel(this.language)
+ },
+ captionPath: this.getCaptionStaticPath()
+ }
+ }
+
+ getCaptionStaticPath () {
+ return join(STATIC_PATHS.VIDEO_CAPTIONS, this.getCaptionName())
+ }
+
+ getCaptionName () {
+ return `${this.Video.uuid}-${this.language}.vtt`
+ }
+
+ removeCaptionFile () {
+ return unlinkPromise(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName())
+ }
+}
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',
})
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()) {
}
@BeforeDestroy
- static async removeFilesAndSendDelete (instance: VideoModel) {
+ static async removeFiles (instance: VideoModel) {
const tasks: Promise<any>[] = []
logger.debug('Removing files of video %s.', instance.url)
]
},
include: [
+ {
+ attributes: [ 'language' ],
+ model: VideoCaptionModel.unscoped(),
+ required: false
+ },
{
attributes: [ 'id', 'url' ],
model: VideoShareModel.unscoped(),
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())
}
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,
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,
mediaType: 'text/markdown',
content: this.getTruncatedDescription(),
support: this.support,
+ subtitleLanguage,
icon: {
type: 'Image',
url: this.getThumbnailUrl(baseUrlHttp),
cache: {
previews: {
size: 2
+ },
+ captions: {
+ size: 3
}
},
signup: {
import './users'
import './video-abuses'
import './video-blacklist'
+import './video-captions'
import './video-channels'
import './video-comments'
import './videos'
--- /dev/null
+/* 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()
+ }
+ })
+})
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'
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
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',
cache: {
previews: {
size: 2
+ },
+ captions: {
+ size: 3
}
},
signup: {
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 () {
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 () {
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 () {
} 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
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)
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)
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)
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)
--- /dev/null
+/* 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)
+ })
+})
--- /dev/null
+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
--- /dev/null
+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
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()
--- /dev/null
+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
+}
category: ActivityIdentifierObject
licence: ActivityIdentifierObject
language: ActivityIdentifierObject
+ subtitleLanguage: ActivityIdentifierObject[]
views: number
sensitive: boolean
commentsEnabled: boolean
previews: {
size: number
}
+
+ captions: {
+ size: number
+ }
}
signup: {
}
}
+ videoCaption: {
+ file: {
+ size: {
+ max: number
+ },
+ extensions: string[]
+ }
+ }
+
user: {
videoQuota: number
}
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'
--- /dev/null
+export interface VideoCaptionUpdate {
+ language: string
+ captionfile: Blob
+}
--- /dev/null
+import { VideoConstant } from './video-constant.model'
+
+export interface VideoCaption {
+ language: VideoConstant<string>
+ captionPath: string
+}
--- /dev/null
+export interface VideoConstant<T> {
+ id: T
+ label: string
+}
import { VideoChannel } from './video-channel.model'
import { VideoPrivacy } from './video-privacy.enum'
import { VideoScheduleUpdate } from './video-schedule-update.model'
-
-export interface VideoConstant <T> {
- id: T
- label: string
-}
+import { VideoConstant } from './video-constant.model'
export interface VideoFile {
magnetUri: string
previews: '../data/previews/'
thumbnails: '../data/thumbnails/'
torrents: '../data/torrents/'
+ captions: '../data/captions/'
cache: '../data/cache/'
log: