Implement captions/subtitles
This commit is contained in:
parent
d4557fd3ec
commit
40e87e9ecc
|
@ -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">
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -59,6 +59,12 @@ export class ServerService {
|
|||
extensions: []
|
||||
}
|
||||
},
|
||||
videoCaption: {
|
||||
file: {
|
||||
size: { max: 0 },
|
||||
extensions: []
|
||||
}
|
||||
},
|
||||
user: {
|
||||
videoQuota: -1
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
export * from './form-validators'
|
||||
export * from './form-reactive'
|
||||
export * from './reactive-file.component'
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './video-caption.service'
|
|
@ -0,0 +1,9 @@
|
|||
export interface VideoCaptionEdit {
|
||||
language: {
|
||||
id: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
action?: 'CREATE' | 'REMOVE'
|
||||
captionfile?: any
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 */ }
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -16,6 +16,7 @@ storage:
|
|||
previews: 'test1/previews/'
|
||||
thumbnails: 'test1/thumbnails/'
|
||||
torrents: 'test1/torrents/'
|
||||
captions: 'test1/captions/'
|
||||
cache: 'test1/cache/'
|
||||
|
||||
admin:
|
||||
|
|
|
@ -16,6 +16,7 @@ storage:
|
|||
previews: 'test2/previews/'
|
||||
thumbnails: 'test2/thumbnails/'
|
||||
torrents: 'test2/torrents/'
|
||||
captions: 'test2/captions/'
|
||||
cache: 'test2/cache/'
|
||||
|
||||
admin:
|
||||
|
|
|
@ -16,6 +16,7 @@ storage:
|
|||
previews: 'test3/previews/'
|
||||
thumbnails: 'test3/thumbnails/'
|
||||
torrents: 'test3/torrents/'
|
||||
captions: 'test3/captions/'
|
||||
cache: 'test3/cache/'
|
||||
|
||||
admin:
|
||||
|
|
|
@ -16,6 +16,7 @@ storage:
|
|||
previews: 'test4/previews/'
|
||||
thumbnails: 'test4/thumbnails/'
|
||||
torrents: 'test4/torrents/'
|
||||
captions: 'test4/captions/'
|
||||
cache: 'test4/cache/'
|
||||
|
||||
admin:
|
||||
|
|
|
@ -16,6 +16,7 @@ storage:
|
|||
previews: 'test5/previews/'
|
||||
thumbnails: 'test5/thumbnails/'
|
||||
torrents: 'test5/torrents/'
|
||||
captions: 'test5/captions/'
|
||||
cache: 'test5/cache/'
|
||||
|
||||
admin:
|
||||
|
|
|
@ -16,6 +16,7 @@ storage:
|
|||
previews: 'test6/previews/'
|
||||
thumbnails: 'test6/thumbnails/'
|
||||
torrents: 'test6/torrents/'
|
||||
captions: 'test6/captions/'
|
||||
cache: 'test6/cache/'
|
||||
|
||||
admin:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -35,6 +35,9 @@ describe('Test config API validators', function () {
|
|||
cache: {
|
||||
previews: {
|
||||
size: 2
|
||||
},
|
||||
captions: {
|
||||
size: 3
|
||||
}
|
||||
},
|
||||
signup: {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
|
@ -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'
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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.
|
|
@ -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.
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -17,6 +17,7 @@ export interface VideoTorrentObject {
|
|||
category: ActivityIdentifierObject
|
||||
licence: ActivityIdentifierObject
|
||||
language: ActivityIdentifierObject
|
||||
subtitleLanguage: ActivityIdentifierObject[]
|
||||
views: number
|
||||
sensitive: boolean
|
||||
commentsEnabled: boolean
|
||||
|
|
|
@ -25,6 +25,10 @@ export interface CustomConfig {
|
|||
previews: {
|
||||
size: number
|
||||
}
|
||||
|
||||
captions: {
|
||||
size: number
|
||||
}
|
||||
}
|
||||
|
||||
signup: {
|
||||
|
|
|
@ -44,6 +44,15 @@ export interface ServerConfig {
|
|||
}
|
||||
}
|
||||
|
||||
videoCaption: {
|
||||
file: {
|
||||
size: {
|
||||
max: number
|
||||
},
|
||||
extensions: string[]
|
||||
}
|
||||
}
|
||||
|
||||
user: {
|
||||
videoQuota: number
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export interface VideoCaptionUpdate {
|
||||
language: string
|
||||
captionfile: Blob
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { VideoConstant } from './video-constant.model'
|
||||
|
||||
export interface VideoCaption {
|
||||
language: VideoConstant<string>
|
||||
captionPath: string
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export interface VideoConstant<T> {
|
||||
id: T
|
||||
label: string
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -38,6 +38,7 @@ storage:
|
|||
previews: '../data/previews/'
|
||||
thumbnails: '../data/thumbnails/'
|
||||
torrents: '../data/torrents/'
|
||||
captions: '../data/captions/'
|
||||
cache: '../data/cache/'
|
||||
|
||||
log:
|
||||
|
|
Loading…
Reference in New Issue