1
0
Fork 0

Implement captions/subtitles

This commit is contained in:
Chocobozzz 2018-07-12 19:02:00 +02:00
parent d4557fd3ec
commit 40e87e9ecc
83 changed files with 1867 additions and 298 deletions

View File

@ -206,15 +206,17 @@ Check this checkbox, save the configuration and test with a video URL of your in
</div> </div>
</ng-template> </ng-template>
<div i18n class="inner-form-title">Cache</div> <div i18n class="inner-form-title">
Cache
<my-help
helpType="custom" i18n-customHtml
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"> <div class="form-group">
<label i18n for="cachePreviewsSize">Previews cache size</label> <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."
></my-help>
<input <input
type="text" id="cachePreviewsSize" type="text" id="cachePreviewsSize"
formControlName="cachePreviewsSize" [ngClass]="{ 'input-error': formErrors['cachePreviewsSize'] }" formControlName="cachePreviewsSize" [ngClass]="{ 'input-error': formErrors['cachePreviewsSize'] }"
@ -224,6 +226,17 @@ Check this checkbox, save the configuration and test with a video URL of your in
</div> </div>
</div> </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 i18n class="inner-form-title">Customizations</div>
<div class="form-group"> <div class="form-group">

View File

@ -67,6 +67,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
servicesTwitterUsername: this.customConfigValidatorsService.SERVICES_TWITTER_USERNAME, servicesTwitterUsername: this.customConfigValidatorsService.SERVICES_TWITTER_USERNAME,
servicesTwitterWhitelisted: null, servicesTwitterWhitelisted: null,
cachePreviewsSize: this.customConfigValidatorsService.CACHE_PREVIEWS_SIZE, cachePreviewsSize: this.customConfigValidatorsService.CACHE_PREVIEWS_SIZE,
cacheCaptionsSize: this.customConfigValidatorsService.CACHE_CAPTIONS_SIZE,
signupEnabled: null, signupEnabled: null,
signupLimit: this.customConfigValidatorsService.SIGNUP_LIMIT, signupLimit: this.customConfigValidatorsService.SIGNUP_LIMIT,
adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL, adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL,
@ -156,6 +157,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
cache: { cache: {
previews: { previews: {
size: this.form.value['cachePreviewsSize'] size: this.form.value['cachePreviewsSize']
},
captions: {
size: this.form.value['cacheCaptionsSize']
} }
}, },
signup: { signup: {

View File

@ -59,6 +59,12 @@ export class ServerService {
extensions: [] extensions: []
} }
}, },
videoCaption: {
file: {
size: { max: 0 },
extensions: []
}
},
user: { user: {
videoQuota: -1 videoQuota: -1
} }

View File

@ -9,6 +9,7 @@ export class CustomConfigValidatorsService {
readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator
readonly SERVICES_TWITTER_USERNAME: BuildFormValidator readonly SERVICES_TWITTER_USERNAME: BuildFormValidator
readonly CACHE_PREVIEWS_SIZE: BuildFormValidator readonly CACHE_PREVIEWS_SIZE: BuildFormValidator
readonly CACHE_CAPTIONS_SIZE: BuildFormValidator
readonly SIGNUP_LIMIT: BuildFormValidator readonly SIGNUP_LIMIT: BuildFormValidator
readonly ADMIN_EMAIL: BuildFormValidator readonly ADMIN_EMAIL: BuildFormValidator
readonly TRANSCODING_THREADS: BuildFormValidator readonly TRANSCODING_THREADS: BuildFormValidator
@ -44,6 +45,15 @@ export class CustomConfigValidatorsService {
} }
} }
this.CACHE_CAPTIONS_SIZE = {
VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
MESSAGES: {
'required': this.i18n('Captions cache size is required.'),
'min': this.i18n('Captions cache size must be greater than 1.'),
'pattern': this.i18n('Captions cache size must be a number.')
}
}
this.SIGNUP_LIMIT = { this.SIGNUP_LIMIT = {
VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
MESSAGES: { MESSAGES: {

View File

@ -8,3 +8,4 @@ export * from './video-abuse-validators.service'
export * from './video-channel-validators.service' export * from './video-channel-validators.service'
export * from './video-comment-validators.service' export * from './video-comment-validators.service'
export * from './video-validators.service' export * from './video-validators.service'
export * from './video-captions-validators.service'

View File

@ -0,0 +1,27 @@
import { I18n } from '@ngx-translate/i18n-polyfill'
import { Validators } from '@angular/forms'
import { Injectable } from '@angular/core'
import { BuildFormValidator } from '@app/shared'
@Injectable()
export class VideoCaptionsValidatorsService {
readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator
readonly VIDEO_CAPTION_FILE: BuildFormValidator
constructor (private i18n: I18n) {
this.VIDEO_CAPTION_LANGUAGE = {
VALIDATORS: [ Validators.required ],
MESSAGES: {
'required': this.i18n('Video caption language is required.')
}
}
this.VIDEO_CAPTION_FILE = {
VALIDATORS: [ Validators.required ],
MESSAGES: {
'required': this.i18n('Video caption file is required.')
}
}
}
}

View File

@ -1,2 +1,3 @@
export * from './form-validators' export * from './form-validators'
export * from './form-reactive' export * from './form-reactive'
export * from './reactive-file.component'

View File

@ -0,0 +1,14 @@
<div class="root">
<div class="button-file">
<span>{{ inputLabel }}</span>
<input
type="file"
[name]="inputName" [id]="inputName" [accept]="extensions"
(change)="fileChange($event)"
/>
</div>
<div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})</div>
<div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
</div>

View File

@ -0,0 +1,24 @@
@import '_variables';
@import '_mixins';
.root {
height: auto;
display: flex;
align-items: center;
.button-file {
@include peertube-button-file(auto);
min-width: 190px;
}
.file-constraints {
margin-left: 5px;
font-size: 13px;
}
.filename {
font-weight: $font-semibold;
margin-left: 5px;
}
}

View File

@ -0,0 +1,75 @@
import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { NotificationsService } from 'angular2-notifications'
import { I18n } from '@ngx-translate/i18n-polyfill'
@Component({
selector: 'my-reactive-file',
styleUrls: [ './reactive-file.component.scss' ],
templateUrl: './reactive-file.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ReactiveFileComponent),
multi: true
}
]
})
export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
@Input() inputLabel: string
@Input() inputName: string
@Input() extensions: string[] = []
@Input() maxFileSize: number
@Input() displayFilename = false
@Output() fileChanged = new EventEmitter<Blob>()
allowedExtensionsMessage = ''
private file: File
constructor (
private notificationsService: NotificationsService,
private i18n: I18n
) {}
get filename () {
if (!this.file) return ''
return this.file.name
}
ngOnInit () {
this.allowedExtensionsMessage = this.extensions.join(', ')
}
fileChange (event: any) {
if (event.target.files && event.target.files.length) {
const [ file ] = event.target.files
if (file.size > this.maxFileSize) {
this.notificationsService.error(this.i18n('Error'), this.i18n('This file is too large.'))
return
}
this.file = file
this.propagateChange(this.file)
this.fileChanged.emit(this.file)
}
}
propagateChange = (_: any) => { /* empty */ }
writeValue (file: any) {
this.file = file
}
registerOnChange (fn: (_: any) => void) {
this.propagateChange = fn
}
registerOnTouched () {
// Unused
}
}

View File

@ -81,7 +81,7 @@ function objectToFormData (obj: any, form?: FormData, namespace?: string) {
} }
if (obj[key] !== null && typeof obj[ key ] === 'object' && !(obj[ key ] instanceof File)) { if (obj[key] !== null && typeof obj[ key ] === 'object' && !(obj[ key ] instanceof File)) {
objectToFormData(obj[ key ], fd, key) objectToFormData(obj[ key ], fd, formKey)
} else { } else {
fd.append(formKey, obj[ key ]) fd.append(formKey, obj[ key ])
} }
@ -96,6 +96,11 @@ function lineFeedToHtml (obj: object, keyToNormalize: string) {
}) })
} }
function removeElementFromArray <T> (arr: T[], elem: T) {
const index = arr.indexOf(elem)
if (index !== -1) arr.splice(index, 1)
}
export { export {
objectToUrlEncoded, objectToUrlEncoded,
getParameterByName, getParameterByName,
@ -104,5 +109,6 @@ export {
dateToHuman, dateToHuman,
immutableAssign, immutableAssign,
objectToFormData, objectToFormData,
lineFeedToHtml lineFeedToHtml,
removeElementFromArray
} }

View File

@ -37,12 +37,14 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { import {
CustomConfigValidatorsService, CustomConfigValidatorsService,
LoginValidatorsService, LoginValidatorsService, ReactiveFileComponent,
ResetPasswordValidatorsService, ResetPasswordValidatorsService,
UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService
} from '@app/shared/forms' } from '@app/shared/forms'
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
import { ScreenService } from '@app/shared/misc/screen.service' 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({ @NgModule({
imports: [ imports: [
@ -74,7 +76,8 @@ import { ScreenService } from '@app/shared/misc/screen.service'
FromNowPipe, FromNowPipe,
MarkdownTextareaComponent, MarkdownTextareaComponent,
InfiniteScrollerDirective, InfiniteScrollerDirective,
HelpComponent HelpComponent,
ReactiveFileComponent
], ],
exports: [ exports: [
@ -102,6 +105,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
MarkdownTextareaComponent, MarkdownTextareaComponent,
InfiniteScrollerDirective, InfiniteScrollerDirective,
HelpComponent, HelpComponent,
ReactiveFileComponent,
NumberFormatterPipe, NumberFormatterPipe,
ObjectLengthPipe, ObjectLengthPipe,
@ -119,6 +123,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
AccountService, AccountService,
MarkdownService, MarkdownService,
VideoChannelService, VideoChannelService,
VideoCaptionService,
FormValidatorService, FormValidatorService,
CustomConfigValidatorsService, CustomConfigValidatorsService,
@ -129,6 +134,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
VideoChannelValidatorsService, VideoChannelValidatorsService,
VideoCommentValidatorsService, VideoCommentValidatorsService,
VideoValidatorsService, VideoValidatorsService,
VideoCaptionsValidatorsService,
I18nPrimengCalendarService, I18nPrimengCalendarService,
ScreenService, ScreenService,

View File

@ -0,0 +1 @@
export * from './video-caption.service'

View File

@ -0,0 +1,9 @@
export interface VideoCaptionEdit {
language: {
id: string
label?: string
}
action?: 'CREATE' | 'REMOVE'
captionfile?: any
}

View File

@ -0,0 +1,61 @@
import { catchError, map } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { forkJoin, Observable } from 'rxjs'
import { ResultList } from '../../../../../shared'
import { RestExtractor, RestService } from '../rest'
import { VideoCaption } from '../../../../../shared/models/videos/video-caption.model'
import { VideoService } from '@app/shared/video/video.service'
import { objectToFormData } from '@app/shared/misc/utils'
import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
@Injectable()
export class VideoCaptionService {
constructor (
private authHttp: HttpClient,
private restService: RestService,
private restExtractor: RestExtractor
) {}
listCaptions (videoId: number | string): Observable<ResultList<VideoCaption>> {
return this.authHttp.get<ResultList<VideoCaption>>(VideoService.BASE_VIDEO_URL + videoId + '/captions')
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
removeCaption (videoId: number | string, language: string) {
return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
addCaption (videoId: number | string, language: string, captionfile: File) {
const body = { captionfile }
const data = objectToFormData(body)
return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) {
const observables: Observable<any>[] = []
for (const videoCaption of videoCaptions) {
if (videoCaption.action === 'CREATE') {
observables.push(
this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)
)
} else if (videoCaption.action === 'REMOVE') {
observables.push(
this.removeCaption(videoId, videoCaption.language.id)
)
}
}
return forkJoin(observables)
}
}

View File

@ -1,7 +1,7 @@
import { User } from '../' import { User } from '../'
import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 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 { getAbsoluteAPIUrl } from '../misc/utils'
import { ServerConfig } from '../../../../../shared/models' import { ServerConfig } from '../../../../../shared/models'
import { Actor } from '@app/shared/actor/actor.model' import { Actor } from '@app/shared/actor/actor.model'

View File

@ -28,8 +28,8 @@ import { ServerService } from '@app/core'
@Injectable() @Injectable()
export class VideoService { export class VideoService {
private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
constructor ( constructor (
private authHttp: HttpClient, private authHttp: HttpClient,

View File

@ -0,0 +1,47 @@
<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" [formGroup]="form">
<div class="modal-header">
<span class="close" aria-hidden="true" (click)="hide()"></span>
<h4 i18n class="modal-title">Add caption</h4>
</div>
<div class="modal-body">
<label i18n for="language">Language</label>
<div class="peertube-select-container">
<select id="language" formControlName="language">
<option></option>
<option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option>
</select>
</div>
<div *ngIf="formErrors.language" class="form-error">
{{ formErrors.language }}
</div>
<div class="caption-file">
<my-reactive-file
formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file"
[extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true"
></my-reactive-file>
</div>
<div *ngIf="isReplacingExistingCaption()" class="warning-replace-caption" i18n>
This will replace an existing caption!
</div>
<div class="form-group inputs">
<span i18n class="action-button action-button-cancel" (click)="hide()">
Cancel
</span>
<input
type="submit" i18n-value value="Add this caption" class="action-button-submit"
[disabled]="!form.valid" (click)="addCaption()"
>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
@import '_variables';
@import '_mixins';
.peertube-select-container {
@include peertube-select-container(auto);
}
.caption-file {
margin-top: 20px;
}
.warning-replace-caption {
color: red;
margin-top: 10px;
}

View File

@ -0,0 +1,80 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { ModalDirective } from 'ngx-bootstrap/modal'
import { FormReactive } from '@app/shared'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
import { ServerService } from '@app/core'
import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
@Component({
selector: 'my-video-caption-add-modal',
styleUrls: [ './video-caption-add-modal.component.scss' ],
templateUrl: './video-caption-add-modal.component.html'
})
export class VideoCaptionAddModalComponent extends FormReactive implements OnInit {
@Input() existingCaptions: string[]
@Output() captionAdded = new EventEmitter<VideoCaptionEdit>()
@ViewChild('modal') modal: ModalDirective
videoCaptionLanguages = []
private closingModal = false
constructor (
protected formValidatorService: FormValidatorService,
private serverService: ServerService,
private videoCaptionsValidatorsService: VideoCaptionsValidatorsService
) {
super()
}
get videoCaptionExtensions () {
return this.serverService.getConfig().videoCaption.file.extensions
}
get videoCaptionMaxSize () {
return this.serverService.getConfig().videoCaption.file.size.max
}
ngOnInit () {
this.videoCaptionLanguages = this.serverService.getVideoLanguages()
this.buildForm({
language: this.videoCaptionsValidatorsService.VIDEO_CAPTION_LANGUAGE,
captionfile: this.videoCaptionsValidatorsService.VIDEO_CAPTION_FILE
})
}
show () {
this.modal.show()
}
hide () {
this.modal.hide()
}
isReplacingExistingCaption () {
if (this.closingModal === true) return false
const languageId = this.form.value[ 'language' ]
return languageId && this.existingCaptions.indexOf(languageId) !== -1
}
async addCaption () {
this.closingModal = true
const languageId = this.form.value[ 'language' ]
const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId)
this.captionAdded.emit({
language: languageObject,
captionfile: this.form.value['captionfile']
})
this.hide()
}
}

View File

@ -132,13 +132,39 @@
<label i18n for="waitTranscoding">Wait transcoding before publishing the video</label> <label i18n for="waitTranscoding">Wait transcoding before publishing the video</label>
<my-help <my-help
tooltipPlacement="top" helpType="custom" i18n-customHtml 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> ></my-help>
</div> </div>
</div> </div>
</tab> </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"> <tab i18n-heading heading="Advanced settings">
<div class="col-md-12 advanced-settings"> <div class="col-md-12 advanced-settings">
<div class="form-group"> <div class="form-group">
@ -172,3 +198,7 @@
</tabset> </tabset>
</div> </div>
<my-video-caption-add-modal
#videoCaptionAddModal [existingCaptions]="getExistingCaptions()" (captionAdded)="onCaptionAdded($event)"
></my-video-caption-add-modal>

View File

@ -7,6 +7,7 @@
.video-edit { .video-edit {
height: 100%; height: 100%;
min-height: 300px;
.form-group { .form-group {
margin-bottom: 25px; margin-bottom: 25px;
@ -49,6 +50,40 @@
} }
} }
.captions {
.captions-header {
text-align: right;
.create-caption {
@include create-button('../../../../assets/images/global/add.svg');
}
}
.caption-entry {
display: flex;
height: 40px;
align-items: center;
.caption-entry-label {
font-size: 15px;
font-weight: bold;
margin-right: 20px;
}
.caption-entry-delete {
@include peertube-button;
@include grey-button;
}
}
.no-caption {
text-align: center;
font-size: 15px;
}
}
.submit-container { .submit-container {
text-align: right; text-align: right;

View File

@ -1,5 +1,5 @@
import { Component, Input, OnInit } from '@angular/core' import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { FormGroup, ValidatorFn, Validators } from '@angular/forms' import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared' import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared'
import { NotificationsService } from 'angular2-notifications' import { NotificationsService } from 'angular2-notifications'
@ -8,6 +8,10 @@ import { VideoEdit } from '../../../shared/video/video-edit.model'
import { map } from 'rxjs/operators' import { map } from 'rxjs/operators'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 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({ @Component({
selector: 'my-video-edit', selector: 'my-video-edit',
@ -15,13 +19,16 @@ import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calend
templateUrl: './video-edit.component.html' templateUrl: './video-edit.component.html'
}) })
export class VideoEditComponent implements OnInit { export class VideoEditComponent implements OnInit, OnDestroy {
@Input() form: FormGroup @Input() form: FormGroup
@Input() formErrors: { [ id: string ]: string } = {} @Input() formErrors: { [ id: string ]: string } = {}
@Input() validationMessages: FormReactiveValidationMessages = {} @Input() validationMessages: FormReactiveValidationMessages = {}
@Input() videoPrivacies = [] @Input() videoPrivacies = []
@Input() userVideoChannels: { id: number, label: string, support: string }[] = [] @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
@Input() schedulePublicationPossible = true @Input() schedulePublicationPossible = true
@Input() videoCaptions: VideoCaptionEdit[] = []
@ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent
// So that it can be accessed in the template // So that it can be accessed in the template
readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
@ -41,9 +48,12 @@ export class VideoEditComponent implements OnInit {
calendarTimezone: string calendarTimezone: string
calendarDateFormat: string calendarDateFormat: string
private schedulerInterval
constructor ( constructor (
private formValidatorService: FormValidatorService, private formValidatorService: FormValidatorService,
private videoValidatorsService: VideoValidatorsService, private videoValidatorsService: VideoValidatorsService,
private videoCaptionService: VideoCaptionService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
@ -91,6 +101,13 @@ export class VideoEditComponent implements OnInit {
defaultValues defaultValues
) )
this.form.addControl('captions', new FormArray([
new FormGroup({
language: new FormControl(),
captionfile: new FormControl()
})
]))
this.trackChannelChange() this.trackChannelChange()
this.trackPrivacyChange() this.trackPrivacyChange()
} }
@ -102,7 +119,35 @@ export class VideoEditComponent implements OnInit {
this.videoLicences = this.serverService.getVideoLicences() this.videoLicences = this.serverService.getVideoLicences()
this.videoLanguages = this.serverService.getVideoLanguages() 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 () { private trackPrivacyChange () {

View File

@ -5,6 +5,7 @@ import { SharedModule } from '../../../shared/'
import { VideoEditComponent } from './video-edit.component' import { VideoEditComponent } from './video-edit.component'
import { VideoImageComponent } from './video-image.component' import { VideoImageComponent } from './video-image.component'
import { CalendarModule } from 'primeng/components/calendar/calendar' import { CalendarModule } from 'primeng/components/calendar/calendar'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
@NgModule({ @NgModule({
imports: [ imports: [
@ -16,7 +17,8 @@ import { CalendarModule } from 'primeng/components/calendar/calendar'
declarations: [ declarations: [
VideoEditComponent, VideoEditComponent,
VideoImageComponent VideoImageComponent,
VideoCaptionAddModalComponent
], ],
exports: [ exports: [

View File

@ -1,15 +1,8 @@
<div class="root"> <div class="root">
<div> <my-reactive-file
<div class="button-file"> [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
<span>{{ inputLabel }}</span> (fileChanged)="onFileChanged($event)"
<input ></my-reactive-file>
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>
<img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" /> <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> <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>

View File

@ -6,16 +6,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
.button-file {
@include peertube-button-file(auto);
min-width: 190px;
}
.image-constraints {
font-size: 13px;
}
.preview { .preview {
border: 2px solid grey; border: 2px solid grey;
border-radius: 4px; border-radius: 4px;

View File

@ -2,8 +2,6 @@ import { Component, forwardRef, Input } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { NotificationsService } from 'angular2-notifications'
import { I18n } from '@ngx-translate/i18n-polyfill'
@Component({ @Component({
selector: 'my-video-image', selector: 'my-video-image',
@ -25,37 +23,27 @@ export class VideoImageComponent implements ControlValueAccessor {
imageSrc: SafeResourceUrl imageSrc: SafeResourceUrl
private file: Blob private file: File
constructor ( constructor (
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
private serverService: ServerService, private serverService: ServerService
private notificationsService: NotificationsService,
private i18n: I18n
) {} ) {}
get videoImageExtensions () { get videoImageExtensions () {
return this.serverService.getConfig().video.image.extensions.join(',') return this.serverService.getConfig().video.image.extensions
} }
get maxVideoImageSize () { get maxVideoImageSize () {
return this.serverService.getConfig().video.image.size.max return this.serverService.getConfig().video.image.size.max
} }
fileChange (event: any) { onFileChanged (file: File) {
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
}
this.file = file this.file = file
this.propagateChange(this.file) this.propagateChange(this.file)
this.updatePreview() this.updatePreview()
} }
}
propagateChange = (_: any) => { /* empty */ } propagateChange = (_: any) => { /* empty */ }

View File

@ -46,7 +46,7 @@
<!-- Hidden because we want to load the component --> <!-- Hidden because we want to load the component -->
<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form"> <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
<my-video-edit <my-video-edit
[form]="form" [formErrors]="formErrors" [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
[validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
></my-video-edit> ></my-video-edit>

View File

@ -15,6 +15,8 @@ import { VideoEdit } from '../../shared/video/video-edit.model'
import { VideoService } from '../../shared/video/video.service' import { VideoService } from '../../shared/video/video.service'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { switchMap } from 'rxjs/operators'
import { VideoCaptionService } from '@app/shared/video-caption'
@Component({ @Component({
selector: 'my-videos-add', selector: 'my-videos-add',
@ -46,6 +48,7 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
videoPrivacies = [] videoPrivacies = []
firstStepPrivacyId = 0 firstStepPrivacyId = 0
firstStepChannelId = 0 firstStepChannelId = 0
videoCaptions = []
constructor ( constructor (
protected formValidatorService: FormValidatorService, protected formValidatorService: FormValidatorService,
@ -56,7 +59,8 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
private serverService: ServerService, private serverService: ServerService,
private videoService: VideoService, private videoService: VideoService,
private loadingBar: LoadingBarService, private loadingBar: LoadingBarService,
private i18n: I18n private i18n: I18n,
private videoCaptionService: VideoCaptionService
) { ) {
super() super()
} }
@ -159,11 +163,8 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
let name: string let name: string
// If the name of the file is very small, keep the extension // If the name of the file is very small, keep the extension
if (nameWithoutExtension.length < 3) { if (nameWithoutExtension.length < 3) name = videofile.name
name = videofile.name else name = nameWithoutExtension
} else {
name = nameWithoutExtension
}
const privacy = this.firstStepPrivacyId.toString() const privacy = this.firstStepPrivacyId.toString()
const nsfw = false const nsfw = false
@ -225,6 +226,10 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
this.isUpdatingVideo = true this.isUpdatingVideo = true
this.loadingBar.start() this.loadingBar.start()
this.videoService.updateVideo(video) this.videoService.updateVideo(video)
.pipe(
// Then update captions
switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions))
)
.subscribe( .subscribe(
() => { () => {
this.isUpdatingVideo = false this.isUpdatingVideo = false
@ -241,6 +246,5 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
console.error(err) console.error(err)
} }
) )
} }
} }

View File

@ -8,6 +8,7 @@
<my-video-edit <my-video-edit
[form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible" [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
[validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
[videoCaptions]="videoCaptions"
></my-video-edit> ></my-video-edit>
<div class="submit-container"> <div class="submit-container">

View File

@ -12,6 +12,7 @@ import { VideoService } from '../../shared/video/video.service'
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { VideoCaptionService } from '@app/shared/video-caption'
@Component({ @Component({
selector: 'my-videos-update', selector: 'my-videos-update',
@ -25,6 +26,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
videoPrivacies = [] videoPrivacies = []
userVideoChannels = [] userVideoChannels = []
schedulePublicationPossible = false schedulePublicationPossible = false
videoCaptions = []
constructor ( constructor (
protected formValidatorService: FormValidatorService, protected formValidatorService: FormValidatorService,
@ -36,6 +38,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
private authService: AuthService, private authService: AuthService,
private loadingBar: LoadingBarService, private loadingBar: LoadingBarService,
private videoChannelService: VideoChannelService, private videoChannelService: VideoChannelService,
private videoCaptionService: VideoCaptionService,
private i18n: I18n private i18n: I18n
) { ) {
super() super()
@ -63,12 +66,21 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))), map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))),
map(videoChannels => ({ video, videoChannels })) map(videoChannels => ({ video, videoChannels }))
) )
}),
switchMap(({ video, videoChannels }) => {
return this.videoCaptionService
.listCaptions(video.id)
.pipe(
map(result => result.data),
map(videoCaptions => ({ video, videoChannels, videoCaptions }))
)
}) })
) )
.subscribe( .subscribe(
({ video, videoChannels }) => { ({ video, videoChannels, videoCaptions }) => {
this.video = new VideoEdit(video) this.video = new VideoEdit(video)
this.userVideoChannels = videoChannels this.userVideoChannels = videoChannels
this.videoCaptions = videoCaptions
// We cannot set private a video that was not private // We cannot set private a video that was not private
if (this.video.privacy !== VideoPrivacy.PRIVATE) { if (this.video.privacy !== VideoPrivacy.PRIVATE) {
@ -102,7 +114,13 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
this.loadingBar.start() this.loadingBar.start()
this.isUpdatingVideo = true this.isUpdatingVideo = true
// Update the video
this.videoService.updateVideo(this.video) this.videoService.updateVideo(this.video)
.pipe(
// Then update captions
switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
)
.subscribe( .subscribe(
() => { () => {
this.isUpdatingVideo = false this.isUpdatingVideo = false

View File

@ -49,6 +49,7 @@ storage:
previews: 'storage/previews/' previews: 'storage/previews/'
thumbnails: 'storage/thumbnails/' thumbnails: 'storage/thumbnails/'
torrents: 'storage/torrents/' torrents: 'storage/torrents/'
captions: 'storage/captions/'
cache: 'storage/cache/' cache: 'storage/cache/'
log: log:
@ -57,6 +58,8 @@ log:
cache: cache:
previews: previews:
size: 1 # Max number of previews you want to cache size: 1 # Max number of previews you want to cache
captions:
size: 1 # Max number of video captions/subtitles you want to cache
admin: admin:
email: 'admin@example.com' # Your personal email as administrator email: 'admin@example.com' # Your personal email as administrator

View File

@ -50,6 +50,7 @@ storage:
previews: '/var/www/peertube/storage/previews/' previews: '/var/www/peertube/storage/previews/'
thumbnails: '/var/www/peertube/storage/thumbnails/' thumbnails: '/var/www/peertube/storage/thumbnails/'
torrents: '/var/www/peertube/storage/torrents/' torrents: '/var/www/peertube/storage/torrents/'
captions: '/var/www/peertube/storage/captions/'
cache: '/var/www/peertube/storage/cache/' cache: '/var/www/peertube/storage/cache/'
log: log:

View File

@ -16,6 +16,7 @@ storage:
previews: 'test1/previews/' previews: 'test1/previews/'
thumbnails: 'test1/thumbnails/' thumbnails: 'test1/thumbnails/'
torrents: 'test1/torrents/' torrents: 'test1/torrents/'
captions: 'test1/captions/'
cache: 'test1/cache/' cache: 'test1/cache/'
admin: admin:

View File

@ -16,6 +16,7 @@ storage:
previews: 'test2/previews/' previews: 'test2/previews/'
thumbnails: 'test2/thumbnails/' thumbnails: 'test2/thumbnails/'
torrents: 'test2/torrents/' torrents: 'test2/torrents/'
captions: 'test2/captions/'
cache: 'test2/cache/' cache: 'test2/cache/'
admin: admin:

View File

@ -16,6 +16,7 @@ storage:
previews: 'test3/previews/' previews: 'test3/previews/'
thumbnails: 'test3/thumbnails/' thumbnails: 'test3/thumbnails/'
torrents: 'test3/torrents/' torrents: 'test3/torrents/'
captions: 'test3/captions/'
cache: 'test3/cache/' cache: 'test3/cache/'
admin: admin:

View File

@ -16,6 +16,7 @@ storage:
previews: 'test4/previews/' previews: 'test4/previews/'
thumbnails: 'test4/thumbnails/' thumbnails: 'test4/thumbnails/'
torrents: 'test4/torrents/' torrents: 'test4/torrents/'
captions: 'test4/captions/'
cache: 'test4/cache/' cache: 'test4/cache/'
admin: admin:

View File

@ -16,6 +16,7 @@ storage:
previews: 'test5/previews/' previews: 'test5/previews/'
thumbnails: 'test5/thumbnails/' thumbnails: 'test5/thumbnails/'
torrents: 'test5/torrents/' torrents: 'test5/torrents/'
captions: 'test5/captions/'
cache: 'test5/cache/' cache: 'test5/cache/'
admin: admin:

View File

@ -16,6 +16,7 @@ storage:
previews: 'test6/previews/' previews: 'test6/previews/'
thumbnails: 'test6/thumbnails/' thumbnails: 'test6/thumbnails/'
torrents: 'test6/torrents/' torrents: 'test6/torrents/'
captions: 'test6/captions/'
cache: 'test6/cache/' cache: 'test6/cache/'
admin: admin:

View File

@ -1,4 +1,6 @@
// FIXME: https://github.com/nodejs/node/pull/16853 // FIXME: https://github.com/nodejs/node/pull/16853
import { VideosCaptionCache } from './server/lib/cache/videos-caption-cache'
require('tls').DEFAULT_ECDH_CURVE = 'auto' require('tls').DEFAULT_ECDH_CURVE = 'auto'
import { isTestInstance } from './server/helpers/core-utils' import { isTestInstance } from './server/helpers/core-utils'
@ -181,6 +183,7 @@ async function startApplication () {
// Caches initializations // Caches initializations
VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE) VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE)
VideosCaptionCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE)
// Enable Schedulers // Enable Schedulers
BadActorFollowScheduler.Instance.enable() BadActorFollowScheduler.Instance.enable()

View File

@ -25,6 +25,8 @@ import {
getVideoLikesActivityPubUrl, getVideoLikesActivityPubUrl,
getVideoSharesActivityPubUrl getVideoSharesActivityPubUrl
} from '../../lib/activitypub' } from '../../lib/activitypub'
import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
import { VideoCaptionModel } from '../../models/video/video-caption'
const activityPubClientRouter = express.Router() const activityPubClientRouter = express.Router()
@ -123,6 +125,9 @@ async function accountFollowingController (req: express.Request, res: express.Re
async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) { async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) {
const video: VideoModel = res.locals.video 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 audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
const videoObject = audiencify(video.toActivityPubObject(), audience) const videoObject = audiencify(video.toActivityPubObject(), audience)

View File

@ -80,6 +80,14 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME 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: { user: {
videoQuota: CONFIG.USER.VIDEO_QUOTA videoQuota: CONFIG.USER.VIDEO_QUOTA
} }
@ -122,12 +130,13 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
// Force number conversion // Force number conversion
toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10) 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.signup.limit = parseInt('' + toUpdate.signup.limit, 10)
toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10) toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10)
toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10) toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
// camelCase to snake_case key // 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.user['video_quota'] = toUpdate.user.videoQuota
toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
@ -172,6 +181,9 @@ function customConfig (): CustomConfig {
cache: { cache: {
previews: { previews: {
size: CONFIG.CACHE.PREVIEWS.SIZE size: CONFIG.CACHE.PREVIEWS.SIZE
},
captions: {
size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
} }
}, },
signup: { signup: {

View File

@ -0,0 +1,100 @@
import * as express from 'express'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
import {
addVideoCaptionValidator,
deleteVideoCaptionValidator,
listVideoCaptionsValidator
} from '../../../middlewares/validators/video-captions'
import { createReqFiles } from '../../../helpers/express-utils'
import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers'
import { getFormattedObjects } from '../../../helpers/utils'
import { VideoCaptionModel } from '../../../models/video/video-caption'
import { renamePromise } from '../../../helpers/core-utils'
import { join } from 'path'
import { VideoModel } from '../../../models/video/video'
import { logger } from '../../../helpers/logger'
import { federateVideoIfNeeded } from '../../../lib/activitypub'
const reqVideoCaptionAdd = createReqFiles(
[ 'captionfile' ],
VIDEO_CAPTIONS_MIMETYPE_EXT,
{
captionfile: CONFIG.STORAGE.CAPTIONS_DIR
}
)
const videoCaptionsRouter = express.Router()
videoCaptionsRouter.get('/:videoId/captions',
asyncMiddleware(listVideoCaptionsValidator),
asyncMiddleware(listVideoCaptions)
)
videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
authenticate,
reqVideoCaptionAdd,
asyncMiddleware(addVideoCaptionValidator),
asyncRetryTransactionMiddleware(addVideoCaption)
)
videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
authenticate,
asyncMiddleware(deleteVideoCaptionValidator),
asyncRetryTransactionMiddleware(deleteVideoCaption)
)
// ---------------------------------------------------------------------------
export {
videoCaptionsRouter
}
// ---------------------------------------------------------------------------
async function listVideoCaptions (req: express.Request, res: express.Response) {
const data = await VideoCaptionModel.listVideoCaptions(res.locals.video.id)
return res.json(getFormattedObjects(data, data.length))
}
async function addVideoCaption (req: express.Request, res: express.Response) {
const videoCaptionPhysicalFile = req.files['captionfile'][0]
const video = res.locals.video as VideoModel
const videoCaption = new VideoCaptionModel({
videoId: video.id,
language: req.params.captionLanguage
})
videoCaption.Video = video
// Move physical file
const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR
const destination = join(videoCaptionsDir, videoCaption.getCaptionName())
await renamePromise(videoCaptionPhysicalFile.path, destination)
// This is important in case if there is another attempt in the retry process
videoCaptionPhysicalFile.filename = videoCaption.getCaptionName()
videoCaptionPhysicalFile.path = destination
await sequelizeTypescript.transaction(async t => {
await VideoCaptionModel.insertOrReplaceLanguage(video.id, req.params.captionLanguage, t)
// Update video update
await federateVideoIfNeeded(video, false, t)
})
return res.status(204).end()
}
async function deleteVideoCaption (req: express.Request, res: express.Response) {
const video = res.locals.video as VideoModel
const videoCaption = res.locals.videoCaption as VideoCaptionModel
await sequelizeTypescript.transaction(async t => {
await videoCaption.destroy({ transaction: t })
// Send video update
await federateVideoIfNeeded(video, false, t)
})
logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid)
return res.type('json').status(204).end()
}

View File

@ -53,6 +53,7 @@ import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { videoCaptionsRouter } from './captions'
const videosRouter = express.Router() const videosRouter = express.Router()
@ -78,6 +79,7 @@ videosRouter.use('/', abuseVideoRouter)
videosRouter.use('/', blacklistRouter) videosRouter.use('/', blacklistRouter)
videosRouter.use('/', rateVideoRouter) videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoCommentRouter) videosRouter.use('/', videoCommentRouter)
videosRouter.use('/', videoCaptionsRouter)
videosRouter.get('/categories', listVideoCategories) videosRouter.get('/categories', listVideoCategories)
videosRouter.get('/licences', listVideoLicences) videosRouter.get('/licences', listVideoLicences)

View File

@ -118,7 +118,7 @@ function addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
const videoNameEscaped = escapeHTML(video.name) const videoNameEscaped = escapeHTML(video.name)
const videoDescriptionEscaped = escapeHTML(video.description) const videoDescriptionEscaped = escapeHTML(video.description)
const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedPath() const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedStaticPath()
const openGraphMetaTags = { const openGraphMetaTags = {
'og:type': 'video', 'og:type': 'video',

View File

@ -129,7 +129,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response, n
torrent: torrents, torrent: torrents,
thumbnail: [ thumbnail: [
{ {
url: CONFIG.WEBSERVER.URL + video.getThumbnailPath(), url: CONFIG.WEBSERVER.URL + video.getThumbnailStaticPath(),
height: THUMBNAILS_SIZE.height, height: THUMBNAILS_SIZE.height,
width: THUMBNAILS_SIZE.width width: THUMBNAILS_SIZE.width
} }

View File

@ -29,8 +29,8 @@ function generateOEmbed (req: express.Request, res: express.Response, next: expr
const maxHeight = parseInt(req.query.maxheight, 10) const maxHeight = parseInt(req.query.maxheight, 10)
const maxWidth = parseInt(req.query.maxwidth, 10) const maxWidth = parseInt(req.query.maxwidth, 10)
const embedUrl = webserverUrl + video.getEmbedPath() const embedUrl = webserverUrl + video.getEmbedStaticPath()
let thumbnailUrl = webserverUrl + video.getPreviewPath() let thumbnailUrl = webserverUrl + video.getPreviewStaticPath()
let embedWidth = EMBED_SIZE.width let embedWidth = EMBED_SIZE.width
let embedHeight = EMBED_SIZE.height let embedHeight = EMBED_SIZE.height

View File

@ -4,6 +4,7 @@ import { CONFIG, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../
import { VideosPreviewCache } from '../lib/cache' import { VideosPreviewCache } from '../lib/cache'
import { asyncMiddleware, videosGetValidator } from '../middlewares' import { asyncMiddleware, videosGetValidator } from '../middlewares'
import { VideoModel } from '../models/video/video' import { VideoModel } from '../models/video/video'
import { VideosCaptionCache } from '../lib/cache/videos-caption-cache'
const staticRouter = express.Router() const staticRouter = express.Router()
@ -49,12 +50,18 @@ staticRouter.use(
express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE }) 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( staticRouter.use(
STATIC_PATHS.PREVIEWS + ':uuid.jpg', STATIC_PATHS.PREVIEWS + ':uuid.jpg',
asyncMiddleware(getPreview) 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 // robots.txt service
staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => { staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => {
res.type('text/plain') res.type('text/plain')
@ -70,7 +77,17 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) { 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) if (!path) return res.sendStatus(404)
return res.sendFile(path, { maxAge: STATIC_MAX_AGE }) return res.sendFile(path, { maxAge: STATIC_MAX_AGE })

View File

@ -18,6 +18,7 @@ function activityPubContextify <T> (data: T) {
uuid: 'http://schema.org/identifier', uuid: 'http://schema.org/identifier',
category: 'http://schema.org/category', category: 'http://schema.org/category',
licence: 'http://schema.org/license', licence: 'http://schema.org/license',
subtitleLanguage: 'http://schema.org/subtitleLanguage',
sensitive: 'as:sensitive', sensitive: 'as:sensitive',
language: 'http://schema.org/inLanguage', language: 'http://schema.org/inLanguage',
views: 'http://schema.org/Number', views: 'http://schema.org/Number',

View File

@ -51,6 +51,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
if (!setValidRemoteVideoUrls(video)) return false if (!setValidRemoteVideoUrls(video)) return false
if (!setRemoteVideoTruncatedContent(video)) return false if (!setRemoteVideoTruncatedContent(video)) return false
if (!setValidAttributedTo(video)) return false if (!setValidAttributedTo(video)) return false
if (!setValidRemoteCaptions(video)) return false
// Default attributes // Default attributes
if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
@ -98,6 +99,18 @@ function setValidRemoteTags (video: any) {
return true 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) { function isRemoteNumberIdentifierValid (data: any) {
return validator.isInt(data.identifier, { min: 0 }) return validator.isInt(data.identifier, { min: 0 })
} }

View File

@ -0,0 +1,41 @@
import { CONSTRAINTS_FIELDS, VIDEO_LANGUAGES } from '../../initializers'
import { exists, isFileValid } from './misc'
import { Response } from 'express'
import { VideoModel } from '../../models/video/video'
import { VideoCaptionModel } from '../../models/video/video-caption'
function isVideoCaptionLanguageValid (value: any) {
return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined
}
const videoCaptionTypes = CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
.map(v => v.replace('.', ''))
.join('|')
const videoCaptionsTypesRegex = `text/(${videoCaptionTypes})`
function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
return isFileValid(files, videoCaptionsTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
}
async function isVideoCaptionExist (video: VideoModel, language: string, res: Response) {
const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, language)
if (!videoCaption) {
res.status(404)
.json({ error: 'Video caption not found' })
.end()
return false
}
res.locals.videoCaption = videoCaption
return true
}
// ---------------------------------------------------------------------------
export {
isVideoCaptionFile,
isVideoCaptionLanguageValid,
isVideoCaptionExist
}

View File

@ -126,6 +126,29 @@ function isVideoFileSizeValid (value: string) {
return exists(value) && validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.FILE_SIZE) 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) { async function isVideoExist (id: string, res: Response) {
let video: VideoModel let video: VideoModel
@ -179,6 +202,7 @@ async function isVideoChannelOfAccountExist (channelId: number, user: UserModel,
export { export {
isVideoCategoryValid, isVideoCategoryValid,
checkUserCanManageVideo,
isVideoLicenceValid, isVideoLicenceValid,
isVideoLanguageValid, isVideoLanguageValid,
isVideoTruncatedDescriptionValid, isVideoTruncatedDescriptionValid,

View File

@ -138,6 +138,7 @@ const CONFIG = {
VIDEOS_DIR: buildPath(config.get<string>('storage.videos')), VIDEOS_DIR: buildPath(config.get<string>('storage.videos')),
THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')), THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')), PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')), TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')),
CACHE_DIR: buildPath(config.get<string>('storage.cache')) CACHE_DIR: buildPath(config.get<string>('storage.cache'))
}, },
@ -183,6 +184,9 @@ const CONFIG = {
CACHE: { CACHE: {
PREVIEWS: { PREVIEWS: {
get SIZE () { return config.get<number>('cache.previews.size') } get SIZE () { return config.get<number>('cache.previews.size') }
},
VIDEO_CAPTIONS: {
get SIZE () { return config.get<number>('cache.captions.size') }
} }
}, },
INSTANCE: { INSTANCE: {
@ -225,6 +229,14 @@ const CONSTRAINTS_FIELDS = {
SUPPORT: { min: 3, max: 500 }, // Length SUPPORT: { min: 3, max: 500 }, // Length
URL: { min: 3, max: 2000 } // Length URL: { min: 3, max: 2000 } // Length
}, },
VIDEO_CAPTIONS: {
CAPTION_FILE: {
EXTNAME: [ '.vtt' ],
FILE_SIZE: {
max: 2 * 1024 * 1024 // 2MB
}
}
},
VIDEOS: { VIDEOS: {
NAME: { min: 3, max: 120 }, // Length NAME: { min: 3, max: 120 }, // Length
LANGUAGE: { min: 1, max: 10 }, // Length LANGUAGE: { min: 1, max: 10 }, // Length
@ -351,6 +363,10 @@ const IMAGE_MIMETYPE_EXT = {
'image/jpeg': '.jpg' 'image/jpeg': '.jpg'
} }
const VIDEO_CAPTIONS_MIMETYPE_EXT = {
'text/vtt': '.vtt'
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SERVER_ACTOR_NAME = 'peertube' const SERVER_ACTOR_NAME = 'peertube'
@ -403,7 +419,8 @@ const STATIC_PATHS = {
THUMBNAILS: '/static/thumbnails/', THUMBNAILS: '/static/thumbnails/',
TORRENTS: '/static/torrents/', TORRENTS: '/static/torrents/',
WEBSEED: '/static/webseed/', WEBSEED: '/static/webseed/',
AVATARS: '/static/avatars/' AVATARS: '/static/avatars/',
VIDEO_CAPTIONS: '/static/video-captions/'
} }
const STATIC_DOWNLOAD_PATHS = { const STATIC_DOWNLOAD_PATHS = {
TORRENTS: '/download/torrents/', TORRENTS: '/download/torrents/',
@ -435,7 +452,8 @@ const EMBED_SIZE = {
// Sub folders of cache directory // Sub folders of cache directory
const CACHE = { const CACHE = {
DIRECTORIES: { DIRECTORIES: {
PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews') PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
VIDEO_CAPTIONS: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions')
} }
} }
@ -490,6 +508,7 @@ updateWebserverConfig()
export { export {
API_VERSION, API_VERSION,
VIDEO_CAPTIONS_MIMETYPE_EXT,
AVATARS_SIZE, AVATARS_SIZE,
ACCEPT_HEADERS, ACCEPT_HEADERS,
BCRYPT_SALT_SIZE, BCRYPT_SALT_SIZE,

View File

@ -23,6 +23,7 @@ import { VideoShareModel } from '../models/video/video-share'
import { VideoTagModel } from '../models/video/video-tag' import { VideoTagModel } from '../models/video/video-tag'
import { CONFIG } from './constants' import { CONFIG } from './constants'
import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' 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 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -71,6 +72,7 @@ async function initDatabaseModels (silent: boolean) {
VideoChannelModel, VideoChannelModel,
VideoShareModel, VideoShareModel,
VideoFileModel, VideoFileModel,
VideoCaptionModel,
VideoBlacklistModel, VideoBlacklistModel,
VideoTagModel, VideoTagModel,
VideoModel, VideoModel,

View File

@ -19,6 +19,7 @@ import {
videoFileActivityUrlToDBAttributes videoFileActivityUrlToDBAttributes
} from '../videos' } from '../videos'
import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
import { VideoCaptionModel } from '../../../models/video/video-caption'
async function processUpdateActivity (activity: ActivityUpdate) { async function processUpdateActivity (activity: ActivityUpdate) {
const actor = await getOrCreateActorAndServerAndModel(activity.actor) const actor = await getOrCreateActorAndServerAndModel(activity.actor)
@ -110,9 +111,18 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
const tasks = videoFileAttributes.map(f => VideoFileModel.create(f)) const tasks = videoFileAttributes.map(f => VideoFileModel.create(f))
await Promise.all(tasks) 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) const tagInstances = await TagModel.findOrCreateTags(tags, t)
await videoInstance.$set('Tags', tagInstances, sequelizeOptions) 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) logger.info('Remote video with uuid %s updated', videoObject.uuid)

View File

@ -24,10 +24,20 @@ import { addVideoComments } from './video-comments'
import { crawlCollectionPage } from './crawl' import { crawlCollectionPage } from './crawl'
import { sendCreateVideo, sendUpdateVideo } from './send' import { sendCreateVideo, sendUpdateVideo } from './send'
import { shareVideoByServerAndChannel } from './index' 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) { async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
// If the video is not private and published, we federate it // If the video is not private and published, we federate it
if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) { 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) { if (isNewVideo === true) {
// Now we'll add the video's meta data to our followers // Now we'll add the video's meta data to our followers
await sendCreateVideo(video, transaction) await sendCreateVideo(video, transaction)
@ -38,9 +48,8 @@ async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, tr
} }
} }
function fetchRemoteVideoPreview (video: VideoModel, reject: Function) { function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
const host = video.VideoChannel.Account.Actor.Server.host const 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 // We need to provide a callback, if no we could have an uncaught exception
return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => { return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
@ -179,24 +188,32 @@ async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor:
const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to) const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
const video = VideoModel.build(videoData) 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) generateThumbnailFromUrl(video, videoObject.icon)
.catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })) .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
const videoCreated = await video.save(sequelizeOptions) const videoCreated = await video.save(sequelizeOptions)
// Process files
const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject) const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
if (videoFileAttributes.length === 0) { if (videoFileAttributes.length === 0) {
throw new Error('Cannot find valid files for video %s ' + videoObject.url) throw new Error('Cannot find valid files for video %s ' + videoObject.url)
} }
const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t })) const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
await Promise.all(tasks) await Promise.all(videoFilePromises)
// Process tags
const tags = videoObject.tag.map(t => t.name) const tags = videoObject.tag.map(t => t.name)
const tagInstances = await TagModel.findOrCreateTags(tags, t) const tagInstances = await TagModel.findOrCreateTags(tags, t)
await videoCreated.$set('Tags', tagInstances, sequelizeOptions) 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) logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
videoCreated.VideoChannel = channelActor.VideoChannel videoCreated.VideoChannel = channelActor.VideoChannel
@ -328,7 +345,7 @@ export {
federateVideoIfNeeded, federateVideoIfNeeded,
fetchRemoteVideo, fetchRemoteVideo,
getOrCreateAccountAndVideoAndChannel, getOrCreateAccountAndVideoAndChannel,
fetchRemoteVideoPreview, fetchRemoteVideoStaticFile,
fetchRemoteVideoDescription, fetchRemoteVideoDescription,
generateThumbnailFromUrl, generateThumbnailFromUrl,
videoActivityObjectToDBAttributes, videoActivityObjectToDBAttributes,

View File

@ -0,0 +1,54 @@
import * as AsyncLRU from 'async-lru'
import { createWriteStream } from 'fs'
import { join } from 'path'
import { unlinkPromise } from '../../helpers/core-utils'
import { logger } from '../../helpers/logger'
import { CACHE, CONFIG } from '../../initializers'
import { VideoModel } from '../../models/video/video'
import { fetchRemoteVideoStaticFile } from '../activitypub'
import { VideoCaptionModel } from '../../models/video/video-caption'
export abstract class AbstractVideoStaticFileCache <T> {
protected lru
abstract getFilePath (params: T): Promise<string>
// Load and save the remote file, then return the local path from filesystem
protected abstract loadRemoteFile (key: string): Promise<string>
init (max: number) {
this.lru = new AsyncLRU({
max,
load: (key, cb) => {
this.loadRemoteFile(key)
.then(res => cb(null, res))
.catch(err => cb(err))
}
})
this.lru.on('evict', (obj: { key: string, value: string }) => {
unlinkPromise(obj.value).then(() => logger.debug('%s evicted from %s', obj.value, this.constructor.name))
})
}
protected loadFromLRU (key: string) {
return new Promise<string>((res, rej) => {
this.lru.get(key, (err, value) => {
err ? rej(err) : res(value)
})
})
}
protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {
return new Promise<string>((res, rej) => {
const req = fetchRemoteVideoStaticFile(video, remoteStaticPath, rej)
const stream = createWriteStream(destPath)
req.pipe(stream)
.on('error', (err) => rej(err))
.on('finish', () => res(destPath))
})
}
}

View File

@ -0,0 +1,53 @@
import { join } from 'path'
import { CACHE, CONFIG } from '../../initializers'
import { VideoModel } from '../../models/video/video'
import { VideoCaptionModel } from '../../models/video/video-caption'
import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
type GetPathParam = { videoId: string, language: string }
class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
private static readonly KEY_DELIMITER = '%'
private static instance: VideosCaptionCache
private constructor () {
super()
}
static get Instance () {
return this.instance || (this.instance = new this())
}
async getFilePath (params: GetPathParam) {
const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
if (!videoCaption) return undefined
if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language
return this.loadFromLRU(key)
}
protected async loadRemoteFile (key: string) {
const [ videoId, language ] = key.split(VideosCaptionCache.KEY_DELIMITER)
const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(videoId, language)
if (!videoCaption) return undefined
if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
// Used to fetch the path
const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
if (!video) return undefined
const remoteStaticPath = videoCaption.getCaptionStaticPath()
const destPath = join(CACHE.DIRECTORIES.VIDEO_CAPTIONS, videoCaption.getCaptionName())
return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
}
}
export {
VideosCaptionCache
}

View File

@ -1,71 +1,39 @@
import * as asyncLRU from 'async-lru'
import { createWriteStream } from 'fs'
import { join } from 'path' import { join } from 'path'
import { unlinkPromise } from '../../helpers/core-utils' import { CACHE, CONFIG, STATIC_PATHS } from '../../initializers'
import { logger } from '../../helpers/logger'
import { CACHE, CONFIG } from '../../initializers'
import { VideoModel } from '../../models/video/video' 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 static instance: VideosPreviewCache
private lru private constructor () {
super()
private constructor () { } }
static get Instance () { static get Instance () {
return this.instance || (this.instance = new this()) return this.instance || (this.instance = new this())
} }
init (max: number) { async getFilePath (videoUUID: string) {
this.lru = new asyncLRU({ const video = await VideoModel.loadByUUID(videoUUID)
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)
if (!video) return undefined if (!video) return undefined
if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()) if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
return new Promise<string>((res, rej) => { return this.loadFromLRU(videoUUID)
this.lru.get(key, (err, value) => {
err ? rej(err) : res(value)
})
})
} }
private async loadPreviews (key: string) { protected async loadRemoteFile (key: string) {
const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key) const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
if (!video) return undefined if (!video) return undefined
if (video.isOwned()) throw new Error('Cannot load preview of owned video.') if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
return this.saveRemotePreviewAndReturnPath(video) const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
} const destPath = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
private saveRemotePreviewAndReturnPath (video: VideoModel) { return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
return new Promise<string>((res, rej) => {
const req = fetchRemoteVideoPreview(video, rej)
const path = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
const stream = createWriteStream(path)
req.pipe(stream)
.on('error', (err) => rej(err))
.on('finish', () => res(path))
})
} }
} }

View File

@ -0,0 +1,70 @@
import * as express from 'express'
import { areValidationErrors } from './utils'
import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos'
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
import { body, param } from 'express-validator/check'
import { CONSTRAINTS_FIELDS } from '../../initializers'
import { UserRight } from '../../../shared'
import { logger } from '../../helpers/logger'
import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
const addVideoCaptionValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
body('captionfile')
.custom((value, { req }) => isVideoCaptionFile(req.files, 'captionfile')).withMessage(
'This caption file is not supported or too large. Please, make sure it is of the following type : '
+ CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME.join(', ')
),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking addVideoCaption parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.videoId, res)) return
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
return next()
}
]
const deleteVideoCaptionValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.videoId, res)) return
if (!await isVideoCaptionExist(res.locals.video, req.params.captionLanguage, res)) return
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
return next()
}
]
const listVideoCaptionsValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.videoId, res)) return
return next()
}
]
export {
addVideoCaptionValidator,
listVideoCaptionsValidator,
deleteVideoCaptionValidator
}

View File

@ -12,6 +12,7 @@ import {
toValueOrNull toValueOrNull
} from '../../helpers/custom-validators/misc' } from '../../helpers/custom-validators/misc'
import { import {
checkUserCanManageVideo,
isScheduleVideoUpdatePrivacyValid, isScheduleVideoUpdatePrivacyValid,
isVideoAbuseReasonValid, isVideoAbuseReasonValid,
isVideoCategoryValid, isVideoCategoryValid,
@ -31,8 +32,6 @@ import {
import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { CONSTRAINTS_FIELDS } from '../../initializers' 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 { VideoShareModel } from '../../models/video/video-share'
import { authenticate } from '../oauth' import { authenticate } from '../oauth'
import { areValidationErrors } from './utils' import { areValidationErrors } from './utils'
@ -40,17 +39,17 @@ import { areValidationErrors } from './utils'
const videosAddValidator = [ const videosAddValidator = [
body('videofile') body('videofile')
.custom((value, { req }) => isVideoFile(req.files)).withMessage( .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(', ') + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
), ),
body('thumbnailfile') body('thumbnailfile')
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( .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(', ') + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
), ),
body('previewfile') body('previewfile')
.custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( .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(', ') + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
), ),
body('name').custom(isVideoNameValid).withMessage('Should have a valid name'), body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
@ -152,12 +151,12 @@ const videosUpdateValidator = [
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
body('thumbnailfile') body('thumbnailfile')
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( .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(', ') + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
), ),
body('previewfile') body('previewfile')
.custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( .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(', ') + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
), ),
body('name') body('name')
@ -373,29 +372,6 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: express.Response) {
// Retrieve the user who did the request
if (video.isOwned() === false) {
res.status(403)
.json({ error: 'Cannot manage a video of another server.' })
.end()
return false
}
// Check if the user can delete the video
// The user can delete it if he has the right
// Or if s/he is the video's account
const account = video.VideoChannel.Account
if (user.hasRight(right) === false && account.userId !== user.id) {
res.status(403)
.json({ error: 'Cannot manage a video of another user.' })
.end()
return false
}
return true
}
function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) { function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) {
// Files are optional // Files are optional
if (!req.files) return false if (!req.files) return false

View File

@ -0,0 +1,173 @@
import * as Sequelize from 'sequelize'
import {
AllowNull,
BeforeDestroy,
BelongsTo,
Column,
CreatedAt,
ForeignKey,
Is,
Model,
Scopes,
Table,
UpdatedAt
} from 'sequelize-typescript'
import { throwIfNotValid } from '../utils'
import { VideoModel } from './video'
import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers'
import { join } from 'path'
import { logger } from '../../helpers/logger'
import { unlinkPromise } from '../../helpers/core-utils'
export enum ScopeNames {
WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
}
@Scopes({
[ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
include: [
{
attributes: [ 'uuid', 'remote' ],
model: () => VideoModel.unscoped(),
required: true
}
]
}
})
@Table({
tableName: 'videoCaption',
indexes: [
{
fields: [ 'videoId' ]
},
{
fields: [ 'videoId', 'language' ],
unique: true
}
]
})
export class VideoCaptionModel extends Model<VideoCaptionModel> {
@CreatedAt
createdAt: Date
@UpdatedAt
updatedAt: Date
@AllowNull(false)
@Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
@Column
language: string
@ForeignKey(() => VideoModel)
@Column
videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
Video: VideoModel
@BeforeDestroy
static async removeFiles (instance: VideoCaptionModel) {
if (instance.isOwned()) {
if (!instance.Video) {
instance.Video = await instance.$get('Video') as VideoModel
}
logger.debug('Removing captions %s of video %s.', instance.Video.uuid, instance.language)
return instance.removeCaptionFile()
}
return undefined
}
static loadByVideoIdAndLanguage (videoId: string | number, language: string) {
const videoInclude = {
model: VideoModel.unscoped(),
attributes: [ 'id', 'remote', 'uuid' ],
where: { }
}
if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId
else videoInclude.where['id'] = videoId
const query = {
where: {
language
},
include: [
videoInclude
]
}
return VideoCaptionModel.findOne(query)
}
static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) {
const values = {
videoId,
language
}
return VideoCaptionModel.upsert(values, { transaction })
}
static listVideoCaptions (videoId: number) {
const query = {
order: [ [ 'language', 'ASC' ] ],
where: {
videoId
}
}
return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
}
static getLanguageLabel (language: string) {
return VIDEO_LANGUAGES[language] || 'Unknown'
}
static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) {
const query = {
where: {
videoId
},
transaction
}
return VideoCaptionModel.destroy(query)
}
isOwned () {
return this.Video.remote === false
}
toFormattedJSON (): VideoCaption {
return {
language: {
id: this.language,
label: VideoCaptionModel.getLanguageLabel(this.language)
},
captionPath: this.getCaptionStaticPath()
}
}
getCaptionStaticPath () {
return join(STATIC_PATHS.VIDEO_CAPTIONS, this.getCaptionName())
}
getCaptionName () {
return `${this.Video.uuid}-${this.language}.vtt`
}
removeCaptionFile () {
return unlinkPromise(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName())
}
}

View File

@ -92,6 +92,7 @@ import { VideoFileModel } from './video-file'
import { VideoShareModel } from './video-share' import { VideoShareModel } from './video-share'
import { VideoTagModel } from './video-tag' import { VideoTagModel } from './video-tag'
import { ScheduleVideoUpdateModel } from './schedule-video-update' import { ScheduleVideoUpdateModel } from './schedule-video-update'
import { VideoCaptionModel } from './video-caption'
export enum ScopeNames { export enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
@ -526,6 +527,17 @@ export class VideoModel extends Model<VideoModel> {
}) })
ScheduleVideoUpdate: ScheduleVideoUpdateModel ScheduleVideoUpdate: ScheduleVideoUpdateModel
@HasMany(() => VideoCaptionModel, {
foreignKey: {
name: 'videoId',
allowNull: false
},
onDelete: 'cascade',
hooks: true,
['separate' as any]: true
})
VideoCaptions: VideoCaptionModel[]
@BeforeDestroy @BeforeDestroy
static async sendDelete (instance: VideoModel, options) { static async sendDelete (instance: VideoModel, options) {
if (instance.isOwned()) { if (instance.isOwned()) {
@ -550,7 +562,7 @@ export class VideoModel extends Model<VideoModel> {
} }
@BeforeDestroy @BeforeDestroy
static async removeFilesAndSendDelete (instance: VideoModel) { static async removeFiles (instance: VideoModel) {
const tasks: Promise<any>[] = [] const tasks: Promise<any>[] = []
logger.debug('Removing files of video %s.', instance.url) logger.debug('Removing files of video %s.', instance.url)
@ -615,6 +627,11 @@ export class VideoModel extends Model<VideoModel> {
] ]
}, },
include: [ include: [
{
attributes: [ 'language' ],
model: VideoCaptionModel.unscoped(),
required: false
},
{ {
attributes: [ 'id', 'url' ], attributes: [ 'id', 'url' ],
model: VideoShareModel.unscoped(), model: VideoShareModel.unscoped(),
@ -1028,15 +1045,15 @@ export class VideoModel extends Model<VideoModel> {
videoFile.infoHash = parsedTorrent.infoHash videoFile.infoHash = parsedTorrent.infoHash
} }
getEmbedPath () { getEmbedStaticPath () {
return '/videos/embed/' + this.uuid return '/videos/embed/' + this.uuid
} }
getThumbnailPath () { getThumbnailStaticPath () {
return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
} }
getPreviewPath () { getPreviewStaticPath () {
return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
} }
@ -1077,9 +1094,9 @@ export class VideoModel extends Model<VideoModel> {
views: this.views, views: this.views,
likes: this.likes, likes: this.likes,
dislikes: this.dislikes, dislikes: this.dislikes,
thumbnailPath: this.getThumbnailPath(), thumbnailPath: this.getThumbnailStaticPath(),
previewPath: this.getPreviewPath(), previewPath: this.getPreviewStaticPath(),
embedPath: this.getEmbedPath(), embedPath: this.getEmbedStaticPath(),
createdAt: this.createdAt, createdAt: this.createdAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
publishedAt: this.publishedAt, publishedAt: this.publishedAt,
@ -1247,6 +1264,14 @@ export class VideoModel extends Model<VideoModel> {
href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid 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 { return {
type: 'Video' as 'Video', type: 'Video' as 'Video',
id: this.url, id: this.url,
@ -1267,6 +1292,7 @@ export class VideoModel extends Model<VideoModel> {
mediaType: 'text/markdown', mediaType: 'text/markdown',
content: this.getTruncatedDescription(), content: this.getTruncatedDescription(),
support: this.support, support: this.support,
subtitleLanguage,
icon: { icon: {
type: 'Image', type: 'Image',
url: this.getThumbnailUrl(baseUrlHttp), url: this.getThumbnailUrl(baseUrlHttp),

View File

@ -35,6 +35,9 @@ describe('Test config API validators', function () {
cache: { cache: {
previews: { previews: {
size: 2 size: 2
},
captions: {
size: 3
} }
}, },
signup: { signup: {

View File

@ -6,6 +6,7 @@ import './services'
import './users' import './users'
import './video-abuses' import './video-abuses'
import './video-blacklist' import './video-blacklist'
import './video-captions'
import './video-channels' import './video-channels'
import './video-comments' import './video-comments'
import './videos' import './videos'

View File

@ -0,0 +1,223 @@
/* tslint:disable:no-unused-expression */
import * as chai from 'chai'
import 'mocha'
import {
createUser,
flushTests,
killallServers,
makeDeleteRequest,
makeGetRequest,
makeUploadRequest,
runServer,
ServerInfo,
setAccessTokensToServers,
uploadVideo,
userLogin
} from '../../utils'
import { join } from 'path'
describe('Test video captions API validator', function () {
const path = '/api/v1/videos/'
let server: ServerInfo
let userAccessToken: string
let videoUUID: string
// ---------------------------------------------------------------
before(async function () {
this.timeout(30000)
await flushTests()
server = await runServer(1)
await setAccessTokensToServers([ server ])
{
const res = await uploadVideo(server.url, server.accessToken, {})
videoUUID = res.body.video.uuid
}
{
const user = {
username: 'user1',
password: 'my super password'
}
await createUser(server.url, server.accessToken, user.username, user.password)
userAccessToken = await userLogin(server, user)
}
})
describe('When adding video caption', function () {
const fields = { }
const attaches = {
'captionfile': join(__dirname, '..', '..', 'fixtures', 'subtitle-good1.vtt')
}
it('Should fail without a valid uuid', async function () {
await makeUploadRequest({
method: 'PUT',
url: server.url,
path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions',
token: server.accessToken,
fields,
attaches
})
})
it('Should fail with an unknown id', async function () {
await makeUploadRequest({
method: 'PUT',
url: server.url,
path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions',
token: server.accessToken,
fields,
attaches
})
})
it('Should fail with a missing language in path', async function () {
const captionPath = path + videoUUID + '/captions'
await makeUploadRequest({
method: 'PUT',
url: server.url,
path: captionPath,
token: server.accessToken,
fields,
attaches
})
})
it('Should fail with an unknown language', async function () {
const captionPath = path + videoUUID + '/captions/15'
await makeUploadRequest({
method: 'PUT',
url: server.url,
path: captionPath,
token: server.accessToken,
fields,
attaches
})
})
it('Should fail without access token', async function () {
const captionPath = path + videoUUID + '/captions/fr'
await makeUploadRequest({
method: 'PUT',
url: server.url,
path: captionPath,
fields,
attaches,
statusCodeExpected: 401
})
})
it('Should fail with a bad access token', async function () {
const captionPath = path + videoUUID + '/captions/fr'
await makeUploadRequest({
method: 'PUT',
url: server.url,
path: captionPath,
token: 'blabla',
fields,
attaches,
statusCodeExpected: 401
})
})
it('Should success with the correct parameters', async function () {
const captionPath = path + videoUUID + '/captions/fr'
await makeUploadRequest({
method: 'PUT',
url: server.url,
path: captionPath,
token: server.accessToken,
fields,
attaches,
statusCodeExpected: 204
})
})
})
describe('When listing video captions', function () {
it('Should fail without a valid uuid', async function () {
await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions' })
})
it('Should fail with an unknown id', async function () {
await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions', statusCodeExpected: 404 })
})
it('Should success with the correct parameters', async function () {
await makeGetRequest({ url: server.url, path: path + videoUUID + '/captions', statusCodeExpected: 200 })
})
})
describe('When deleting video caption', function () {
it('Should fail without a valid uuid', async function () {
await makeDeleteRequest({
url: server.url,
path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions/fr',
token: server.accessToken
})
})
it('Should fail with an unknown id', async function () {
await makeDeleteRequest({
url: server.url,
path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/fr',
token: server.accessToken,
statusCodeExpected: 404
})
})
it('Should fail with an invalid language', async function () {
await makeDeleteRequest({
url: server.url,
path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/16',
token: server.accessToken
})
})
it('Should fail with a missing language', async function () {
const captionPath = path + videoUUID + '/captions'
await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
})
it('Should fail with an unknown language', async function () {
const captionPath = path + videoUUID + '/captions/15'
await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
})
it('Should fail without access token', async function () {
const captionPath = path + videoUUID + '/captions/fr'
await makeDeleteRequest({ url: server.url, path: captionPath, statusCodeExpected: 401 })
})
it('Should fail with a bad access token', async function () {
const captionPath = path + videoUUID + '/captions/fr'
await makeDeleteRequest({ url: server.url, path: captionPath, token: 'coucou', statusCodeExpected: 401 })
})
it('Should fail with another user', async function () {
const captionPath = path + videoUUID + '/captions/fr'
await makeDeleteRequest({ url: server.url, path: captionPath, token: userAccessToken, statusCodeExpected: 403 })
})
it('Should success with the correct parameters', async function () {
const captionPath = path + videoUUID + '/captions/fr'
await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken, statusCodeExpected: 204 })
})
})
after(async function () {
killallServers([ server ])
// Keep the logs if the test failed
if (this['ok']) {
await flushTests()
}
})
})

View File

@ -4,6 +4,7 @@ import './check-params'
import './users/users' import './users/users'
import './videos/single-server' import './videos/single-server'
import './videos/video-abuse' import './videos/video-abuse'
import './videos/video-captions'
import './videos/video-blacklist' import './videos/video-blacklist'
import './videos/video-blacklist-management' import './videos/video-blacklist-management'
import './videos/video-description' import './videos/video-description'

View File

@ -14,6 +14,61 @@ import {
registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig
} from '../../utils/index' } 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 () { describe('Test config', function () {
let server = null let server = null
@ -51,35 +106,11 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken) const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body as CustomConfig const data = res.body as CustomConfig
expect(data.instance.name).to.equal('PeerTube') checkInitialConfig(data)
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
}) })
it('Should update the customized configuration', async function () { it('Should update the customized configuration', async function () {
const newCustomConfig = { const newCustomConfig: CustomConfig = {
instance: { instance: {
name: 'PeerTube updated', name: 'PeerTube updated',
shortDescription: 'my short description', shortDescription: 'my short description',
@ -101,6 +132,9 @@ describe('Test config', function () {
cache: { cache: {
previews: { previews: {
size: 2 size: 2
},
captions: {
size: 3
} }
}, },
signup: { signup: {
@ -130,28 +164,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken) const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body const data = res.body
expect(data.instance.name).to.equal('PeerTube updated') checkUpdatedConfig(data)
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
}) })
it('Should have the configuration updated after a restart', async function () { it('Should have the configuration updated after a restart', async function () {
@ -164,28 +177,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken) const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body const data = res.body
expect(data.instance.name).to.equal('PeerTube updated') checkUpdatedConfig(data)
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
}) })
it('Should fetch the about information', async function () { it('Should fetch the about information', async function () {
@ -206,31 +198,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken) const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body const data = res.body
expect(data.instance.name).to.equal('PeerTube') checkInitialConfig(data)
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
}) })
after(async function () { after(async function () {

View File

@ -26,6 +26,8 @@ import {
} from '../../utils/videos/video-comments' } from '../../utils/videos/video-comments'
import { rateVideo } from '../../utils/videos/videos' import { rateVideo } from '../../utils/videos/videos'
import { waitJobs } from '../../utils/server/jobs' 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 expect = chai.expect
@ -244,6 +246,16 @@ describe('Test follows', function () {
const text3 = 'my second answer to thread 1' const text3 = 'my second answer to thread 1'
await addVideoCommentReply(servers[ 2 ].url, servers[ 2 ].accessToken, video4.id, threadId, text3) 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 waitJobs(servers)
@ -266,7 +278,7 @@ describe('Test follows', function () {
await expectAccountFollows(servers[2].url, 'peertube@localhost:9003', 1, 0) 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) const res = await getVideosList(servers[ 0 ].url)
expect(res.body.total).to.equal(7) expect(res.body.total).to.equal(7)
@ -314,7 +326,7 @@ describe('Test follows', function () {
await completeVideoCheck(servers[ 0 ].url, video4, checkAttributes) 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) const res1 = await getVideoCommentThreads(servers[0].url, video4.id, 0, 5)
expect(res1.body.total).to.equal(1) expect(res1.body.total).to.equal(1)
@ -353,6 +365,18 @@ describe('Test follows', function () {
expect(secondChild.children).to.have.lengthOf(0) 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 () { it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
this.timeout(5000) this.timeout(5000)

View File

@ -0,0 +1,139 @@
/* tslint:disable:no-unused-expression */
import * as chai from 'chai'
import 'mocha'
import { doubleFollow, flushAndRunMultipleServers, uploadVideo } from '../../utils'
import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
import { waitJobs } from '../../utils/server/jobs'
import { createVideoCaption, deleteVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
const expect = chai.expect
describe('Test video captions', function () {
let servers: ServerInfo[]
let videoUUID: string
before(async function () {
this.timeout(30000)
await flushTests()
servers = await flushAndRunMultipleServers(2)
await setAccessTokensToServers(servers)
await doubleFollow(servers[0], servers[1])
await waitJobs(servers)
const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'my video name' })
videoUUID = res.body.video.uuid
await waitJobs(servers)
})
it('Should list the captions and return an empty list', async function () {
for (const server of servers) {
const res = await listVideoCaptions(server.url, videoUUID)
expect(res.body.total).to.equal(0)
expect(res.body.data).to.have.lengthOf(0)
}
})
it('Should create two new captions', async function () {
this.timeout(30000)
await createVideoCaption({
url: servers[0].url,
accessToken: servers[0].accessToken,
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good1.vtt'
})
await createVideoCaption({
url: servers[0].url,
accessToken: servers[0].accessToken,
language: 'zh',
videoId: videoUUID,
fixture: 'subtitle-good2.vtt'
})
await waitJobs(servers)
})
it('Should list these uploaded captions', async function () {
for (const server of servers) {
const res = await listVideoCaptions(server.url, videoUUID)
expect(res.body.total).to.equal(2)
expect(res.body.data).to.have.lengthOf(2)
const caption1: VideoCaption = res.body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.')
const caption2: VideoCaption = res.body.data[1]
expect(caption2.language.id).to.equal('zh')
expect(caption2.language.label).to.equal('Chinese')
expect(caption2.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.')
}
})
it('Should replace an existing caption', async function () {
this.timeout(30000)
await createVideoCaption({
url: servers[0].url,
accessToken: servers[0].accessToken,
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good2.vtt'
})
await waitJobs(servers)
})
it('Should have this caption updated', async function () {
for (const server of servers) {
const res = await listVideoCaptions(server.url, videoUUID)
expect(res.body.total).to.equal(2)
expect(res.body.data).to.have.lengthOf(2)
const caption1: VideoCaption = res.body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.')
}
})
it('Should remove one caption', async function () {
this.timeout(30000)
await deleteVideoCaption(servers[0].url, servers[0].accessToken, videoUUID, 'ar')
await waitJobs(servers)
})
it('Should only list the caption that was not deleted', async function () {
for (const server of servers) {
const res = await listVideoCaptions(server.url, videoUUID)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.have.lengthOf(1)
const caption: VideoCaption = res.body.data[0]
expect(caption.language.id).to.equal('zh')
expect(caption.language.label).to.equal('Chinese')
expect(caption.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.')
}
})
after(async function () {
killallServers(servers)
})
})

View File

@ -0,0 +1,8 @@
WEBVTT
00:01.000 --> 00:04.000
Subtitle good 1.
00:05.000 --> 00:09.000
- It will perforate your stomach.
- You could die.

View File

@ -0,0 +1,8 @@
WEBVTT
00:01.000 --> 00:04.000
Subtitle good 2.
00:05.000 --> 00:09.000
- It will perforate your stomach.
- You could die.

View File

@ -5,7 +5,6 @@ import { isAbsolute, join } from 'path'
import * as request from 'supertest' import * as request from 'supertest'
import * as WebTorrent from 'webtorrent' import * as WebTorrent from 'webtorrent'
import { readFileBufferPromise } from '../../../helpers/core-utils' import { readFileBufferPromise } from '../../../helpers/core-utils'
import { ServerInfo } from '..'
const expect = chai.expect const expect = chai.expect
let webtorrent = new WebTorrent() let webtorrent = new WebTorrent()

View File

@ -0,0 +1,66 @@
import { makeDeleteRequest, makeGetRequest } from '../'
import { buildAbsoluteFixturePath, makeUploadRequest } from '../index'
import * as request from 'supertest'
import * as chai from 'chai'
const expect = chai.expect
function createVideoCaption (args: {
url: string,
accessToken: string
videoId: string | number
language: string
fixture: string
}) {
const path = '/api/v1/videos/' + args.videoId + '/captions/' + args.language
return makeUploadRequest({
method: 'PUT',
url: args.url,
path,
token: args.accessToken,
fields: {},
attaches: {
captionfile: buildAbsoluteFixturePath(args.fixture)
},
statusCodeExpected: 204
})
}
function listVideoCaptions (url: string, videoId: string | number) {
const path = '/api/v1/videos/' + videoId + '/captions'
return makeGetRequest({
url,
path,
statusCodeExpected: 200
})
}
function deleteVideoCaption (url: string, token: string, videoId: string | number, language: string) {
const path = '/api/v1/videos/' + videoId + '/captions/' + language
return makeDeleteRequest({
url,
token,
path,
statusCodeExpected: 204
})
}
async function testCaptionFile (url: string, captionPath: string, containsString: string) {
const res = await request(url)
.get(captionPath)
.expect(200)
expect(res.text).to.contain(containsString)
}
// ---------------------------------------------------------------------------
export {
createVideoCaption,
listVideoCaptions,
testCaptionFile,
deleteVideoCaption
}

View File

@ -17,6 +17,7 @@ export interface VideoTorrentObject {
category: ActivityIdentifierObject category: ActivityIdentifierObject
licence: ActivityIdentifierObject licence: ActivityIdentifierObject
language: ActivityIdentifierObject language: ActivityIdentifierObject
subtitleLanguage: ActivityIdentifierObject[]
views: number views: number
sensitive: boolean sensitive: boolean
commentsEnabled: boolean commentsEnabled: boolean

View File

@ -25,6 +25,10 @@ export interface CustomConfig {
previews: { previews: {
size: number size: number
} }
captions: {
size: number
}
} }
signup: { signup: {

View File

@ -44,6 +44,15 @@ export interface ServerConfig {
} }
} }
videoCaption: {
file: {
size: {
max: number
},
extensions: string[]
}
}
user: { user: {
videoQuota: number videoQuota: number
} }

View File

@ -14,3 +14,5 @@ export * from './video-resolution.enum'
export * from './video-update.model' export * from './video-update.model'
export * from './video.model' export * from './video.model'
export * from './video-state.enum' export * from './video-state.enum'
export * from './video-caption-update.model'
export { VideoConstant } from './video-constant.model'

View File

@ -0,0 +1,4 @@
export interface VideoCaptionUpdate {
language: string
captionfile: Blob
}

View File

@ -0,0 +1,6 @@
import { VideoConstant } from './video-constant.model'
export interface VideoCaption {
language: VideoConstant<string>
captionPath: string
}

View File

@ -0,0 +1,4 @@
export interface VideoConstant<T> {
id: T
label: string
}

View File

@ -4,11 +4,7 @@ import { Avatar } from '../avatars/avatar.model'
import { VideoChannel } from './video-channel.model' import { VideoChannel } from './video-channel.model'
import { VideoPrivacy } from './video-privacy.enum' import { VideoPrivacy } from './video-privacy.enum'
import { VideoScheduleUpdate } from './video-schedule-update.model' import { VideoScheduleUpdate } from './video-schedule-update.model'
import { VideoConstant } from './video-constant.model'
export interface VideoConstant <T> {
id: T
label: string
}
export interface VideoFile { export interface VideoFile {
magnetUri: string magnetUri: string

View File

@ -38,6 +38,7 @@ storage:
previews: '../data/previews/' previews: '../data/previews/'
thumbnails: '../data/thumbnails/' thumbnails: '../data/thumbnails/'
torrents: '../data/torrents/' torrents: '../data/torrents/'
captions: '../data/captions/'
cache: '../data/cache/' cache: '../data/cache/'
log: log: