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>
</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">
<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
type="text" id="cachePreviewsSize"
formControlName="cachePreviewsSize" [ngClass]="{ 'input-error': formErrors['cachePreviewsSize'] }"
@ -224,6 +226,17 @@ Check this checkbox, save the configuration and test with a video URL of your in
</div>
</div>
<div class="form-group">
<label i18n for="cachePreviewsSize">Video captions cache size</label>
<input
type="text" id="cacheCaptionsSize"
formControlName="cacheCaptionsSize" [ngClass]="{ 'input-error': formErrors['cacheCaptionsSize'] }"
>
<div *ngIf="formErrors.cacheCaptionsSize" class="form-error">
{{ formErrors.cacheCaptionsSize }}
</div>
</div>
<div i18n class="inner-form-title">Customizations</div>
<div class="form-group">

View File

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

View File

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

View File

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

View File

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

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-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)) {
objectToFormData(obj[ key ], fd, key)
objectToFormData(obj[ key ], fd, formKey)
} else {
fd.append(formKey, obj[ key ])
}
@ -96,6 +96,11 @@ function lineFeedToHtml (obj: object, keyToNormalize: string) {
})
}
function removeElementFromArray <T> (arr: T[], elem: T) {
const index = arr.indexOf(elem)
if (index !== -1) arr.splice(index, 1)
}
export {
objectToUrlEncoded,
getParameterByName,
@ -104,5 +109,6 @@ export {
dateToHuman,
immutableAssign,
objectToFormData,
lineFeedToHtml
lineFeedToHtml,
removeElementFromArray
}

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

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

View File

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

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>
<my-help
tooltipPlacement="top" helpType="custom" i18n-customHtml
customHtml="If you decide to not wait transcoding before publishing the video, it can be unplayable until it transcoding ends."
customHtml="If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends."
></my-help>
</div>
</div>
</tab>
<tab i18n-heading heading="Captions">
<div class="col-md-12 captions">
<div class="captions-header">
<a (click)="openAddCaptionModal()" class="create-caption">
<span class="icon icon-add"></span>
<ng-container i18n>Add another caption</ng-container>
</a>
</div>
<div class="form-group" *ngFor="let videoCaption of videoCaptions">
<div class="caption-entry">
<div class="caption-entry-label">{{ videoCaption.language.label }}</div>
<span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span>
</div>
</div>
<div class="no-caption" *ngIf="videoCaptions?.length === 0">
No captions for now.
</div>
</div>
</tab>
<tab i18n-heading heading="Advanced settings">
<div class="col-md-12 advanced-settings">
<div class="form-group">
@ -172,3 +198,7 @@
</tabset>
</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 {
height: 100%;
min-height: 300px;
.form-group {
margin-bottom: 25px;
@ -49,6 +50,40 @@
}
}
.captions {
.captions-header {
text-align: right;
.create-caption {
@include create-button('../../../../assets/images/global/add.svg');
}
}
.caption-entry {
display: flex;
height: 40px;
align-items: center;
.caption-entry-label {
font-size: 15px;
font-weight: bold;
margin-right: 20px;
}
.caption-entry-delete {
@include peertube-button;
@include grey-button;
}
}
.no-caption {
text-align: center;
font-size: 15px;
}
}
.submit-container {
text-align: right;

View File

@ -1,5 +1,5 @@
import { Component, Input, OnInit } from '@angular/core'
import { FormGroup, ValidatorFn, Validators } from '@angular/forms'
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared'
import { NotificationsService } from 'angular2-notifications'
@ -8,6 +8,10 @@ import { VideoEdit } from '../../../shared/video/video-edit.model'
import { map } from 'rxjs/operators'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
import { VideoCaptionService } from '@app/shared/video-caption'
import { VideoCaptionAddModalComponent } from '@app/videos/+video-edit/shared/video-caption-add-modal.component'
import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
import { removeElementFromArray } from '@app/shared/misc/utils'
@Component({
selector: 'my-video-edit',
@ -15,13 +19,16 @@ import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calend
templateUrl: './video-edit.component.html'
})
export class VideoEditComponent implements OnInit {
export class VideoEditComponent implements OnInit, OnDestroy {
@Input() form: FormGroup
@Input() formErrors: { [ id: string ]: string } = {}
@Input() validationMessages: FormReactiveValidationMessages = {}
@Input() videoPrivacies = []
@Input() userVideoChannels: { id: number, label: string, support: string }[] = []
@Input() schedulePublicationPossible = true
@Input() videoCaptions: VideoCaptionEdit[] = []
@ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent
// So that it can be accessed in the template
readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
@ -41,9 +48,12 @@ export class VideoEditComponent implements OnInit {
calendarTimezone: string
calendarDateFormat: string
private schedulerInterval
constructor (
private formValidatorService: FormValidatorService,
private videoValidatorsService: VideoValidatorsService,
private videoCaptionService: VideoCaptionService,
private route: ActivatedRoute,
private router: Router,
private notificationsService: NotificationsService,
@ -91,6 +101,13 @@ export class VideoEditComponent implements OnInit {
defaultValues
)
this.form.addControl('captions', new FormArray([
new FormGroup({
language: new FormControl(),
captionfile: new FormControl()
})
]))
this.trackChannelChange()
this.trackPrivacyChange()
}
@ -102,7 +119,35 @@ export class VideoEditComponent implements OnInit {
this.videoLicences = this.serverService.getVideoLicences()
this.videoLanguages = this.serverService.getVideoLanguages()
setTimeout(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
}
ngOnDestroy () {
if (this.schedulerInterval) clearInterval(this.schedulerInterval)
}
getExistingCaptions () {
return this.videoCaptions.map(c => c.language.id)
}
onCaptionAdded (caption: VideoCaptionEdit) {
this.videoCaptions.push(
Object.assign(caption, { action: 'CREATE' as 'CREATE' })
)
}
deleteCaption (caption: VideoCaptionEdit) {
// This caption is not on the server, just remove it from our array
if (caption.action === 'CREATE') {
removeElementFromArray(this.videoCaptions, caption)
return
}
caption.action = 'REMOVE' as 'REMOVE'
}
openAddCaptionModal () {
this.videoCaptionAddModal.show()
}
private trackPrivacyChange () {

View File

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

View File

@ -1,15 +1,8 @@
<div class="root">
<div>
<div class="button-file">
<span>{{ inputLabel }}</span>
<input
type="file"
[name]="inputName" [id]="inputName" [accept]="videoImageExtensions"
(change)="fileChange($event)"
/>
</div>
<div i18n class="image-constraints">(extensions: {{ videoImageExtensions }}, max size: {{ maxVideoImageSize | bytes }})</div>
</div>
<my-reactive-file
[inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
(fileChanged)="onFileChanged($event)"
></my-reactive-file>
<img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
<div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>

View File

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

View File

@ -2,8 +2,6 @@ import { Component, forwardRef, Input } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
import { ServerService } from '@app/core'
import { NotificationsService } from 'angular2-notifications'
import { I18n } from '@ngx-translate/i18n-polyfill'
@Component({
selector: 'my-video-image',
@ -25,36 +23,26 @@ export class VideoImageComponent implements ControlValueAccessor {
imageSrc: SafeResourceUrl
private file: Blob
private file: File
constructor (
private sanitizer: DomSanitizer,
private serverService: ServerService,
private notificationsService: NotificationsService,
private i18n: I18n
private serverService: ServerService
) {}
get videoImageExtensions () {
return this.serverService.getConfig().video.image.extensions.join(',')
return this.serverService.getConfig().video.image.extensions
}
get maxVideoImageSize () {
return this.serverService.getConfig().video.image.size.max
}
fileChange (event: any) {
if (event.target.files && event.target.files.length) {
const [ file ] = event.target.files
onFileChanged (file: File) {
this.file = file
if (file.size > this.maxVideoImageSize) {
this.notificationsService.error(this.i18n('Error'), this.i18n('This image is too large.'))
return
}
this.file = file
this.propagateChange(this.file)
this.updatePreview()
}
this.propagateChange(this.file)
this.updatePreview()
}
propagateChange = (_: any) => { /* empty */ }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,6 +25,8 @@ import {
getVideoLikesActivityPubUrl,
getVideoSharesActivityPubUrl
} from '../../lib/activitypub'
import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
import { VideoCaptionModel } from '../../models/video/video-caption'
const activityPubClientRouter = express.Router()
@ -123,6 +125,9 @@ async function accountFollowingController (req: express.Request, res: express.Re
async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) {
const video: VideoModel = res.locals.video
// We need captions to render AP object
video.VideoCaptions = await VideoCaptionModel.listVideoCaptions(video.id)
const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
const videoObject = audiencify(video.toActivityPubObject(), audience)

View File

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

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 { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { videoCaptionsRouter } from './captions'
const videosRouter = express.Router()
@ -78,6 +79,7 @@ videosRouter.use('/', abuseVideoRouter)
videosRouter.use('/', blacklistRouter)
videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoCommentRouter)
videosRouter.use('/', videoCaptionsRouter)
videosRouter.get('/categories', listVideoCategories)
videosRouter.get('/licences', listVideoLicences)

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { CONFIG, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../
import { VideosPreviewCache } from '../lib/cache'
import { asyncMiddleware, videosGetValidator } from '../middlewares'
import { VideoModel } from '../models/video/video'
import { VideosCaptionCache } from '../lib/cache/videos-caption-cache'
const staticRouter = express.Router()
@ -49,12 +50,18 @@ staticRouter.use(
express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE })
)
// Video previews path for express
// We don't have video previews, fetch them from the origin instance
staticRouter.use(
STATIC_PATHS.PREVIEWS + ':uuid.jpg',
asyncMiddleware(getPreview)
)
// We don't have video captions, fetch them from the origin instance
staticRouter.use(
STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt',
asyncMiddleware(getVideoCaption)
)
// robots.txt service
staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => {
res.type('text/plain')
@ -70,7 +77,17 @@ export {
// ---------------------------------------------------------------------------
async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) {
const path = await VideosPreviewCache.Instance.getPreviewPath(req.params.uuid)
const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
if (!path) return res.sendStatus(404)
return res.sendFile(path, { maxAge: STATIC_MAX_AGE })
}
async function getVideoCaption (req: express.Request, res: express.Response) {
const path = await VideosCaptionCache.Instance.getFilePath({
videoId: req.params.videoId,
language: req.params.captionLanguage
})
if (!path) return res.sendStatus(404)
return res.sendFile(path, { maxAge: STATIC_MAX_AGE })

View File

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

View File

@ -51,6 +51,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
if (!setValidRemoteVideoUrls(video)) return false
if (!setRemoteVideoTruncatedContent(video)) return false
if (!setValidAttributedTo(video)) return false
if (!setValidRemoteCaptions(video)) return false
// Default attributes
if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
@ -98,6 +99,18 @@ function setValidRemoteTags (video: any) {
return true
}
function setValidRemoteCaptions (video: any) {
if (!video.subtitleLanguage) video.subtitleLanguage = []
if (Array.isArray(video.subtitleLanguage) === false) return false
video.subtitleLanguage = video.subtitleLanguage.filter(caption => {
return isRemoteStringIdentifierValid(caption)
})
return true
}
function isRemoteNumberIdentifierValid (data: any) {
return validator.isInt(data.identifier, { min: 0 })
}

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)
}
function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: Response) {
// Retrieve the user who did the request
if (video.isOwned() === false) {
res.status(403)
.json({ error: 'Cannot manage a video of another server.' })
.end()
return false
}
// Check if the user can delete the video
// The user can delete it if he has the right
// Or if s/he is the video's account
const account = video.VideoChannel.Account
if (user.hasRight(right) === false && account.userId !== user.id) {
res.status(403)
.json({ error: 'Cannot manage a video of another user.' })
.end()
return false
}
return true
}
async function isVideoExist (id: string, res: Response) {
let video: VideoModel
@ -179,6 +202,7 @@ async function isVideoChannelOfAccountExist (channelId: number, user: UserModel,
export {
isVideoCategoryValid,
checkUserCanManageVideo,
isVideoLicenceValid,
isVideoLanguageValid,
isVideoTruncatedDescriptionValid,

View File

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

View File

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

View File

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

View File

@ -24,10 +24,20 @@ import { addVideoComments } from './video-comments'
import { crawlCollectionPage } from './crawl'
import { sendCreateVideo, sendUpdateVideo } from './send'
import { shareVideoByServerAndChannel } from './index'
import { isArray } from '../../helpers/custom-validators/misc'
import { VideoCaptionModel } from '../../models/video/video-caption'
async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
// If the video is not private and published, we federate it
if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
// Fetch more attributes that we will need to serialize in AP object
if (isArray(video.VideoCaptions) === false) {
video.VideoCaptions = await video.$get('VideoCaptions', {
attributes: [ 'language' ],
transaction
}) as VideoCaptionModel[]
}
if (isNewVideo === true) {
// Now we'll add the video's meta data to our followers
await sendCreateVideo(video, transaction)
@ -38,9 +48,8 @@ async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, tr
}
}
function fetchRemoteVideoPreview (video: VideoModel, reject: Function) {
function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
const host = video.VideoChannel.Account.Actor.Server.host
const path = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
// We need to provide a callback, if no we could have an uncaught exception
return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
@ -179,24 +188,32 @@ async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor:
const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
const video = VideoModel.build(videoData)
// Don't block on request
// Don't block on remote HTTP request (we are in a transaction!)
generateThumbnailFromUrl(video, videoObject.icon)
.catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
const videoCreated = await video.save(sequelizeOptions)
// Process files
const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
if (videoFileAttributes.length === 0) {
throw new Error('Cannot find valid files for video %s ' + videoObject.url)
}
const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
await Promise.all(tasks)
const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
await Promise.all(videoFilePromises)
// Process tags
const tags = videoObject.tag.map(t => t.name)
const tagInstances = await TagModel.findOrCreateTags(tags, t)
await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
// Process captions
const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
})
await Promise.all(videoCaptionsPromises)
logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
videoCreated.VideoChannel = channelActor.VideoChannel
@ -328,7 +345,7 @@ export {
federateVideoIfNeeded,
fetchRemoteVideo,
getOrCreateAccountAndVideoAndChannel,
fetchRemoteVideoPreview,
fetchRemoteVideoStaticFile,
fetchRemoteVideoDescription,
generateThumbnailFromUrl,
videoActivityObjectToDBAttributes,

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 { unlinkPromise } from '../../helpers/core-utils'
import { logger } from '../../helpers/logger'
import { CACHE, CONFIG } from '../../initializers'
import { CACHE, CONFIG, STATIC_PATHS } from '../../initializers'
import { VideoModel } from '../../models/video/video'
import { fetchRemoteVideoPreview } from '../activitypub'
import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
class VideosPreviewCache {
class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
private static instance: VideosPreviewCache
private lru
private constructor () { }
private constructor () {
super()
}
static get Instance () {
return this.instance || (this.instance = new this())
}
init (max: number) {
this.lru = new asyncLRU({
max,
load: (key, cb) => {
this.loadPreviews(key)
.then(res => cb(null, res))
.catch(err => cb(err))
}
})
this.lru.on('evict', (obj: { key: string, value: string }) => {
unlinkPromise(obj.value).then(() => logger.debug('%s evicted from VideosPreviewCache', obj.value))
})
}
async getPreviewPath (key: string) {
const video = await VideoModel.loadByUUID(key)
async getFilePath (videoUUID: string) {
const video = await VideoModel.loadByUUID(videoUUID)
if (!video) return undefined
if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
return new Promise<string>((res, rej) => {
this.lru.get(key, (err, value) => {
err ? rej(err) : res(value)
})
})
return this.loadFromLRU(videoUUID)
}
private async loadPreviews (key: string) {
protected async loadRemoteFile (key: string) {
const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
if (!video) return undefined
if (video.isOwned()) throw new Error('Cannot load preview of owned video.')
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 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))
})
return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
}
}

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

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

View File

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

View File

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

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 './videos/single-server'
import './videos/video-abuse'
import './videos/video-captions'
import './videos/video-blacklist'
import './videos/video-blacklist-management'
import './videos/video-description'

View File

@ -14,6 +14,61 @@ import {
registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig
} from '../../utils/index'
function checkInitialConfig (data: CustomConfig) {
expect(data.instance.name).to.equal('PeerTube')
expect(data.instance.shortDescription).to.equal(
'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
'with WebTorrent and Angular.'
)
expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
expect(data.instance.terms).to.equal('No terms for now.')
expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
expect(data.instance.defaultNSFWPolicy).to.equal('display')
expect(data.instance.customizations.css).to.be.empty
expect(data.instance.customizations.javascript).to.be.empty
expect(data.services.twitter.username).to.equal('@Chocobozzz')
expect(data.services.twitter.whitelisted).to.be.false
expect(data.cache.previews.size).to.equal(1)
expect(data.cache.captions.size).to.equal(1)
expect(data.signup.enabled).to.be.true
expect(data.signup.limit).to.equal(4)
expect(data.admin.email).to.equal('admin1@example.com')
expect(data.user.videoQuota).to.equal(5242880)
expect(data.transcoding.enabled).to.be.false
expect(data.transcoding.threads).to.equal(2)
expect(data.transcoding.resolutions['240p']).to.be.true
expect(data.transcoding.resolutions['360p']).to.be.true
expect(data.transcoding.resolutions['480p']).to.be.true
expect(data.transcoding.resolutions['720p']).to.be.true
expect(data.transcoding.resolutions['1080p']).to.be.true
}
function checkUpdatedConfig (data: CustomConfig) {
expect(data.instance.name).to.equal('PeerTube updated')
expect(data.instance.shortDescription).to.equal('my short description')
expect(data.instance.description).to.equal('my super description')
expect(data.instance.terms).to.equal('my super terms')
expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
expect(data.instance.defaultNSFWPolicy).to.equal('blur')
expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
expect(data.services.twitter.username).to.equal('@Kuja')
expect(data.services.twitter.whitelisted).to.be.true
expect(data.cache.previews.size).to.equal(2)
expect(data.cache.captions.size).to.equal(3)
expect(data.signup.enabled).to.be.false
expect(data.signup.limit).to.equal(5)
expect(data.admin.email).to.equal('superadmin1@example.com')
expect(data.user.videoQuota).to.equal(5242881)
expect(data.transcoding.enabled).to.be.true
expect(data.transcoding.threads).to.equal(1)
expect(data.transcoding.resolutions['240p']).to.be.false
expect(data.transcoding.resolutions['360p']).to.be.true
expect(data.transcoding.resolutions['480p']).to.be.true
expect(data.transcoding.resolutions['720p']).to.be.false
expect(data.transcoding.resolutions['1080p']).to.be.false
}
describe('Test config', function () {
let server = null
@ -51,35 +106,11 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body as CustomConfig
expect(data.instance.name).to.equal('PeerTube')
expect(data.instance.shortDescription).to.equal(
'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
'with WebTorrent and Angular.'
)
expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
expect(data.instance.terms).to.equal('No terms for now.')
expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
expect(data.instance.defaultNSFWPolicy).to.equal('display')
expect(data.instance.customizations.css).to.be.empty
expect(data.instance.customizations.javascript).to.be.empty
expect(data.services.twitter.username).to.equal('@Chocobozzz')
expect(data.services.twitter.whitelisted).to.be.false
expect(data.cache.previews.size).to.equal(1)
expect(data.signup.enabled).to.be.true
expect(data.signup.limit).to.equal(4)
expect(data.admin.email).to.equal('admin1@example.com')
expect(data.user.videoQuota).to.equal(5242880)
expect(data.transcoding.enabled).to.be.false
expect(data.transcoding.threads).to.equal(2)
expect(data.transcoding.resolutions['240p']).to.be.true
expect(data.transcoding.resolutions['360p']).to.be.true
expect(data.transcoding.resolutions['480p']).to.be.true
expect(data.transcoding.resolutions['720p']).to.be.true
expect(data.transcoding.resolutions['1080p']).to.be.true
checkInitialConfig(data)
})
it('Should update the customized configuration', async function () {
const newCustomConfig = {
const newCustomConfig: CustomConfig = {
instance: {
name: 'PeerTube updated',
shortDescription: 'my short description',
@ -101,6 +132,9 @@ describe('Test config', function () {
cache: {
previews: {
size: 2
},
captions: {
size: 3
}
},
signup: {
@ -130,28 +164,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body
expect(data.instance.name).to.equal('PeerTube updated')
expect(data.instance.shortDescription).to.equal('my short description')
expect(data.instance.description).to.equal('my super description')
expect(data.instance.terms).to.equal('my super terms')
expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
expect(data.instance.defaultNSFWPolicy).to.equal('blur')
expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
expect(data.services.twitter.username).to.equal('@Kuja')
expect(data.services.twitter.whitelisted).to.be.true
expect(data.cache.previews.size).to.equal(2)
expect(data.signup.enabled).to.be.false
expect(data.signup.limit).to.equal(5)
expect(data.admin.email).to.equal('superadmin1@example.com')
expect(data.user.videoQuota).to.equal(5242881)
expect(data.transcoding.enabled).to.be.true
expect(data.transcoding.threads).to.equal(1)
expect(data.transcoding.resolutions['240p']).to.be.false
expect(data.transcoding.resolutions['360p']).to.be.true
expect(data.transcoding.resolutions['480p']).to.be.true
expect(data.transcoding.resolutions['720p']).to.be.false
expect(data.transcoding.resolutions['1080p']).to.be.false
checkUpdatedConfig(data)
})
it('Should have the configuration updated after a restart', async function () {
@ -164,28 +177,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body
expect(data.instance.name).to.equal('PeerTube updated')
expect(data.instance.shortDescription).to.equal('my short description')
expect(data.instance.description).to.equal('my super description')
expect(data.instance.terms).to.equal('my super terms')
expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
expect(data.instance.defaultNSFWPolicy).to.equal('blur')
expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
expect(data.services.twitter.username).to.equal('@Kuja')
expect(data.services.twitter.whitelisted).to.be.true
expect(data.cache.previews.size).to.equal(2)
expect(data.signup.enabled).to.be.false
expect(data.signup.limit).to.equal(5)
expect(data.admin.email).to.equal('superadmin1@example.com')
expect(data.user.videoQuota).to.equal(5242881)
expect(data.transcoding.enabled).to.be.true
expect(data.transcoding.threads).to.equal(1)
expect(data.transcoding.resolutions['240p']).to.be.false
expect(data.transcoding.resolutions['360p']).to.be.true
expect(data.transcoding.resolutions['480p']).to.be.true
expect(data.transcoding.resolutions['720p']).to.be.false
expect(data.transcoding.resolutions['1080p']).to.be.false
checkUpdatedConfig(data)
})
it('Should fetch the about information', async function () {
@ -206,31 +198,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body
expect(data.instance.name).to.equal('PeerTube')
expect(data.instance.shortDescription).to.equal(
'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
'with WebTorrent and Angular.'
)
expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
expect(data.instance.terms).to.equal('No terms for now.')
expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
expect(data.instance.defaultNSFWPolicy).to.equal('display')
expect(data.instance.customizations.css).to.be.empty
expect(data.instance.customizations.javascript).to.be.empty
expect(data.services.twitter.username).to.equal('@Chocobozzz')
expect(data.services.twitter.whitelisted).to.be.false
expect(data.cache.previews.size).to.equal(1)
expect(data.signup.enabled).to.be.true
expect(data.signup.limit).to.equal(4)
expect(data.admin.email).to.equal('admin1@example.com')
expect(data.user.videoQuota).to.equal(5242880)
expect(data.transcoding.enabled).to.be.false
expect(data.transcoding.threads).to.equal(2)
expect(data.transcoding.resolutions['240p']).to.be.true
expect(data.transcoding.resolutions['360p']).to.be.true
expect(data.transcoding.resolutions['480p']).to.be.true
expect(data.transcoding.resolutions['720p']).to.be.true
expect(data.transcoding.resolutions['1080p']).to.be.true
checkInitialConfig(data)
})
after(async function () {

View File

@ -26,6 +26,8 @@ import {
} from '../../utils/videos/video-comments'
import { rateVideo } from '../../utils/videos/videos'
import { waitJobs } from '../../utils/server/jobs'
import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
const expect = chai.expect
@ -244,6 +246,16 @@ describe('Test follows', function () {
const text3 = 'my second answer to thread 1'
await addVideoCommentReply(servers[ 2 ].url, servers[ 2 ].accessToken, video4.id, threadId, text3)
}
{
await createVideoCaption({
url: servers[2].url,
accessToken: servers[2].accessToken,
language: 'ar',
videoId: video4.id,
fixture: 'subtitle-good2.vtt'
})
}
}
await waitJobs(servers)
@ -266,7 +278,7 @@ describe('Test follows', function () {
await expectAccountFollows(servers[2].url, 'peertube@localhost:9003', 1, 0)
})
it('Should propagate videos', async function () {
it('Should have propagated videos', async function () {
const res = await getVideosList(servers[ 0 ].url)
expect(res.body.total).to.equal(7)
@ -314,7 +326,7 @@ describe('Test follows', function () {
await completeVideoCheck(servers[ 0 ].url, video4, checkAttributes)
})
it('Should propagate comments', async function () {
it('Should have propagated comments', async function () {
const res1 = await getVideoCommentThreads(servers[0].url, video4.id, 0, 5)
expect(res1.body.total).to.equal(1)
@ -353,6 +365,18 @@ describe('Test follows', function () {
expect(secondChild.children).to.have.lengthOf(0)
})
it('Should have propagated captions', async function () {
const res = await listVideoCaptions(servers[0].url, video4.id)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.have.lengthOf(1)
const caption1: VideoCaption = res.body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.equal('/static/video-captions/' + video4.uuid + '-ar.vtt')
await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.')
})
it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
this.timeout(5000)

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 WebTorrent from 'webtorrent'
import { readFileBufferPromise } from '../../../helpers/core-utils'
import { ServerInfo } from '..'
const expect = chai.expect
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
licence: ActivityIdentifierObject
language: ActivityIdentifierObject
subtitleLanguage: ActivityIdentifierObject[]
views: number
sensitive: boolean
commentsEnabled: boolean

View File

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

View File

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

View File

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

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 { VideoPrivacy } from './video-privacy.enum'
import { VideoScheduleUpdate } from './video-schedule-update.model'
export interface VideoConstant <T> {
id: T
label: string
}
import { VideoConstant } from './video-constant.model'
export interface VideoFile {
magnetUri: string

View File

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