aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html21
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts4
-rw-r--r--client/src/app/core/server/server.service.ts6
-rw-r--r--client/src/app/shared/forms/form-validators/custom-config-validators.service.ts10
-rw-r--r--client/src/app/shared/forms/form-validators/index.ts1
-rw-r--r--client/src/app/shared/forms/form-validators/video-captions-validators.service.ts27
-rw-r--r--client/src/app/shared/forms/index.ts1
-rw-r--r--client/src/app/shared/forms/reactive-file.component.html14
-rw-r--r--client/src/app/shared/forms/reactive-file.component.scss24
-rw-r--r--client/src/app/shared/forms/reactive-file.component.ts75
-rw-r--r--client/src/app/shared/misc/utils.ts10
-rw-r--r--client/src/app/shared/shared.module.ts10
-rw-r--r--client/src/app/shared/video-caption/index.ts1
-rw-r--r--client/src/app/shared/video-caption/video-caption-edit.model.ts9
-rw-r--r--client/src/app/shared/video-caption/video-caption.service.ts61
-rw-r--r--client/src/app/shared/video/video.model.ts2
-rw-r--r--client/src/app/shared/video/video.service.ts4
-rw-r--r--client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html47
-rw-r--r--client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss15
-rw-r--r--client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts80
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.html32
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.scss35
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.ts53
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.module.ts4
-rw-r--r--client/src/app/videos/+video-edit/shared/video-image.component.html15
-rw-r--r--client/src/app/videos/+video-edit/shared/video-image.component.scss10
-rw-r--r--client/src/app/videos/+video-edit/shared/video-image.component.ts26
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.html2
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.ts50
-rw-r--r--client/src/app/videos/+video-edit/video-update.component.html1
-rw-r--r--client/src/app/videos/+video-edit/video-update.component.ts48
-rw-r--r--config/default.yaml3
-rw-r--r--config/production.yaml.example1
-rw-r--r--config/test-1.yaml1
-rw-r--r--config/test-2.yaml1
-rw-r--r--config/test-3.yaml1
-rw-r--r--config/test-4.yaml1
-rw-r--r--config/test-5.yaml1
-rw-r--r--config/test-6.yaml1
-rw-r--r--server.ts3
-rw-r--r--server/controllers/activitypub/client.ts5
-rw-r--r--server/controllers/api/config.ts14
-rw-r--r--server/controllers/api/videos/captions.ts100
-rw-r--r--server/controllers/api/videos/index.ts2
-rw-r--r--server/controllers/client.ts2
-rw-r--r--server/controllers/feeds.ts2
-rw-r--r--server/controllers/services.ts4
-rw-r--r--server/controllers/static.ts21
-rw-r--r--server/helpers/activitypub.ts1
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts13
-rw-r--r--server/helpers/custom-validators/video-captions.ts41
-rw-r--r--server/helpers/custom-validators/videos.ts24
-rw-r--r--server/initializers/constants.ts23
-rw-r--r--server/initializers/database.ts2
-rw-r--r--server/lib/activitypub/process/process-update.ts12
-rw-r--r--server/lib/activitypub/videos.ts29
-rw-r--r--server/lib/cache/abstract-video-static-file-cache.ts54
-rw-r--r--server/lib/cache/videos-caption-cache.ts53
-rw-r--r--server/lib/cache/videos-preview-cache.ts60
-rw-r--r--server/middlewares/validators/video-captions.ts70
-rw-r--r--server/middlewares/validators/videos.ts36
-rw-r--r--server/models/video/video-caption.ts173
-rw-r--r--server/models/video/video.ts40
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/video-captions.ts223
-rw-r--r--server/tests/api/index-fast.ts1
-rw-r--r--server/tests/api/server/config.ts158
-rw-r--r--server/tests/api/server/follows.ts28
-rw-r--r--server/tests/api/videos/video-captions.ts139
-rw-r--r--server/tests/fixtures/subtitle-good1.vtt8
-rw-r--r--server/tests/fixtures/subtitle-good2.vtt8
-rw-r--r--server/tests/utils/miscs/miscs.ts1
-rw-r--r--server/tests/utils/videos/video-captions.ts66
-rw-r--r--shared/models/activitypub/objects/video-torrent-object.ts1
-rw-r--r--shared/models/server/custom-config.model.ts4
-rw-r--r--shared/models/server/server-config.model.ts9
-rw-r--r--shared/models/videos/index.ts2
-rw-r--r--shared/models/videos/video-caption-update.model.ts4
-rw-r--r--shared/models/videos/video-caption.model.ts6
-rw-r--r--shared/models/videos/video-constant.model.ts4
-rw-r--r--shared/models/videos/video.model.ts6
-rw-r--r--support/docker/production/config/production.yaml1
83 files changed, 1867 insertions, 298 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index 1e5308531..97900e523 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -206,15 +206,17 @@ Check this checkbox, save the configuration and test with a video URL of your in
206 </div> 206 </div>
207 </ng-template> 207 </ng-template>
208 208
209 <div i18n class="inner-form-title">Cache</div> 209 <div i18n class="inner-form-title">
210 Cache
210 211
211 <div class="form-group">
212 <label i18n for="cachePreviewsSize">Previews cache size</label>
213 <my-help 212 <my-help
214 helpType="custom" i18n-customHtml 213 helpType="custom" i18n-customHtml
215 customHtml="Previews are not federated. We fetch them directly from the origin instance and cache them." 214 customHtml="Some files are not federated (previews, captions). We fetch them directly from the origin instance and cache them."
216 ></my-help> 215 ></my-help>
216 </div>
217 217
218 <div class="form-group">
219 <label i18n for="cachePreviewsSize">Previews cache size</label>
218 <input 220 <input
219 type="text" id="cachePreviewsSize" 221 type="text" id="cachePreviewsSize"
220 formControlName="cachePreviewsSize" [ngClass]="{ 'input-error': formErrors['cachePreviewsSize'] }" 222 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
224 </div> 226 </div>
225 </div> 227 </div>
226 228
229 <div class="form-group">
230 <label i18n for="cachePreviewsSize">Video captions cache size</label>
231 <input
232 type="text" id="cacheCaptionsSize"
233 formControlName="cacheCaptionsSize" [ngClass]="{ 'input-error': formErrors['cacheCaptionsSize'] }"
234 >
235 <div *ngIf="formErrors.cacheCaptionsSize" class="form-error">
236 {{ formErrors.cacheCaptionsSize }}
237 </div>
238 </div>
239
227 <div i18n class="inner-form-title">Customizations</div> 240 <div i18n class="inner-form-title">Customizations</div>
228 241
229 <div class="form-group"> 242 <div class="form-group">
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 7b3e72803..8d476393f 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -67,6 +67,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
67 servicesTwitterUsername: this.customConfigValidatorsService.SERVICES_TWITTER_USERNAME, 67 servicesTwitterUsername: this.customConfigValidatorsService.SERVICES_TWITTER_USERNAME,
68 servicesTwitterWhitelisted: null, 68 servicesTwitterWhitelisted: null,
69 cachePreviewsSize: this.customConfigValidatorsService.CACHE_PREVIEWS_SIZE, 69 cachePreviewsSize: this.customConfigValidatorsService.CACHE_PREVIEWS_SIZE,
70 cacheCaptionsSize: this.customConfigValidatorsService.CACHE_CAPTIONS_SIZE,
70 signupEnabled: null, 71 signupEnabled: null,
71 signupLimit: this.customConfigValidatorsService.SIGNUP_LIMIT, 72 signupLimit: this.customConfigValidatorsService.SIGNUP_LIMIT,
72 adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL, 73 adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL,
@@ -156,6 +157,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
156 cache: { 157 cache: {
157 previews: { 158 previews: {
158 size: this.form.value['cachePreviewsSize'] 159 size: this.form.value['cachePreviewsSize']
160 },
161 captions: {
162 size: this.form.value['cacheCaptionsSize']
159 } 163 }
160 }, 164 },
161 signup: { 165 signup: {
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index 74363e6a1..3baefb6a7 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -59,6 +59,12 @@ export class ServerService {
59 extensions: [] 59 extensions: []
60 } 60 }
61 }, 61 },
62 videoCaption: {
63 file: {
64 size: { max: 0 },
65 extensions: []
66 }
67 },
62 user: { 68 user: {
63 videoQuota: -1 69 videoQuota: -1
64 } 70 }
diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
index 1b36bbc6b..0c2489a9d 100644
--- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
@@ -9,6 +9,7 @@ export class CustomConfigValidatorsService {
9 readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator 9 readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator
10 readonly SERVICES_TWITTER_USERNAME: BuildFormValidator 10 readonly SERVICES_TWITTER_USERNAME: BuildFormValidator
11 readonly CACHE_PREVIEWS_SIZE: BuildFormValidator 11 readonly CACHE_PREVIEWS_SIZE: BuildFormValidator
12 readonly CACHE_CAPTIONS_SIZE: BuildFormValidator
12 readonly SIGNUP_LIMIT: BuildFormValidator 13 readonly SIGNUP_LIMIT: BuildFormValidator
13 readonly ADMIN_EMAIL: BuildFormValidator 14 readonly ADMIN_EMAIL: BuildFormValidator
14 readonly TRANSCODING_THREADS: BuildFormValidator 15 readonly TRANSCODING_THREADS: BuildFormValidator
@@ -44,6 +45,15 @@ export class CustomConfigValidatorsService {
44 } 45 }
45 } 46 }
46 47
48 this.CACHE_CAPTIONS_SIZE = {
49 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
50 MESSAGES: {
51 'required': this.i18n('Captions cache size is required.'),
52 'min': this.i18n('Captions cache size must be greater than 1.'),
53 'pattern': this.i18n('Captions cache size must be a number.')
54 }
55 }
56
47 this.SIGNUP_LIMIT = { 57 this.SIGNUP_LIMIT = {
48 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], 58 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
49 MESSAGES: { 59 MESSAGES: {
diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts
index 487683088..60d735ef7 100644
--- a/client/src/app/shared/forms/form-validators/index.ts
+++ b/client/src/app/shared/forms/form-validators/index.ts
@@ -8,3 +8,4 @@ export * from './video-abuse-validators.service'
8export * from './video-channel-validators.service' 8export * from './video-channel-validators.service'
9export * from './video-comment-validators.service' 9export * from './video-comment-validators.service'
10export * from './video-validators.service' 10export * from './video-validators.service'
11export * from './video-captions-validators.service'
diff --git a/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts b/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts
new file mode 100644
index 000000000..d1b4667bb
--- /dev/null
+++ b/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts
@@ -0,0 +1,27 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from '@app/shared'
5
6@Injectable()
7export class VideoCaptionsValidatorsService {
8 readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator
9 readonly VIDEO_CAPTION_FILE: BuildFormValidator
10
11 constructor (private i18n: I18n) {
12
13 this.VIDEO_CAPTION_LANGUAGE = {
14 VALIDATORS: [ Validators.required ],
15 MESSAGES: {
16 'required': this.i18n('Video caption language is required.')
17 }
18 }
19
20 this.VIDEO_CAPTION_FILE = {
21 VALIDATORS: [ Validators.required ],
22 MESSAGES: {
23 'required': this.i18n('Video caption file is required.')
24 }
25 }
26 }
27}
diff --git a/client/src/app/shared/forms/index.ts b/client/src/app/shared/forms/index.ts
index 7464bb022..41c321c4c 100644
--- a/client/src/app/shared/forms/index.ts
+++ b/client/src/app/shared/forms/index.ts
@@ -1,2 +1,3 @@
1export * from './form-validators' 1export * from './form-validators'
2export * from './form-reactive' 2export * from './form-reactive'
3export * from './reactive-file.component'
diff --git a/client/src/app/shared/forms/reactive-file.component.html b/client/src/app/shared/forms/reactive-file.component.html
new file mode 100644
index 000000000..9fb1c9e3e
--- /dev/null
+++ b/client/src/app/shared/forms/reactive-file.component.html
@@ -0,0 +1,14 @@
1<div class="root">
2 <div class="button-file">
3 <span>{{ inputLabel }}</span>
4 <input
5 type="file"
6 [name]="inputName" [id]="inputName" [accept]="extensions"
7 (change)="fileChange($event)"
8 />
9 </div>
10
11 <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})</div>
12
13 <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
14</div>
diff --git a/client/src/app/shared/forms/reactive-file.component.scss b/client/src/app/shared/forms/reactive-file.component.scss
new file mode 100644
index 000000000..d89844264
--- /dev/null
+++ b/client/src/app/shared/forms/reactive-file.component.scss
@@ -0,0 +1,24 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 height: auto;
6 display: flex;
7 align-items: center;
8
9 .button-file {
10 @include peertube-button-file(auto);
11
12 min-width: 190px;
13 }
14
15 .file-constraints {
16 margin-left: 5px;
17 font-size: 13px;
18 }
19
20 .filename {
21 font-weight: $font-semibold;
22 margin-left: 5px;
23 }
24}
diff --git a/client/src/app/shared/forms/reactive-file.component.ts b/client/src/app/shared/forms/reactive-file.component.ts
new file mode 100644
index 000000000..f5758b643
--- /dev/null
+++ b/client/src/app/shared/forms/reactive-file.component.ts
@@ -0,0 +1,75 @@
1import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { NotificationsService } from 'angular2-notifications'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5
6@Component({
7 selector: 'my-reactive-file',
8 styleUrls: [ './reactive-file.component.scss' ],
9 templateUrl: './reactive-file.component.html',
10 providers: [
11 {
12 provide: NG_VALUE_ACCESSOR,
13 useExisting: forwardRef(() => ReactiveFileComponent),
14 multi: true
15 }
16 ]
17})
18export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
19 @Input() inputLabel: string
20 @Input() inputName: string
21 @Input() extensions: string[] = []
22 @Input() maxFileSize: number
23 @Input() displayFilename = false
24
25 @Output() fileChanged = new EventEmitter<Blob>()
26
27 allowedExtensionsMessage = ''
28
29 private file: File
30
31 constructor (
32 private notificationsService: NotificationsService,
33 private i18n: I18n
34 ) {}
35
36 get filename () {
37 if (!this.file) return ''
38
39 return this.file.name
40 }
41
42 ngOnInit () {
43 this.allowedExtensionsMessage = this.extensions.join(', ')
44 }
45
46 fileChange (event: any) {
47 if (event.target.files && event.target.files.length) {
48 const [ file ] = event.target.files
49
50 if (file.size > this.maxFileSize) {
51 this.notificationsService.error(this.i18n('Error'), this.i18n('This file is too large.'))
52 return
53 }
54
55 this.file = file
56
57 this.propagateChange(this.file)
58 this.fileChanged.emit(this.file)
59 }
60 }
61
62 propagateChange = (_: any) => { /* empty */ }
63
64 writeValue (file: any) {
65 this.file = file
66 }
67
68 registerOnChange (fn: (_: any) => void) {
69 this.propagateChange = fn
70 }
71
72 registerOnTouched () {
73 // Unused
74 }
75}
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts
index 53aff1b24..8381745f5 100644
--- a/client/src/app/shared/misc/utils.ts
+++ b/client/src/app/shared/misc/utils.ts
@@ -81,7 +81,7 @@ function objectToFormData (obj: any, form?: FormData, namespace?: string) {
81 } 81 }
82 82
83 if (obj[key] !== null && typeof obj[ key ] === 'object' && !(obj[ key ] instanceof File)) { 83 if (obj[key] !== null && typeof obj[ key ] === 'object' && !(obj[ key ] instanceof File)) {
84 objectToFormData(obj[ key ], fd, key) 84 objectToFormData(obj[ key ], fd, formKey)
85 } else { 85 } else {
86 fd.append(formKey, obj[ key ]) 86 fd.append(formKey, obj[ key ])
87 } 87 }
@@ -96,6 +96,11 @@ function lineFeedToHtml (obj: object, keyToNormalize: string) {
96 }) 96 })
97} 97}
98 98
99function removeElementFromArray <T> (arr: T[], elem: T) {
100 const index = arr.indexOf(elem)
101 if (index !== -1) arr.splice(index, 1)
102}
103
99export { 104export {
100 objectToUrlEncoded, 105 objectToUrlEncoded,
101 getParameterByName, 106 getParameterByName,
@@ -104,5 +109,6 @@ export {
104 dateToHuman, 109 dateToHuman,
105 immutableAssign, 110 immutableAssign,
106 objectToFormData, 111 objectToFormData,
107 lineFeedToHtml 112 lineFeedToHtml,
113 removeElementFromArray
108} 114}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 97e49e7ab..c3f4bf88b 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -37,12 +37,14 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
37import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 37import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
38import { 38import {
39 CustomConfigValidatorsService, 39 CustomConfigValidatorsService,
40 LoginValidatorsService, 40 LoginValidatorsService, ReactiveFileComponent,
41 ResetPasswordValidatorsService, 41 ResetPasswordValidatorsService,
42 UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService 42 UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService
43} from '@app/shared/forms' 43} from '@app/shared/forms'
44import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 44import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
45import { ScreenService } from '@app/shared/misc/screen.service' 45import { ScreenService } from '@app/shared/misc/screen.service'
46import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
47import { VideoCaptionService } from '@app/shared/video-caption'
46 48
47@NgModule({ 49@NgModule({
48 imports: [ 50 imports: [
@@ -74,7 +76,8 @@ import { ScreenService } from '@app/shared/misc/screen.service'
74 FromNowPipe, 76 FromNowPipe,
75 MarkdownTextareaComponent, 77 MarkdownTextareaComponent,
76 InfiniteScrollerDirective, 78 InfiniteScrollerDirective,
77 HelpComponent 79 HelpComponent,
80 ReactiveFileComponent
78 ], 81 ],
79 82
80 exports: [ 83 exports: [
@@ -102,6 +105,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
102 MarkdownTextareaComponent, 105 MarkdownTextareaComponent,
103 InfiniteScrollerDirective, 106 InfiniteScrollerDirective,
104 HelpComponent, 107 HelpComponent,
108 ReactiveFileComponent,
105 109
106 NumberFormatterPipe, 110 NumberFormatterPipe,
107 ObjectLengthPipe, 111 ObjectLengthPipe,
@@ -119,6 +123,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
119 AccountService, 123 AccountService,
120 MarkdownService, 124 MarkdownService,
121 VideoChannelService, 125 VideoChannelService,
126 VideoCaptionService,
122 127
123 FormValidatorService, 128 FormValidatorService,
124 CustomConfigValidatorsService, 129 CustomConfigValidatorsService,
@@ -129,6 +134,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
129 VideoChannelValidatorsService, 134 VideoChannelValidatorsService,
130 VideoCommentValidatorsService, 135 VideoCommentValidatorsService,
131 VideoValidatorsService, 136 VideoValidatorsService,
137 VideoCaptionsValidatorsService,
132 138
133 I18nPrimengCalendarService, 139 I18nPrimengCalendarService,
134 ScreenService, 140 ScreenService,
diff --git a/client/src/app/shared/video-caption/index.ts b/client/src/app/shared/video-caption/index.ts
new file mode 100644
index 000000000..c48a70558
--- /dev/null
+++ b/client/src/app/shared/video-caption/index.ts
@@ -0,0 +1 @@
export * from './video-caption.service'
diff --git a/client/src/app/shared/video-caption/video-caption-edit.model.ts b/client/src/app/shared/video-caption/video-caption-edit.model.ts
new file mode 100644
index 000000000..732f20158
--- /dev/null
+++ b/client/src/app/shared/video-caption/video-caption-edit.model.ts
@@ -0,0 +1,9 @@
1export interface VideoCaptionEdit {
2 language: {
3 id: string
4 label?: string
5 }
6
7 action?: 'CREATE' | 'REMOVE'
8 captionfile?: any
9}
diff --git a/client/src/app/shared/video-caption/video-caption.service.ts b/client/src/app/shared/video-caption/video-caption.service.ts
new file mode 100644
index 000000000..4ae8ebd0a
--- /dev/null
+++ b/client/src/app/shared/video-caption/video-caption.service.ts
@@ -0,0 +1,61 @@
1import { catchError, map } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { forkJoin, Observable } from 'rxjs'
5import { ResultList } from '../../../../../shared'
6import { RestExtractor, RestService } from '../rest'
7import { VideoCaption } from '../../../../../shared/models/videos/video-caption.model'
8import { VideoService } from '@app/shared/video/video.service'
9import { objectToFormData } from '@app/shared/misc/utils'
10import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
11
12@Injectable()
13export class VideoCaptionService {
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) {}
19
20 listCaptions (videoId: number | string): Observable<ResultList<VideoCaption>> {
21 return this.authHttp.get<ResultList<VideoCaption>>(VideoService.BASE_VIDEO_URL + videoId + '/captions')
22 .pipe(catchError(res => this.restExtractor.handleError(res)))
23 }
24
25 removeCaption (videoId: number | string, language: string) {
26 return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language)
27 .pipe(
28 map(this.restExtractor.extractDataBool),
29 catchError(res => this.restExtractor.handleError(res))
30 )
31 }
32
33 addCaption (videoId: number | string, language: string, captionfile: File) {
34 const body = { captionfile }
35 const data = objectToFormData(body)
36
37 return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data)
38 .pipe(
39 map(this.restExtractor.extractDataBool),
40 catchError(res => this.restExtractor.handleError(res))
41 )
42 }
43
44 updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) {
45 const observables: Observable<any>[] = []
46
47 for (const videoCaption of videoCaptions) {
48 if (videoCaption.action === 'CREATE') {
49 observables.push(
50 this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)
51 )
52 } else if (videoCaption.action === 'REMOVE') {
53 observables.push(
54 this.removeCaption(videoId, videoCaption.language.id)
55 )
56 }
57 }
58
59 return forkJoin(observables)
60 }
61}
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 5c820a227..6b1a299ea 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -1,7 +1,7 @@
1import { User } from '../' 1import { User } from '../'
2import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' 2import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
3import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 3import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
4import { VideoConstant } from '../../../../../shared/models/videos/video.model' 4import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model'
5import { getAbsoluteAPIUrl } from '../misc/utils' 5import { getAbsoluteAPIUrl } from '../misc/utils'
6import { ServerConfig } from '../../../../../shared/models' 6import { ServerConfig } from '../../../../../shared/models'
7import { Actor } from '@app/shared/actor/actor.model' 7import { Actor } from '@app/shared/actor/actor.model'
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 9498a06fe..b4c1f10f9 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -28,8 +28,8 @@ import { ServerService } from '@app/core'
28 28
29@Injectable() 29@Injectable()
30export class VideoService { 30export class VideoService {
31 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' 31 static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
32 private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' 32 static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
33 33
34 constructor ( 34 constructor (
35 private authHttp: HttpClient, 35 private authHttp: HttpClient,
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html
new file mode 100644
index 000000000..9cd303b29
--- /dev/null
+++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html
@@ -0,0 +1,47 @@
1<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
2 <div class="modal-dialog">
3 <div class="modal-content" [formGroup]="form">
4
5 <div class="modal-header">
6 <span class="close" aria-hidden="true" (click)="hide()"></span>
7 <h4 i18n class="modal-title">Add caption</h4>
8 </div>
9
10 <div class="modal-body">
11 <label i18n for="language">Language</label>
12 <div class="peertube-select-container">
13 <select id="language" formControlName="language">
14 <option></option>
15 <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option>
16 </select>
17 </div>
18
19 <div *ngIf="formErrors.language" class="form-error">
20 {{ formErrors.language }}
21 </div>
22
23 <div class="caption-file">
24 <my-reactive-file
25 formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file"
26 [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true"
27 ></my-reactive-file>
28 </div>
29
30 <div *ngIf="isReplacingExistingCaption()" class="warning-replace-caption" i18n>
31 This will replace an existing caption!
32 </div>
33
34 <div class="form-group inputs">
35 <span i18n class="action-button action-button-cancel" (click)="hide()">
36 Cancel
37 </span>
38
39 <input
40 type="submit" i18n-value value="Add this caption" class="action-button-submit"
41 [disabled]="!form.valid" (click)="addCaption()"
42 >
43 </div>
44 </div>
45 </div>
46 </div>
47</div>
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss
new file mode 100644
index 000000000..c6da1877e
--- /dev/null
+++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss
@@ -0,0 +1,15 @@
1@import '_variables';
2@import '_mixins';
3
4.peertube-select-container {
5 @include peertube-select-container(auto);
6}
7
8.caption-file {
9 margin-top: 20px;
10}
11
12.warning-replace-caption {
13 color: red;
14 margin-top: 10px;
15} \ No newline at end of file
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts
new file mode 100644
index 000000000..45b8c71f8
--- /dev/null
+++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts
@@ -0,0 +1,80 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { ModalDirective } from 'ngx-bootstrap/modal'
3import { FormReactive } from '@app/shared'
4import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
5import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
6import { ServerService } from '@app/core'
7import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
8
9@Component({
10 selector: 'my-video-caption-add-modal',
11 styleUrls: [ './video-caption-add-modal.component.scss' ],
12 templateUrl: './video-caption-add-modal.component.html'
13})
14
15export class VideoCaptionAddModalComponent extends FormReactive implements OnInit {
16 @Input() existingCaptions: string[]
17
18 @Output() captionAdded = new EventEmitter<VideoCaptionEdit>()
19
20 @ViewChild('modal') modal: ModalDirective
21
22 videoCaptionLanguages = []
23
24 private closingModal = false
25
26 constructor (
27 protected formValidatorService: FormValidatorService,
28 private serverService: ServerService,
29 private videoCaptionsValidatorsService: VideoCaptionsValidatorsService
30 ) {
31 super()
32 }
33
34 get videoCaptionExtensions () {
35 return this.serverService.getConfig().videoCaption.file.extensions
36 }
37
38 get videoCaptionMaxSize () {
39 return this.serverService.getConfig().videoCaption.file.size.max
40 }
41
42 ngOnInit () {
43 this.videoCaptionLanguages = this.serverService.getVideoLanguages()
44
45 this.buildForm({
46 language: this.videoCaptionsValidatorsService.VIDEO_CAPTION_LANGUAGE,
47 captionfile: this.videoCaptionsValidatorsService.VIDEO_CAPTION_FILE
48 })
49 }
50
51 show () {
52 this.modal.show()
53 }
54
55 hide () {
56 this.modal.hide()
57 }
58
59 isReplacingExistingCaption () {
60 if (this.closingModal === true) return false
61
62 const languageId = this.form.value[ 'language' ]
63
64 return languageId && this.existingCaptions.indexOf(languageId) !== -1
65 }
66
67 async addCaption () {
68 this.closingModal = true
69
70 const languageId = this.form.value[ 'language' ]
71 const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId)
72
73 this.captionAdded.emit({
74 language: languageObject,
75 captionfile: this.form.value['captionfile']
76 })
77
78 this.hide()
79 }
80}
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html
index 447c5ab9b..14d5f3614 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.html
@@ -132,13 +132,39 @@
132 <label i18n for="waitTranscoding">Wait transcoding before publishing the video</label> 132 <label i18n for="waitTranscoding">Wait transcoding before publishing the video</label>
133 <my-help 133 <my-help
134 tooltipPlacement="top" helpType="custom" i18n-customHtml 134 tooltipPlacement="top" helpType="custom" i18n-customHtml
135 customHtml="If you decide to not wait transcoding before publishing the video, it can be unplayable until it transcoding ends." 135 customHtml="If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends."
136 ></my-help> 136 ></my-help>
137 </div> 137 </div>
138 138
139 </div> 139 </div>
140 </tab> 140 </tab>
141 141
142 <tab i18n-heading heading="Captions">
143 <div class="col-md-12 captions">
144
145 <div class="captions-header">
146 <a (click)="openAddCaptionModal()" class="create-caption">
147 <span class="icon icon-add"></span>
148 <ng-container i18n>Add another caption</ng-container>
149 </a>
150 </div>
151
152 <div class="form-group" *ngFor="let videoCaption of videoCaptions">
153
154 <div class="caption-entry">
155 <div class="caption-entry-label">{{ videoCaption.language.label }}</div>
156
157 <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span>
158 </div>
159 </div>
160
161 <div class="no-caption" *ngIf="videoCaptions?.length === 0">
162 No captions for now.
163 </div>
164
165 </div>
166 </tab>
167
142 <tab i18n-heading heading="Advanced settings"> 168 <tab i18n-heading heading="Advanced settings">
143 <div class="col-md-12 advanced-settings"> 169 <div class="col-md-12 advanced-settings">
144 <div class="form-group"> 170 <div class="form-group">
@@ -172,3 +198,7 @@
172 </tabset> 198 </tabset>
173 199
174</div> 200</div>
201
202<my-video-caption-add-modal
203 #videoCaptionAddModal [existingCaptions]="getExistingCaptions()" (captionAdded)="onCaptionAdded($event)"
204></my-video-caption-add-modal> \ No newline at end of file
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss
index 061eca4a7..03b8359de 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss
@@ -7,6 +7,7 @@
7 7
8.video-edit { 8.video-edit {
9 height: 100%; 9 height: 100%;
10 min-height: 300px;
10 11
11 .form-group { 12 .form-group {
12 margin-bottom: 25px; 13 margin-bottom: 25px;
@@ -49,6 +50,40 @@
49 } 50 }
50} 51}
51 52
53.captions {
54
55 .captions-header {
56 text-align: right;
57
58 .create-caption {
59 @include create-button('../../../../assets/images/global/add.svg');
60 }
61 }
62
63 .caption-entry {
64 display: flex;
65 height: 40px;
66 align-items: center;
67
68 .caption-entry-label {
69 font-size: 15px;
70 font-weight: bold;
71
72 margin-right: 20px;
73 }
74
75 .caption-entry-delete {
76 @include peertube-button;
77 @include grey-button;
78 }
79 }
80
81 .no-caption {
82 text-align: center;
83 font-size: 15px;
84 }
85}
86
52.submit-container { 87.submit-container {
53 text-align: right; 88 text-align: right;
54 89
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
index 66eb6611a..9394d7dab 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
@@ -1,5 +1,5 @@
1import { Component, Input, OnInit } from '@angular/core' 1import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
2import { FormGroup, ValidatorFn, Validators } from '@angular/forms' 2import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared' 4import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared'
5import { NotificationsService } from 'angular2-notifications' 5import { NotificationsService } from 'angular2-notifications'
@@ -8,6 +8,10 @@ import { VideoEdit } from '../../../shared/video/video-edit.model'
8import { map } from 'rxjs/operators' 8import { map } from 'rxjs/operators'
9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
10import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 10import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
11import { VideoCaptionService } from '@app/shared/video-caption'
12import { VideoCaptionAddModalComponent } from '@app/videos/+video-edit/shared/video-caption-add-modal.component'
13import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
14import { removeElementFromArray } from '@app/shared/misc/utils'
11 15
12@Component({ 16@Component({
13 selector: 'my-video-edit', 17 selector: 'my-video-edit',
@@ -15,13 +19,16 @@ import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calend
15 templateUrl: './video-edit.component.html' 19 templateUrl: './video-edit.component.html'
16}) 20})
17 21
18export class VideoEditComponent implements OnInit { 22export class VideoEditComponent implements OnInit, OnDestroy {
19 @Input() form: FormGroup 23 @Input() form: FormGroup
20 @Input() formErrors: { [ id: string ]: string } = {} 24 @Input() formErrors: { [ id: string ]: string } = {}
21 @Input() validationMessages: FormReactiveValidationMessages = {} 25 @Input() validationMessages: FormReactiveValidationMessages = {}
22 @Input() videoPrivacies = [] 26 @Input() videoPrivacies = []
23 @Input() userVideoChannels: { id: number, label: string, support: string }[] = [] 27 @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
24 @Input() schedulePublicationPossible = true 28 @Input() schedulePublicationPossible = true
29 @Input() videoCaptions: VideoCaptionEdit[] = []
30
31 @ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent
25 32
26 // So that it can be accessed in the template 33 // So that it can be accessed in the template
27 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 34 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
@@ -41,9 +48,12 @@ export class VideoEditComponent implements OnInit {
41 calendarTimezone: string 48 calendarTimezone: string
42 calendarDateFormat: string 49 calendarDateFormat: string
43 50
51 private schedulerInterval
52
44 constructor ( 53 constructor (
45 private formValidatorService: FormValidatorService, 54 private formValidatorService: FormValidatorService,
46 private videoValidatorsService: VideoValidatorsService, 55 private videoValidatorsService: VideoValidatorsService,
56 private videoCaptionService: VideoCaptionService,
47 private route: ActivatedRoute, 57 private route: ActivatedRoute,
48 private router: Router, 58 private router: Router,
49 private notificationsService: NotificationsService, 59 private notificationsService: NotificationsService,
@@ -91,6 +101,13 @@ export class VideoEditComponent implements OnInit {
91 defaultValues 101 defaultValues
92 ) 102 )
93 103
104 this.form.addControl('captions', new FormArray([
105 new FormGroup({
106 language: new FormControl(),
107 captionfile: new FormControl()
108 })
109 ]))
110
94 this.trackChannelChange() 111 this.trackChannelChange()
95 this.trackPrivacyChange() 112 this.trackPrivacyChange()
96 } 113 }
@@ -102,7 +119,35 @@ export class VideoEditComponent implements OnInit {
102 this.videoLicences = this.serverService.getVideoLicences() 119 this.videoLicences = this.serverService.getVideoLicences()
103 this.videoLanguages = this.serverService.getVideoLanguages() 120 this.videoLanguages = this.serverService.getVideoLanguages()
104 121
105 setTimeout(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute 122 this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
123 }
124
125 ngOnDestroy () {
126 if (this.schedulerInterval) clearInterval(this.schedulerInterval)
127 }
128
129 getExistingCaptions () {
130 return this.videoCaptions.map(c => c.language.id)
131 }
132
133 onCaptionAdded (caption: VideoCaptionEdit) {
134 this.videoCaptions.push(
135 Object.assign(caption, { action: 'CREATE' as 'CREATE' })
136 )
137 }
138
139 deleteCaption (caption: VideoCaptionEdit) {
140 // This caption is not on the server, just remove it from our array
141 if (caption.action === 'CREATE') {
142 removeElementFromArray(this.videoCaptions, caption)
143 return
144 }
145
146 caption.action = 'REMOVE' as 'REMOVE'
147 }
148
149 openAddCaptionModal () {
150 this.videoCaptionAddModal.show()
106 } 151 }
107 152
108 private trackPrivacyChange () { 153 private trackPrivacyChange () {
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.module.ts b/client/src/app/videos/+video-edit/shared/video-edit.module.ts
index 6bf3e34b1..f6bd65fdc 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.module.ts
+++ b/client/src/app/videos/+video-edit/shared/video-edit.module.ts
@@ -5,6 +5,7 @@ import { SharedModule } from '../../../shared/'
5import { VideoEditComponent } from './video-edit.component' 5import { VideoEditComponent } from './video-edit.component'
6import { VideoImageComponent } from './video-image.component' 6import { VideoImageComponent } from './video-image.component'
7import { CalendarModule } from 'primeng/components/calendar/calendar' 7import { CalendarModule } from 'primeng/components/calendar/calendar'
8import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
8 9
9@NgModule({ 10@NgModule({
10 imports: [ 11 imports: [
@@ -16,7 +17,8 @@ import { CalendarModule } from 'primeng/components/calendar/calendar'
16 17
17 declarations: [ 18 declarations: [
18 VideoEditComponent, 19 VideoEditComponent,
19 VideoImageComponent 20 VideoImageComponent,
21 VideoCaptionAddModalComponent
20 ], 22 ],
21 23
22 exports: [ 24 exports: [
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.html b/client/src/app/videos/+video-edit/shared/video-image.component.html
index e319d7ee7..c09c862c4 100644
--- a/client/src/app/videos/+video-edit/shared/video-image.component.html
+++ b/client/src/app/videos/+video-edit/shared/video-image.component.html
@@ -1,15 +1,8 @@
1<div class="root"> 1<div class="root">
2 <div> 2 <my-reactive-file
3 <div class="button-file"> 3 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
4 <span>{{ inputLabel }}</span> 4 (fileChanged)="onFileChanged($event)"
5 <input 5 ></my-reactive-file>
6 type="file"
7 [name]="inputName" [id]="inputName" [accept]="videoImageExtensions"
8 (change)="fileChange($event)"
9 />
10 </div>
11 <div i18n class="image-constraints">(extensions: {{ videoImageExtensions }}, max size: {{ maxVideoImageSize | bytes }})</div>
12 </div>
13 6
14 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" /> 7 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
15 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div> 8 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.scss b/client/src/app/videos/+video-edit/shared/video-image.component.scss
index d4901e7ab..b63963bca 100644
--- a/client/src/app/videos/+video-edit/shared/video-image.component.scss
+++ b/client/src/app/videos/+video-edit/shared/video-image.component.scss
@@ -6,16 +6,6 @@
6 display: flex; 6 display: flex;
7 align-items: center; 7 align-items: center;
8 8
9 .button-file {
10 @include peertube-button-file(auto);
11
12 min-width: 190px;
13 }
14
15 .image-constraints {
16 font-size: 13px;
17 }
18
19 .preview { 9 .preview {
20 border: 2px solid grey; 10 border: 2px solid grey;
21 border-radius: 4px; 11 border-radius: 4px;
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.ts b/client/src/app/videos/+video-edit/shared/video-image.component.ts
index 25955baaa..a604cde90 100644
--- a/client/src/app/videos/+video-edit/shared/video-image.component.ts
+++ b/client/src/app/videos/+video-edit/shared/video-image.component.ts
@@ -2,8 +2,6 @@ import { Component, forwardRef, Input } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' 3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
4import { ServerService } from '@app/core' 4import { ServerService } from '@app/core'
5import { NotificationsService } from 'angular2-notifications'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7 5
8@Component({ 6@Component({
9 selector: 'my-video-image', 7 selector: 'my-video-image',
@@ -25,36 +23,26 @@ export class VideoImageComponent implements ControlValueAccessor {
25 23
26 imageSrc: SafeResourceUrl 24 imageSrc: SafeResourceUrl
27 25
28 private file: Blob 26 private file: File
29 27
30 constructor ( 28 constructor (
31 private sanitizer: DomSanitizer, 29 private sanitizer: DomSanitizer,
32 private serverService: ServerService, 30 private serverService: ServerService
33 private notificationsService: NotificationsService,
34 private i18n: I18n
35 ) {} 31 ) {}
36 32
37 get videoImageExtensions () { 33 get videoImageExtensions () {
38 return this.serverService.getConfig().video.image.extensions.join(',') 34 return this.serverService.getConfig().video.image.extensions
39 } 35 }
40 36
41 get maxVideoImageSize () { 37 get maxVideoImageSize () {
42 return this.serverService.getConfig().video.image.size.max 38 return this.serverService.getConfig().video.image.size.max
43 } 39 }
44 40
45 fileChange (event: any) { 41 onFileChanged (file: File) {
46 if (event.target.files && event.target.files.length) { 42 this.file = file
47 const [ file ] = event.target.files
48
49 if (file.size > this.maxVideoImageSize) {
50 this.notificationsService.error(this.i18n('Error'), this.i18n('This image is too large.'))
51 return
52 }
53 43
54 this.file = file 44 this.propagateChange(this.file)
55 this.propagateChange(this.file) 45 this.updatePreview()
56 this.updatePreview()
57 }
58 } 46 }
59 47
60 propagateChange = (_: any) => { /* empty */ } 48 propagateChange = (_: any) => { /* empty */ }
diff --git a/client/src/app/videos/+video-edit/video-add.component.html b/client/src/app/videos/+video-edit/video-add.component.html
index 7d9443209..9c2c01c65 100644
--- a/client/src/app/videos/+video-edit/video-add.component.html
+++ b/client/src/app/videos/+video-edit/video-add.component.html
@@ -46,7 +46,7 @@
46 <!-- Hidden because we want to load the component --> 46 <!-- Hidden because we want to load the component -->
47 <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form"> 47 <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
48 <my-video-edit 48 <my-video-edit
49 [form]="form" [formErrors]="formErrors" 49 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
50 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" 50 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
51 ></my-video-edit> 51 ></my-video-edit>
52 52
diff --git a/client/src/app/videos/+video-edit/video-add.component.ts b/client/src/app/videos/+video-edit/video-add.component.ts
index 7c4b6260b..8c30cedfb 100644
--- a/client/src/app/videos/+video-edit/video-add.component.ts
+++ b/client/src/app/videos/+video-edit/video-add.component.ts
@@ -15,6 +15,8 @@ import { VideoEdit } from '../../shared/video/video-edit.model'
15import { VideoService } from '../../shared/video/video.service' 15import { VideoService } from '../../shared/video/video.service'
16import { I18n } from '@ngx-translate/i18n-polyfill' 16import { I18n } from '@ngx-translate/i18n-polyfill'
17import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 17import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
18import { switchMap } from 'rxjs/operators'
19import { VideoCaptionService } from '@app/shared/video-caption'
18 20
19@Component({ 21@Component({
20 selector: 'my-videos-add', 22 selector: 'my-videos-add',
@@ -46,6 +48,7 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
46 videoPrivacies = [] 48 videoPrivacies = []
47 firstStepPrivacyId = 0 49 firstStepPrivacyId = 0
48 firstStepChannelId = 0 50 firstStepChannelId = 0
51 videoCaptions = []
49 52
50 constructor ( 53 constructor (
51 protected formValidatorService: FormValidatorService, 54 protected formValidatorService: FormValidatorService,
@@ -56,7 +59,8 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
56 private serverService: ServerService, 59 private serverService: ServerService,
57 private videoService: VideoService, 60 private videoService: VideoService,
58 private loadingBar: LoadingBarService, 61 private loadingBar: LoadingBarService,
59 private i18n: I18n 62 private i18n: I18n,
63 private videoCaptionService: VideoCaptionService
60 ) { 64 ) {
61 super() 65 super()
62 } 66 }
@@ -159,11 +163,8 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
159 let name: string 163 let name: string
160 164
161 // If the name of the file is very small, keep the extension 165 // If the name of the file is very small, keep the extension
162 if (nameWithoutExtension.length < 3) { 166 if (nameWithoutExtension.length < 3) name = videofile.name
163 name = videofile.name 167 else name = nameWithoutExtension
164 } else {
165 name = nameWithoutExtension
166 }
167 168
168 const privacy = this.firstStepPrivacyId.toString() 169 const privacy = this.firstStepPrivacyId.toString()
169 const nsfw = false 170 const nsfw = false
@@ -225,22 +226,25 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
225 this.isUpdatingVideo = true 226 this.isUpdatingVideo = true
226 this.loadingBar.start() 227 this.loadingBar.start()
227 this.videoService.updateVideo(video) 228 this.videoService.updateVideo(video)
228 .subscribe( 229 .pipe(
229 () => { 230 // Then update captions
230 this.isUpdatingVideo = false 231 switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions))
231 this.isUploadingVideo = false 232 )
232 this.loadingBar.complete() 233 .subscribe(
233 234 () => {
234 this.notificationsService.success(this.i18n('Success'), this.i18n('Video published.')) 235 this.isUpdatingVideo = false
235 this.router.navigate([ '/videos/watch', video.uuid ]) 236 this.isUploadingVideo = false
236 }, 237 this.loadingBar.complete()
237 238
238 err => { 239 this.notificationsService.success(this.i18n('Success'), this.i18n('Video published.'))
239 this.isUpdatingVideo = false 240 this.router.navigate([ '/videos/watch', video.uuid ])
240 this.notificationsService.error(this.i18n('Error'), err.message) 241 },
241 console.error(err) 242
242 } 243 err => {
243 ) 244 this.isUpdatingVideo = false
244 245 this.notificationsService.error(this.i18n('Error'), err.message)
246 console.error(err)
247 }
248 )
245 } 249 }
246} 250}
diff --git a/client/src/app/videos/+video-edit/video-update.component.html b/client/src/app/videos/+video-edit/video-update.component.html
index 5cb16c8ab..9242c30a0 100644
--- a/client/src/app/videos/+video-edit/video-update.component.html
+++ b/client/src/app/videos/+video-edit/video-update.component.html
@@ -8,6 +8,7 @@
8 <my-video-edit 8 <my-video-edit
9 [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible" 9 [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
10 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" 10 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
11 [videoCaptions]="videoCaptions"
11 ></my-video-edit> 12 ></my-video-edit>
12 13
13 <div class="submit-container"> 14 <div class="submit-container">
diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts
index c4e6f44de..b67874401 100644
--- a/client/src/app/videos/+video-edit/video-update.component.ts
+++ b/client/src/app/videos/+video-edit/video-update.component.ts
@@ -12,6 +12,7 @@ import { VideoService } from '../../shared/video/video.service'
12import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 12import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
13import { I18n } from '@ngx-translate/i18n-polyfill' 13import { I18n } from '@ngx-translate/i18n-polyfill'
14import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 14import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
15import { VideoCaptionService } from '@app/shared/video-caption'
15 16
16@Component({ 17@Component({
17 selector: 'my-videos-update', 18 selector: 'my-videos-update',
@@ -25,6 +26,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
25 videoPrivacies = [] 26 videoPrivacies = []
26 userVideoChannels = [] 27 userVideoChannels = []
27 schedulePublicationPossible = false 28 schedulePublicationPossible = false
29 videoCaptions = []
28 30
29 constructor ( 31 constructor (
30 protected formValidatorService: FormValidatorService, 32 protected formValidatorService: FormValidatorService,
@@ -36,6 +38,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
36 private authService: AuthService, 38 private authService: AuthService,
37 private loadingBar: LoadingBarService, 39 private loadingBar: LoadingBarService,
38 private videoChannelService: VideoChannelService, 40 private videoChannelService: VideoChannelService,
41 private videoCaptionService: VideoCaptionService,
39 private i18n: I18n 42 private i18n: I18n
40 ) { 43 ) {
41 super() 44 super()
@@ -63,12 +66,21 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
63 map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))), 66 map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))),
64 map(videoChannels => ({ video, videoChannels })) 67 map(videoChannels => ({ video, videoChannels }))
65 ) 68 )
69 }),
70 switchMap(({ video, videoChannels }) => {
71 return this.videoCaptionService
72 .listCaptions(video.id)
73 .pipe(
74 map(result => result.data),
75 map(videoCaptions => ({ video, videoChannels, videoCaptions }))
76 )
66 }) 77 })
67 ) 78 )
68 .subscribe( 79 .subscribe(
69 ({ video, videoChannels }) => { 80 ({ video, videoChannels, videoCaptions }) => {
70 this.video = new VideoEdit(video) 81 this.video = new VideoEdit(video)
71 this.userVideoChannels = videoChannels 82 this.userVideoChannels = videoChannels
83 this.videoCaptions = videoCaptions
72 84
73 // We cannot set private a video that was not private 85 // We cannot set private a video that was not private
74 if (this.video.privacy !== VideoPrivacy.PRIVATE) { 86 if (this.video.privacy !== VideoPrivacy.PRIVATE) {
@@ -102,21 +114,27 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
102 114
103 this.loadingBar.start() 115 this.loadingBar.start()
104 this.isUpdatingVideo = true 116 this.isUpdatingVideo = true
117
118 // Update the video
105 this.videoService.updateVideo(this.video) 119 this.videoService.updateVideo(this.video)
106 .subscribe( 120 .pipe(
107 () => { 121 // Then update captions
108 this.isUpdatingVideo = false 122 switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
109 this.loadingBar.complete() 123 )
110 this.notificationsService.success(this.i18n('Success'), this.i18n('Video updated.')) 124 .subscribe(
111 this.router.navigate([ '/videos/watch', this.video.uuid ]) 125 () => {
112 }, 126 this.isUpdatingVideo = false
113 127 this.loadingBar.complete()
114 err => { 128 this.notificationsService.success(this.i18n('Success'), this.i18n('Video updated.'))
115 this.isUpdatingVideo = false 129 this.router.navigate([ '/videos/watch', this.video.uuid ])
116 this.notificationsService.error(this.i18n('Error'), err.message) 130 },
117 console.error(err) 131
118 } 132 err => {
119 ) 133 this.isUpdatingVideo = false
134 this.notificationsService.error(this.i18n('Error'), err.message)
135 console.error(err)
136 }
137 )
120 138
121 } 139 }
122 140
diff --git a/config/default.yaml b/config/default.yaml
index 9a9b5833f..d59425365 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -49,6 +49,7 @@ storage:
49 previews: 'storage/previews/' 49 previews: 'storage/previews/'
50 thumbnails: 'storage/thumbnails/' 50 thumbnails: 'storage/thumbnails/'
51 torrents: 'storage/torrents/' 51 torrents: 'storage/torrents/'
52 captions: 'storage/captions/'
52 cache: 'storage/cache/' 53 cache: 'storage/cache/'
53 54
54log: 55log:
@@ -57,6 +58,8 @@ log:
57cache: 58cache:
58 previews: 59 previews:
59 size: 1 # Max number of previews you want to cache 60 size: 1 # Max number of previews you want to cache
61 captions:
62 size: 1 # Max number of video captions/subtitles you want to cache
60 63
61admin: 64admin:
62 email: 'admin@example.com' # Your personal email as administrator 65 email: 'admin@example.com' # Your personal email as administrator
diff --git a/config/production.yaml.example b/config/production.yaml.example
index a4c80b1f1..98cdd7ca7 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -50,6 +50,7 @@ storage:
50 previews: '/var/www/peertube/storage/previews/' 50 previews: '/var/www/peertube/storage/previews/'
51 thumbnails: '/var/www/peertube/storage/thumbnails/' 51 thumbnails: '/var/www/peertube/storage/thumbnails/'
52 torrents: '/var/www/peertube/storage/torrents/' 52 torrents: '/var/www/peertube/storage/torrents/'
53 captions: '/var/www/peertube/storage/captions/'
53 cache: '/var/www/peertube/storage/cache/' 54 cache: '/var/www/peertube/storage/cache/'
54 55
55log: 56log:
diff --git a/config/test-1.yaml b/config/test-1.yaml
index cb658397c..503bbc661 100644
--- a/config/test-1.yaml
+++ b/config/test-1.yaml
@@ -16,6 +16,7 @@ storage:
16 previews: 'test1/previews/' 16 previews: 'test1/previews/'
17 thumbnails: 'test1/thumbnails/' 17 thumbnails: 'test1/thumbnails/'
18 torrents: 'test1/torrents/' 18 torrents: 'test1/torrents/'
19 captions: 'test1/captions/'
19 cache: 'test1/cache/' 20 cache: 'test1/cache/'
20 21
21admin: 22admin:
diff --git a/config/test-2.yaml b/config/test-2.yaml
index 7b9787c91..8c77bf581 100644
--- a/config/test-2.yaml
+++ b/config/test-2.yaml
@@ -16,6 +16,7 @@ storage:
16 previews: 'test2/previews/' 16 previews: 'test2/previews/'
17 thumbnails: 'test2/thumbnails/' 17 thumbnails: 'test2/thumbnails/'
18 torrents: 'test2/torrents/' 18 torrents: 'test2/torrents/'
19 captions: 'test2/captions/'
19 cache: 'test2/cache/' 20 cache: 'test2/cache/'
20 21
21admin: 22admin:
diff --git a/config/test-3.yaml b/config/test-3.yaml
index e7e30c07b..82d89567a 100644
--- a/config/test-3.yaml
+++ b/config/test-3.yaml
@@ -16,6 +16,7 @@ storage:
16 previews: 'test3/previews/' 16 previews: 'test3/previews/'
17 thumbnails: 'test3/thumbnails/' 17 thumbnails: 'test3/thumbnails/'
18 torrents: 'test3/torrents/' 18 torrents: 'test3/torrents/'
19 captions: 'test3/captions/'
19 cache: 'test3/cache/' 20 cache: 'test3/cache/'
20 21
21admin: 22admin:
diff --git a/config/test-4.yaml b/config/test-4.yaml
index b80acd765..1aa56d041 100644
--- a/config/test-4.yaml
+++ b/config/test-4.yaml
@@ -16,6 +16,7 @@ storage:
16 previews: 'test4/previews/' 16 previews: 'test4/previews/'
17 thumbnails: 'test4/thumbnails/' 17 thumbnails: 'test4/thumbnails/'
18 torrents: 'test4/torrents/' 18 torrents: 'test4/torrents/'
19 captions: 'test4/captions/'
19 cache: 'test4/cache/' 20 cache: 'test4/cache/'
20 21
21admin: 22admin:
diff --git a/config/test-5.yaml b/config/test-5.yaml
index 29d06f1da..5f1c2f583 100644
--- a/config/test-5.yaml
+++ b/config/test-5.yaml
@@ -16,6 +16,7 @@ storage:
16 previews: 'test5/previews/' 16 previews: 'test5/previews/'
17 thumbnails: 'test5/thumbnails/' 17 thumbnails: 'test5/thumbnails/'
18 torrents: 'test5/torrents/' 18 torrents: 'test5/torrents/'
19 captions: 'test5/captions/'
19 cache: 'test5/cache/' 20 cache: 'test5/cache/'
20 21
21admin: 22admin:
diff --git a/config/test-6.yaml b/config/test-6.yaml
index 4fdc2402e..719629844 100644
--- a/config/test-6.yaml
+++ b/config/test-6.yaml
@@ -16,6 +16,7 @@ storage:
16 previews: 'test6/previews/' 16 previews: 'test6/previews/'
17 thumbnails: 'test6/thumbnails/' 17 thumbnails: 'test6/thumbnails/'
18 torrents: 'test6/torrents/' 18 torrents: 'test6/torrents/'
19 captions: 'test6/captions/'
19 cache: 'test6/cache/' 20 cache: 'test6/cache/'
20 21
21admin: 22admin:
diff --git a/server.ts b/server.ts
index fffb8038f..a7fea34da 100644
--- a/server.ts
+++ b/server.ts
@@ -1,4 +1,6 @@
1// FIXME: https://github.com/nodejs/node/pull/16853 1// FIXME: https://github.com/nodejs/node/pull/16853
2import { VideosCaptionCache } from './server/lib/cache/videos-caption-cache'
3
2require('tls').DEFAULT_ECDH_CURVE = 'auto' 4require('tls').DEFAULT_ECDH_CURVE = 'auto'
3 5
4import { isTestInstance } from './server/helpers/core-utils' 6import { isTestInstance } from './server/helpers/core-utils'
@@ -181,6 +183,7 @@ async function startApplication () {
181 183
182 // Caches initializations 184 // Caches initializations
183 VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE) 185 VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE)
186 VideosCaptionCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE)
184 187
185 // Enable Schedulers 188 // Enable Schedulers
186 BadActorFollowScheduler.Instance.enable() 189 BadActorFollowScheduler.Instance.enable()
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index ea8e25f68..3e6361906 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -25,6 +25,8 @@ import {
25 getVideoLikesActivityPubUrl, 25 getVideoLikesActivityPubUrl,
26 getVideoSharesActivityPubUrl 26 getVideoSharesActivityPubUrl
27} from '../../lib/activitypub' 27} from '../../lib/activitypub'
28import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
29import { VideoCaptionModel } from '../../models/video/video-caption'
28 30
29const activityPubClientRouter = express.Router() 31const activityPubClientRouter = express.Router()
30 32
@@ -123,6 +125,9 @@ async function accountFollowingController (req: express.Request, res: express.Re
123async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) { 125async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) {
124 const video: VideoModel = res.locals.video 126 const video: VideoModel = res.locals.video
125 127
128 // We need captions to render AP object
129 video.VideoCaptions = await VideoCaptionModel.listVideoCaptions(video.id)
130
126 const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC) 131 const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
127 const videoObject = audiencify(video.toActivityPubObject(), audience) 132 const videoObject = audiencify(video.toActivityPubObject(), audience)
128 133
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index f678e3c4a..3788975a9 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -80,6 +80,14 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
80 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME 80 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
81 } 81 }
82 }, 82 },
83 videoCaption: {
84 file: {
85 size: {
86 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
87 },
88 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
89 }
90 },
83 user: { 91 user: {
84 videoQuota: CONFIG.USER.VIDEO_QUOTA 92 videoQuota: CONFIG.USER.VIDEO_QUOTA
85 } 93 }
@@ -122,12 +130,13 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
122 130
123 // Force number conversion 131 // Force number conversion
124 toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10) 132 toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10)
133 toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10)
125 toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10) 134 toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10)
126 toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10) 135 toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10)
127 toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10) 136 toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
128 137
129 // camelCase to snake_case key 138 // camelCase to snake_case key
130 const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription') 139 const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription', 'cache.videoCaptions')
131 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota 140 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
132 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute 141 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
133 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription 142 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
@@ -172,6 +181,9 @@ function customConfig (): CustomConfig {
172 cache: { 181 cache: {
173 previews: { 182 previews: {
174 size: CONFIG.CACHE.PREVIEWS.SIZE 183 size: CONFIG.CACHE.PREVIEWS.SIZE
184 },
185 captions: {
186 size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
175 } 187 }
176 }, 188 },
177 signup: { 189 signup: {
diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts
new file mode 100644
index 000000000..05412a17f
--- /dev/null
+++ b/server/controllers/api/videos/captions.ts
@@ -0,0 +1,100 @@
1import * as express from 'express'
2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
3import {
4 addVideoCaptionValidator,
5 deleteVideoCaptionValidator,
6 listVideoCaptionsValidator
7} from '../../../middlewares/validators/video-captions'
8import { createReqFiles } from '../../../helpers/express-utils'
9import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers'
10import { getFormattedObjects } from '../../../helpers/utils'
11import { VideoCaptionModel } from '../../../models/video/video-caption'
12import { renamePromise } from '../../../helpers/core-utils'
13import { join } from 'path'
14import { VideoModel } from '../../../models/video/video'
15import { logger } from '../../../helpers/logger'
16import { federateVideoIfNeeded } from '../../../lib/activitypub'
17
18const reqVideoCaptionAdd = createReqFiles(
19 [ 'captionfile' ],
20 VIDEO_CAPTIONS_MIMETYPE_EXT,
21 {
22 captionfile: CONFIG.STORAGE.CAPTIONS_DIR
23 }
24)
25
26const videoCaptionsRouter = express.Router()
27
28videoCaptionsRouter.get('/:videoId/captions',
29 asyncMiddleware(listVideoCaptionsValidator),
30 asyncMiddleware(listVideoCaptions)
31)
32videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
33 authenticate,
34 reqVideoCaptionAdd,
35 asyncMiddleware(addVideoCaptionValidator),
36 asyncRetryTransactionMiddleware(addVideoCaption)
37)
38videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
39 authenticate,
40 asyncMiddleware(deleteVideoCaptionValidator),
41 asyncRetryTransactionMiddleware(deleteVideoCaption)
42)
43
44// ---------------------------------------------------------------------------
45
46export {
47 videoCaptionsRouter
48}
49
50// ---------------------------------------------------------------------------
51
52async function listVideoCaptions (req: express.Request, res: express.Response) {
53 const data = await VideoCaptionModel.listVideoCaptions(res.locals.video.id)
54
55 return res.json(getFormattedObjects(data, data.length))
56}
57
58async function addVideoCaption (req: express.Request, res: express.Response) {
59 const videoCaptionPhysicalFile = req.files['captionfile'][0]
60 const video = res.locals.video as VideoModel
61
62 const videoCaption = new VideoCaptionModel({
63 videoId: video.id,
64 language: req.params.captionLanguage
65 })
66 videoCaption.Video = video
67
68 // Move physical file
69 const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR
70 const destination = join(videoCaptionsDir, videoCaption.getCaptionName())
71 await renamePromise(videoCaptionPhysicalFile.path, destination)
72 // This is important in case if there is another attempt in the retry process
73 videoCaptionPhysicalFile.filename = videoCaption.getCaptionName()
74 videoCaptionPhysicalFile.path = destination
75
76 await sequelizeTypescript.transaction(async t => {
77 await VideoCaptionModel.insertOrReplaceLanguage(video.id, req.params.captionLanguage, t)
78
79 // Update video update
80 await federateVideoIfNeeded(video, false, t)
81 })
82
83 return res.status(204).end()
84}
85
86async function deleteVideoCaption (req: express.Request, res: express.Response) {
87 const video = res.locals.video as VideoModel
88 const videoCaption = res.locals.videoCaption as VideoCaptionModel
89
90 await sequelizeTypescript.transaction(async t => {
91 await videoCaption.destroy({ transaction: t })
92
93 // Send video update
94 await federateVideoIfNeeded(video, false, t)
95 })
96
97 logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid)
98
99 return res.type('json').status(204).end()
100}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 8c93ae89c..bbb5b8b4c 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -53,6 +53,7 @@ import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
53import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' 53import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
54import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' 54import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
55import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 55import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
56import { videoCaptionsRouter } from './captions'
56 57
57const videosRouter = express.Router() 58const videosRouter = express.Router()
58 59
@@ -78,6 +79,7 @@ videosRouter.use('/', abuseVideoRouter)
78videosRouter.use('/', blacklistRouter) 79videosRouter.use('/', blacklistRouter)
79videosRouter.use('/', rateVideoRouter) 80videosRouter.use('/', rateVideoRouter)
80videosRouter.use('/', videoCommentRouter) 81videosRouter.use('/', videoCommentRouter)
82videosRouter.use('/', videoCaptionsRouter)
81 83
82videosRouter.get('/categories', listVideoCategories) 84videosRouter.get('/categories', listVideoCategories)
83videosRouter.get('/licences', listVideoLicences) 85videosRouter.get('/licences', listVideoLicences)
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
index 5413f61e8..bfdf35021 100644
--- a/server/controllers/client.ts
+++ b/server/controllers/client.ts
@@ -118,7 +118,7 @@ function addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
118 118
119 const videoNameEscaped = escapeHTML(video.name) 119 const videoNameEscaped = escapeHTML(video.name)
120 const videoDescriptionEscaped = escapeHTML(video.description) 120 const videoDescriptionEscaped = escapeHTML(video.description)
121 const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedPath() 121 const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedStaticPath()
122 122
123 const openGraphMetaTags = { 123 const openGraphMetaTags = {
124 'og:type': 'video', 124 'og:type': 'video',
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index 1773fc71e..ff6b423d9 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -129,7 +129,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response, n
129 torrent: torrents, 129 torrent: torrents,
130 thumbnail: [ 130 thumbnail: [
131 { 131 {
132 url: CONFIG.WEBSERVER.URL + video.getThumbnailPath(), 132 url: CONFIG.WEBSERVER.URL + video.getThumbnailStaticPath(),
133 height: THUMBNAILS_SIZE.height, 133 height: THUMBNAILS_SIZE.height,
134 width: THUMBNAILS_SIZE.width 134 width: THUMBNAILS_SIZE.width
135 } 135 }
diff --git a/server/controllers/services.ts b/server/controllers/services.ts
index bd4404b62..352d0b19a 100644
--- a/server/controllers/services.ts
+++ b/server/controllers/services.ts
@@ -29,8 +29,8 @@ function generateOEmbed (req: express.Request, res: express.Response, next: expr
29 const maxHeight = parseInt(req.query.maxheight, 10) 29 const maxHeight = parseInt(req.query.maxheight, 10)
30 const maxWidth = parseInt(req.query.maxwidth, 10) 30 const maxWidth = parseInt(req.query.maxwidth, 10)
31 31
32 const embedUrl = webserverUrl + video.getEmbedPath() 32 const embedUrl = webserverUrl + video.getEmbedStaticPath()
33 let thumbnailUrl = webserverUrl + video.getPreviewPath() 33 let thumbnailUrl = webserverUrl + video.getPreviewStaticPath()
34 let embedWidth = EMBED_SIZE.width 34 let embedWidth = EMBED_SIZE.width
35 let embedHeight = EMBED_SIZE.height 35 let embedHeight = EMBED_SIZE.height
36 36
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 139ba67cc..679999859 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -4,6 +4,7 @@ import { CONFIG, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../
4import { VideosPreviewCache } from '../lib/cache' 4import { VideosPreviewCache } from '../lib/cache'
5import { asyncMiddleware, videosGetValidator } from '../middlewares' 5import { asyncMiddleware, videosGetValidator } from '../middlewares'
6import { VideoModel } from '../models/video/video' 6import { VideoModel } from '../models/video/video'
7import { VideosCaptionCache } from '../lib/cache/videos-caption-cache'
7 8
8const staticRouter = express.Router() 9const staticRouter = express.Router()
9 10
@@ -49,12 +50,18 @@ staticRouter.use(
49 express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE }) 50 express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE })
50) 51)
51 52
52// Video previews path for express 53// We don't have video previews, fetch them from the origin instance
53staticRouter.use( 54staticRouter.use(
54 STATIC_PATHS.PREVIEWS + ':uuid.jpg', 55 STATIC_PATHS.PREVIEWS + ':uuid.jpg',
55 asyncMiddleware(getPreview) 56 asyncMiddleware(getPreview)
56) 57)
57 58
59// We don't have video captions, fetch them from the origin instance
60staticRouter.use(
61 STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt',
62 asyncMiddleware(getVideoCaption)
63)
64
58// robots.txt service 65// robots.txt service
59staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => { 66staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => {
60 res.type('text/plain') 67 res.type('text/plain')
@@ -70,7 +77,17 @@ export {
70// --------------------------------------------------------------------------- 77// ---------------------------------------------------------------------------
71 78
72async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) { 79async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) {
73 const path = await VideosPreviewCache.Instance.getPreviewPath(req.params.uuid) 80 const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
81 if (!path) return res.sendStatus(404)
82
83 return res.sendFile(path, { maxAge: STATIC_MAX_AGE })
84}
85
86async function getVideoCaption (req: express.Request, res: express.Response) {
87 const path = await VideosCaptionCache.Instance.getFilePath({
88 videoId: req.params.videoId,
89 language: req.params.captionLanguage
90 })
74 if (!path) return res.sendStatus(404) 91 if (!path) return res.sendStatus(404)
75 92
76 return res.sendFile(path, { maxAge: STATIC_MAX_AGE }) 93 return res.sendFile(path, { maxAge: STATIC_MAX_AGE })
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts
index 37a251697..c49142a04 100644
--- a/server/helpers/activitypub.ts
+++ b/server/helpers/activitypub.ts
@@ -18,6 +18,7 @@ function activityPubContextify <T> (data: T) {
18 uuid: 'http://schema.org/identifier', 18 uuid: 'http://schema.org/identifier',
19 category: 'http://schema.org/category', 19 category: 'http://schema.org/category',
20 licence: 'http://schema.org/license', 20 licence: 'http://schema.org/license',
21 subtitleLanguage: 'http://schema.org/subtitleLanguage',
21 sensitive: 'as:sensitive', 22 sensitive: 'as:sensitive',
22 language: 'http://schema.org/inLanguage', 23 language: 'http://schema.org/inLanguage',
23 views: 'http://schema.org/Number', 24 views: 'http://schema.org/Number',
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index 37c90a0c8..d97bbd2a9 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -51,6 +51,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
51 if (!setValidRemoteVideoUrls(video)) return false 51 if (!setValidRemoteVideoUrls(video)) return false
52 if (!setRemoteVideoTruncatedContent(video)) return false 52 if (!setRemoteVideoTruncatedContent(video)) return false
53 if (!setValidAttributedTo(video)) return false 53 if (!setValidAttributedTo(video)) return false
54 if (!setValidRemoteCaptions(video)) return false
54 55
55 // Default attributes 56 // Default attributes
56 if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED 57 if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
@@ -98,6 +99,18 @@ function setValidRemoteTags (video: any) {
98 return true 99 return true
99} 100}
100 101
102function setValidRemoteCaptions (video: any) {
103 if (!video.subtitleLanguage) video.subtitleLanguage = []
104
105 if (Array.isArray(video.subtitleLanguage) === false) return false
106
107 video.subtitleLanguage = video.subtitleLanguage.filter(caption => {
108 return isRemoteStringIdentifierValid(caption)
109 })
110
111 return true
112}
113
101function isRemoteNumberIdentifierValid (data: any) { 114function isRemoteNumberIdentifierValid (data: any) {
102 return validator.isInt(data.identifier, { min: 0 }) 115 return validator.isInt(data.identifier, { min: 0 })
103} 116}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
new file mode 100644
index 000000000..fd4dc740b
--- /dev/null
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -0,0 +1,41 @@
1import { CONSTRAINTS_FIELDS, VIDEO_LANGUAGES } from '../../initializers'
2import { exists, isFileValid } from './misc'
3import { Response } from 'express'
4import { VideoModel } from '../../models/video/video'
5import { VideoCaptionModel } from '../../models/video/video-caption'
6
7function isVideoCaptionLanguageValid (value: any) {
8 return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined
9}
10
11const videoCaptionTypes = CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
12 .map(v => v.replace('.', ''))
13 .join('|')
14const videoCaptionsTypesRegex = `text/(${videoCaptionTypes})`
15
16function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
17 return isFileValid(files, videoCaptionsTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
18}
19
20async function isVideoCaptionExist (video: VideoModel, language: string, res: Response) {
21 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, language)
22
23 if (!videoCaption) {
24 res.status(404)
25 .json({ error: 'Video caption not found' })
26 .end()
27
28 return false
29 }
30
31 res.locals.videoCaption = videoCaption
32 return true
33}
34
35// ---------------------------------------------------------------------------
36
37export {
38 isVideoCaptionFile,
39 isVideoCaptionLanguageValid,
40 isVideoCaptionExist
41}
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 672f06dc0..b5cb126d9 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -126,6 +126,29 @@ function isVideoFileSizeValid (value: string) {
126 return exists(value) && validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.FILE_SIZE) 126 return exists(value) && validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.FILE_SIZE)
127} 127}
128 128
129function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: Response) {
130 // Retrieve the user who did the request
131 if (video.isOwned() === false) {
132 res.status(403)
133 .json({ error: 'Cannot manage a video of another server.' })
134 .end()
135 return false
136 }
137
138 // Check if the user can delete the video
139 // The user can delete it if he has the right
140 // Or if s/he is the video's account
141 const account = video.VideoChannel.Account
142 if (user.hasRight(right) === false && account.userId !== user.id) {
143 res.status(403)
144 .json({ error: 'Cannot manage a video of another user.' })
145 .end()
146 return false
147 }
148
149 return true
150}
151
129async function isVideoExist (id: string, res: Response) { 152async function isVideoExist (id: string, res: Response) {
130 let video: VideoModel 153 let video: VideoModel
131 154
@@ -179,6 +202,7 @@ async function isVideoChannelOfAccountExist (channelId: number, user: UserModel,
179 202
180export { 203export {
181 isVideoCategoryValid, 204 isVideoCategoryValid,
205 checkUserCanManageVideo,
182 isVideoLicenceValid, 206 isVideoLicenceValid,
183 isVideoLanguageValid, 207 isVideoLanguageValid,
184 isVideoTruncatedDescriptionValid, 208 isVideoTruncatedDescriptionValid,
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index c5bc886d8..49809e64c 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -138,6 +138,7 @@ const CONFIG = {
138 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')), 138 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')),
139 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')), 139 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
140 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')), 140 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
141 CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
141 TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')), 142 TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')),
142 CACHE_DIR: buildPath(config.get<string>('storage.cache')) 143 CACHE_DIR: buildPath(config.get<string>('storage.cache'))
143 }, 144 },
@@ -183,6 +184,9 @@ const CONFIG = {
183 CACHE: { 184 CACHE: {
184 PREVIEWS: { 185 PREVIEWS: {
185 get SIZE () { return config.get<number>('cache.previews.size') } 186 get SIZE () { return config.get<number>('cache.previews.size') }
187 },
188 VIDEO_CAPTIONS: {
189 get SIZE () { return config.get<number>('cache.captions.size') }
186 } 190 }
187 }, 191 },
188 INSTANCE: { 192 INSTANCE: {
@@ -225,6 +229,14 @@ const CONSTRAINTS_FIELDS = {
225 SUPPORT: { min: 3, max: 500 }, // Length 229 SUPPORT: { min: 3, max: 500 }, // Length
226 URL: { min: 3, max: 2000 } // Length 230 URL: { min: 3, max: 2000 } // Length
227 }, 231 },
232 VIDEO_CAPTIONS: {
233 CAPTION_FILE: {
234 EXTNAME: [ '.vtt' ],
235 FILE_SIZE: {
236 max: 2 * 1024 * 1024 // 2MB
237 }
238 }
239 },
228 VIDEOS: { 240 VIDEOS: {
229 NAME: { min: 3, max: 120 }, // Length 241 NAME: { min: 3, max: 120 }, // Length
230 LANGUAGE: { min: 1, max: 10 }, // Length 242 LANGUAGE: { min: 1, max: 10 }, // Length
@@ -351,6 +363,10 @@ const IMAGE_MIMETYPE_EXT = {
351 'image/jpeg': '.jpg' 363 'image/jpeg': '.jpg'
352} 364}
353 365
366const VIDEO_CAPTIONS_MIMETYPE_EXT = {
367 'text/vtt': '.vtt'
368}
369
354// --------------------------------------------------------------------------- 370// ---------------------------------------------------------------------------
355 371
356const SERVER_ACTOR_NAME = 'peertube' 372const SERVER_ACTOR_NAME = 'peertube'
@@ -403,7 +419,8 @@ const STATIC_PATHS = {
403 THUMBNAILS: '/static/thumbnails/', 419 THUMBNAILS: '/static/thumbnails/',
404 TORRENTS: '/static/torrents/', 420 TORRENTS: '/static/torrents/',
405 WEBSEED: '/static/webseed/', 421 WEBSEED: '/static/webseed/',
406 AVATARS: '/static/avatars/' 422 AVATARS: '/static/avatars/',
423 VIDEO_CAPTIONS: '/static/video-captions/'
407} 424}
408const STATIC_DOWNLOAD_PATHS = { 425const STATIC_DOWNLOAD_PATHS = {
409 TORRENTS: '/download/torrents/', 426 TORRENTS: '/download/torrents/',
@@ -435,7 +452,8 @@ const EMBED_SIZE = {
435// Sub folders of cache directory 452// Sub folders of cache directory
436const CACHE = { 453const CACHE = {
437 DIRECTORIES: { 454 DIRECTORIES: {
438 PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews') 455 PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
456 VIDEO_CAPTIONS: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions')
439 } 457 }
440} 458}
441 459
@@ -490,6 +508,7 @@ updateWebserverConfig()
490 508
491export { 509export {
492 API_VERSION, 510 API_VERSION,
511 VIDEO_CAPTIONS_MIMETYPE_EXT,
493 AVATARS_SIZE, 512 AVATARS_SIZE,
494 ACCEPT_HEADERS, 513 ACCEPT_HEADERS,
495 BCRYPT_SALT_SIZE, 514 BCRYPT_SALT_SIZE,
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 4d90c90fc..434d7ef19 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -23,6 +23,7 @@ import { VideoShareModel } from '../models/video/video-share'
23import { VideoTagModel } from '../models/video/video-tag' 23import { VideoTagModel } from '../models/video/video-tag'
24import { CONFIG } from './constants' 24import { CONFIG } from './constants'
25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' 25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
26import { VideoCaptionModel } from '../models/video/video-caption'
26 27
27require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 28require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
28 29
@@ -71,6 +72,7 @@ async function initDatabaseModels (silent: boolean) {
71 VideoChannelModel, 72 VideoChannelModel,
72 VideoShareModel, 73 VideoShareModel,
73 VideoFileModel, 74 VideoFileModel,
75 VideoCaptionModel,
74 VideoBlacklistModel, 76 VideoBlacklistModel,
75 VideoTagModel, 77 VideoTagModel,
76 VideoModel, 78 VideoModel,
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
index 73db461c3..62791ff1b 100644
--- a/server/lib/activitypub/process/process-update.ts
+++ b/server/lib/activitypub/process/process-update.ts
@@ -19,6 +19,7 @@ import {
19 videoFileActivityUrlToDBAttributes 19 videoFileActivityUrlToDBAttributes
20} from '../videos' 20} from '../videos'
21import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' 21import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
22import { VideoCaptionModel } from '../../../models/video/video-caption'
22 23
23async function processUpdateActivity (activity: ActivityUpdate) { 24async function processUpdateActivity (activity: ActivityUpdate) {
24 const actor = await getOrCreateActorAndServerAndModel(activity.actor) 25 const actor = await getOrCreateActorAndServerAndModel(activity.actor)
@@ -110,9 +111,18 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
110 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f)) 111 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f))
111 await Promise.all(tasks) 112 await Promise.all(tasks)
112 113
113 const tags = videoObject.tag.map(t => t.name) 114 // Update Tags
115 const tags = videoObject.tag.map(tag => tag.name)
114 const tagInstances = await TagModel.findOrCreateTags(tags, t) 116 const tagInstances = await TagModel.findOrCreateTags(tags, t)
115 await videoInstance.$set('Tags', tagInstances, sequelizeOptions) 117 await videoInstance.$set('Tags', tagInstances, sequelizeOptions)
118
119 // Update captions
120 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoInstance.id, t)
121
122 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
123 return VideoCaptionModel.insertOrReplaceLanguage(videoInstance.id, c.identifier, t)
124 })
125 await Promise.all(videoCaptionsPromises)
116 }) 126 })
117 127
118 logger.info('Remote video with uuid %s updated', videoObject.uuid) 128 logger.info('Remote video with uuid %s updated', videoObject.uuid)
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index a16828fda..fdc082b61 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -24,10 +24,20 @@ import { addVideoComments } from './video-comments'
24import { crawlCollectionPage } from './crawl' 24import { crawlCollectionPage } from './crawl'
25import { sendCreateVideo, sendUpdateVideo } from './send' 25import { sendCreateVideo, sendUpdateVideo } from './send'
26import { shareVideoByServerAndChannel } from './index' 26import { shareVideoByServerAndChannel } from './index'
27import { isArray } from '../../helpers/custom-validators/misc'
28import { VideoCaptionModel } from '../../models/video/video-caption'
27 29
28async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { 30async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
29 // If the video is not private and published, we federate it 31 // If the video is not private and published, we federate it
30 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) { 32 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
33 // Fetch more attributes that we will need to serialize in AP object
34 if (isArray(video.VideoCaptions) === false) {
35 video.VideoCaptions = await video.$get('VideoCaptions', {
36 attributes: [ 'language' ],
37 transaction
38 }) as VideoCaptionModel[]
39 }
40
31 if (isNewVideo === true) { 41 if (isNewVideo === true) {
32 // Now we'll add the video's meta data to our followers 42 // Now we'll add the video's meta data to our followers
33 await sendCreateVideo(video, transaction) 43 await sendCreateVideo(video, transaction)
@@ -38,9 +48,8 @@ async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, tr
38 } 48 }
39} 49}
40 50
41function fetchRemoteVideoPreview (video: VideoModel, reject: Function) { 51function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
42 const host = video.VideoChannel.Account.Actor.Server.host 52 const host = video.VideoChannel.Account.Actor.Server.host
43 const path = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
44 53
45 // We need to provide a callback, if no we could have an uncaught exception 54 // We need to provide a callback, if no we could have an uncaught exception
46 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => { 55 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
@@ -179,24 +188,32 @@ async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor:
179 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to) 188 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
180 const video = VideoModel.build(videoData) 189 const video = VideoModel.build(videoData)
181 190
182 // Don't block on request 191 // Don't block on remote HTTP request (we are in a transaction!)
183 generateThumbnailFromUrl(video, videoObject.icon) 192 generateThumbnailFromUrl(video, videoObject.icon)
184 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })) 193 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
185 194
186 const videoCreated = await video.save(sequelizeOptions) 195 const videoCreated = await video.save(sequelizeOptions)
187 196
197 // Process files
188 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject) 198 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
189 if (videoFileAttributes.length === 0) { 199 if (videoFileAttributes.length === 0) {
190 throw new Error('Cannot find valid files for video %s ' + videoObject.url) 200 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
191 } 201 }
192 202
193 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t })) 203 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
194 await Promise.all(tasks) 204 await Promise.all(videoFilePromises)
195 205
206 // Process tags
196 const tags = videoObject.tag.map(t => t.name) 207 const tags = videoObject.tag.map(t => t.name)
197 const tagInstances = await TagModel.findOrCreateTags(tags, t) 208 const tagInstances = await TagModel.findOrCreateTags(tags, t)
198 await videoCreated.$set('Tags', tagInstances, sequelizeOptions) 209 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
199 210
211 // Process captions
212 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
213 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
214 })
215 await Promise.all(videoCaptionsPromises)
216
200 logger.info('Remote video with uuid %s inserted.', videoObject.uuid) 217 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
201 218
202 videoCreated.VideoChannel = channelActor.VideoChannel 219 videoCreated.VideoChannel = channelActor.VideoChannel
@@ -328,7 +345,7 @@ export {
328 federateVideoIfNeeded, 345 federateVideoIfNeeded,
329 fetchRemoteVideo, 346 fetchRemoteVideo,
330 getOrCreateAccountAndVideoAndChannel, 347 getOrCreateAccountAndVideoAndChannel,
331 fetchRemoteVideoPreview, 348 fetchRemoteVideoStaticFile,
332 fetchRemoteVideoDescription, 349 fetchRemoteVideoDescription,
333 generateThumbnailFromUrl, 350 generateThumbnailFromUrl,
334 videoActivityObjectToDBAttributes, 351 videoActivityObjectToDBAttributes,
diff --git a/server/lib/cache/abstract-video-static-file-cache.ts b/server/lib/cache/abstract-video-static-file-cache.ts
new file mode 100644
index 000000000..7eeeb6b3a
--- /dev/null
+++ b/server/lib/cache/abstract-video-static-file-cache.ts
@@ -0,0 +1,54 @@
1import * as AsyncLRU from 'async-lru'
2import { createWriteStream } from 'fs'
3import { join } from 'path'
4import { unlinkPromise } from '../../helpers/core-utils'
5import { logger } from '../../helpers/logger'
6import { CACHE, CONFIG } from '../../initializers'
7import { VideoModel } from '../../models/video/video'
8import { fetchRemoteVideoStaticFile } from '../activitypub'
9import { VideoCaptionModel } from '../../models/video/video-caption'
10
11export abstract class AbstractVideoStaticFileCache <T> {
12
13 protected lru
14
15 abstract getFilePath (params: T): Promise<string>
16
17 // Load and save the remote file, then return the local path from filesystem
18 protected abstract loadRemoteFile (key: string): Promise<string>
19
20 init (max: number) {
21 this.lru = new AsyncLRU({
22 max,
23 load: (key, cb) => {
24 this.loadRemoteFile(key)
25 .then(res => cb(null, res))
26 .catch(err => cb(err))
27 }
28 })
29
30 this.lru.on('evict', (obj: { key: string, value: string }) => {
31 unlinkPromise(obj.value).then(() => logger.debug('%s evicted from %s', obj.value, this.constructor.name))
32 })
33 }
34
35 protected loadFromLRU (key: string) {
36 return new Promise<string>((res, rej) => {
37 this.lru.get(key, (err, value) => {
38 err ? rej(err) : res(value)
39 })
40 })
41 }
42
43 protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {
44 return new Promise<string>((res, rej) => {
45 const req = fetchRemoteVideoStaticFile(video, remoteStaticPath, rej)
46
47 const stream = createWriteStream(destPath)
48
49 req.pipe(stream)
50 .on('error', (err) => rej(err))
51 .on('finish', () => res(destPath))
52 })
53 }
54}
diff --git a/server/lib/cache/videos-caption-cache.ts b/server/lib/cache/videos-caption-cache.ts
new file mode 100644
index 000000000..1336610b2
--- /dev/null
+++ b/server/lib/cache/videos-caption-cache.ts
@@ -0,0 +1,53 @@
1import { join } from 'path'
2import { CACHE, CONFIG } from '../../initializers'
3import { VideoModel } from '../../models/video/video'
4import { VideoCaptionModel } from '../../models/video/video-caption'
5import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
6
7type GetPathParam = { videoId: string, language: string }
8
9class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
10
11 private static readonly KEY_DELIMITER = '%'
12 private static instance: VideosCaptionCache
13
14 private constructor () {
15 super()
16 }
17
18 static get Instance () {
19 return this.instance || (this.instance = new this())
20 }
21
22 async getFilePath (params: GetPathParam) {
23 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
24 if (!videoCaption) return undefined
25
26 if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
27
28 const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language
29 return this.loadFromLRU(key)
30 }
31
32 protected async loadRemoteFile (key: string) {
33 const [ videoId, language ] = key.split(VideosCaptionCache.KEY_DELIMITER)
34
35 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(videoId, language)
36 if (!videoCaption) return undefined
37
38 if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
39
40 // Used to fetch the path
41 const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
42 if (!video) return undefined
43
44 const remoteStaticPath = videoCaption.getCaptionStaticPath()
45 const destPath = join(CACHE.DIRECTORIES.VIDEO_CAPTIONS, videoCaption.getCaptionName())
46
47 return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
48 }
49}
50
51export {
52 VideosCaptionCache
53}
diff --git a/server/lib/cache/videos-preview-cache.ts b/server/lib/cache/videos-preview-cache.ts
index d09d55e11..1c0e7ed9d 100644
--- a/server/lib/cache/videos-preview-cache.ts
+++ b/server/lib/cache/videos-preview-cache.ts
@@ -1,71 +1,39 @@
1import * as asyncLRU from 'async-lru'
2import { createWriteStream } from 'fs'
3import { join } from 'path' 1import { join } from 'path'
4import { unlinkPromise } from '../../helpers/core-utils' 2import { CACHE, CONFIG, STATIC_PATHS } from '../../initializers'
5import { logger } from '../../helpers/logger'
6import { CACHE, CONFIG } from '../../initializers'
7import { VideoModel } from '../../models/video/video' 3import { VideoModel } from '../../models/video/video'
8import { fetchRemoteVideoPreview } from '../activitypub' 4import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
9 5
10class VideosPreviewCache { 6class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
11 7
12 private static instance: VideosPreviewCache 8 private static instance: VideosPreviewCache
13 9
14 private lru 10 private constructor () {
15 11 super()
16 private constructor () { } 12 }
17 13
18 static get Instance () { 14 static get Instance () {
19 return this.instance || (this.instance = new this()) 15 return this.instance || (this.instance = new this())
20 } 16 }
21 17
22 init (max: number) { 18 async getFilePath (videoUUID: string) {
23 this.lru = new asyncLRU({ 19 const video = await VideoModel.loadByUUID(videoUUID)
24 max,
25 load: (key, cb) => {
26 this.loadPreviews(key)
27 .then(res => cb(null, res))
28 .catch(err => cb(err))
29 }
30 })
31
32 this.lru.on('evict', (obj: { key: string, value: string }) => {
33 unlinkPromise(obj.value).then(() => logger.debug('%s evicted from VideosPreviewCache', obj.value))
34 })
35 }
36
37 async getPreviewPath (key: string) {
38 const video = await VideoModel.loadByUUID(key)
39 if (!video) return undefined 20 if (!video) return undefined
40 21
41 if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()) 22 if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
42 23
43 return new Promise<string>((res, rej) => { 24 return this.loadFromLRU(videoUUID)
44 this.lru.get(key, (err, value) => {
45 err ? rej(err) : res(value)
46 })
47 })
48 } 25 }
49 26
50 private async loadPreviews (key: string) { 27 protected async loadRemoteFile (key: string) {
51 const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key) 28 const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
52 if (!video) return undefined 29 if (!video) return undefined
53 30
54 if (video.isOwned()) throw new Error('Cannot load preview of owned video.') 31 if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
55
56 return this.saveRemotePreviewAndReturnPath(video)
57 }
58 32
59 private saveRemotePreviewAndReturnPath (video: VideoModel) { 33 const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
60 return new Promise<string>((res, rej) => { 34 const destPath = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
61 const req = fetchRemoteVideoPreview(video, rej)
62 const path = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
63 const stream = createWriteStream(path)
64 35
65 req.pipe(stream) 36 return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
66 .on('error', (err) => rej(err))
67 .on('finish', () => res(path))
68 })
69 } 37 }
70} 38}
71 39
diff --git a/server/middlewares/validators/video-captions.ts b/server/middlewares/validators/video-captions.ts
new file mode 100644
index 000000000..b6d92d380
--- /dev/null
+++ b/server/middlewares/validators/video-captions.ts
@@ -0,0 +1,70 @@
1import * as express from 'express'
2import { areValidationErrors } from './utils'
3import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos'
4import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
5import { body, param } from 'express-validator/check'
6import { CONSTRAINTS_FIELDS } from '../../initializers'
7import { UserRight } from '../../../shared'
8import { logger } from '../../helpers/logger'
9import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
10
11const addVideoCaptionValidator = [
12 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
13 param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
14 body('captionfile')
15 .custom((value, { req }) => isVideoCaptionFile(req.files, 'captionfile')).withMessage(
16 'This caption file is not supported or too large. Please, make sure it is of the following type : '
17 + CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME.join(', ')
18 ),
19
20 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
21 logger.debug('Checking addVideoCaption parameters', { parameters: req.body })
22
23 if (areValidationErrors(req, res)) return
24 if (!await isVideoExist(req.params.videoId, res)) return
25
26 // Check if the user who did the request is able to update the video
27 const user = res.locals.oauth.token.User
28 if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
29
30 return next()
31 }
32]
33
34const deleteVideoCaptionValidator = [
35 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
36 param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
37
38 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
39 logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params })
40
41 if (areValidationErrors(req, res)) return
42 if (!await isVideoExist(req.params.videoId, res)) return
43 if (!await isVideoCaptionExist(res.locals.video, req.params.captionLanguage, res)) return
44
45 // Check if the user who did the request is able to update the video
46 const user = res.locals.oauth.token.User
47 if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
48
49 return next()
50 }
51]
52
53const listVideoCaptionsValidator = [
54 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
55
56 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
57 logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
58
59 if (areValidationErrors(req, res)) return
60 if (!await isVideoExist(req.params.videoId, res)) return
61
62 return next()
63 }
64]
65
66export {
67 addVideoCaptionValidator,
68 listVideoCaptionsValidator,
69 deleteVideoCaptionValidator
70}
diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts
index 59d65d5a4..899def6fc 100644
--- a/server/middlewares/validators/videos.ts
+++ b/server/middlewares/validators/videos.ts
@@ -12,6 +12,7 @@ import {
12 toValueOrNull 12 toValueOrNull
13} from '../../helpers/custom-validators/misc' 13} from '../../helpers/custom-validators/misc'
14import { 14import {
15 checkUserCanManageVideo,
15 isScheduleVideoUpdatePrivacyValid, 16 isScheduleVideoUpdatePrivacyValid,
16 isVideoAbuseReasonValid, 17 isVideoAbuseReasonValid,
17 isVideoCategoryValid, 18 isVideoCategoryValid,
@@ -31,8 +32,6 @@ import {
31import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' 32import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils'
32import { logger } from '../../helpers/logger' 33import { logger } from '../../helpers/logger'
33import { CONSTRAINTS_FIELDS } from '../../initializers' 34import { CONSTRAINTS_FIELDS } from '../../initializers'
34import { UserModel } from '../../models/account/user'
35import { VideoModel } from '../../models/video/video'
36import { VideoShareModel } from '../../models/video/video-share' 35import { VideoShareModel } from '../../models/video/video-share'
37import { authenticate } from '../oauth' 36import { authenticate } from '../oauth'
38import { areValidationErrors } from './utils' 37import { areValidationErrors } from './utils'
@@ -40,17 +39,17 @@ import { areValidationErrors } from './utils'
40const videosAddValidator = [ 39const videosAddValidator = [
41 body('videofile') 40 body('videofile')
42 .custom((value, { req }) => isVideoFile(req.files)).withMessage( 41 .custom((value, { req }) => isVideoFile(req.files)).withMessage(
43 'This file is not supported or too large. Please, make sure it is of the following type : ' 42 'This file is not supported or too large. Please, make sure it is of the following type: '
44 + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ') 43 + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
45 ), 44 ),
46 body('thumbnailfile') 45 body('thumbnailfile')
47 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( 46 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
48 'This thumbnail file is not supported or too large. Please, make sure it is of the following type : ' 47 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
49 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 48 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
50 ), 49 ),
51 body('previewfile') 50 body('previewfile')
52 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( 51 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
53 'This preview file is not supported or too large. Please, make sure it is of the following type : ' 52 'This preview file is not supported or too large. Please, make sure it is of the following type: '
54 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 53 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
55 ), 54 ),
56 body('name').custom(isVideoNameValid).withMessage('Should have a valid name'), 55 body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
@@ -152,12 +151,12 @@ const videosUpdateValidator = [
152 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 151 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
153 body('thumbnailfile') 152 body('thumbnailfile')
154 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( 153 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
155 'This thumbnail file is not supported or too large. Please, make sure it is of the following type : ' 154 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
156 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 155 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
157 ), 156 ),
158 body('previewfile') 157 body('previewfile')
159 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( 158 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
160 'This preview file is not supported or too large. Please, make sure it is of the following type : ' 159 'This preview file is not supported or too large. Please, make sure it is of the following type: '
161 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 160 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
162 ), 161 ),
163 body('name') 162 body('name')
@@ -373,29 +372,6 @@ export {
373 372
374// --------------------------------------------------------------------------- 373// ---------------------------------------------------------------------------
375 374
376function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: express.Response) {
377 // Retrieve the user who did the request
378 if (video.isOwned() === false) {
379 res.status(403)
380 .json({ error: 'Cannot manage a video of another server.' })
381 .end()
382 return false
383 }
384
385 // Check if the user can delete the video
386 // The user can delete it if he has the right
387 // Or if s/he is the video's account
388 const account = video.VideoChannel.Account
389 if (user.hasRight(right) === false && account.userId !== user.id) {
390 res.status(403)
391 .json({ error: 'Cannot manage a video of another user.' })
392 .end()
393 return false
394 }
395
396 return true
397}
398
399function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) { 375function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) {
400 // Files are optional 376 // Files are optional
401 if (!req.files) return false 377 if (!req.files) return false
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
new file mode 100644
index 000000000..9920dfc7c
--- /dev/null
+++ b/server/models/video/video-caption.ts
@@ -0,0 +1,173 @@
1import * as Sequelize from 'sequelize'
2import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 ForeignKey,
9 Is,
10 Model,
11 Scopes,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { throwIfNotValid } from '../utils'
16import { VideoModel } from './video'
17import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
18import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
19import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers'
20import { join } from 'path'
21import { logger } from '../../helpers/logger'
22import { unlinkPromise } from '../../helpers/core-utils'
23
24export enum ScopeNames {
25 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
26}
27
28@Scopes({
29 [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
30 include: [
31 {
32 attributes: [ 'uuid', 'remote' ],
33 model: () => VideoModel.unscoped(),
34 required: true
35 }
36 ]
37 }
38})
39
40@Table({
41 tableName: 'videoCaption',
42 indexes: [
43 {
44 fields: [ 'videoId' ]
45 },
46 {
47 fields: [ 'videoId', 'language' ],
48 unique: true
49 }
50 ]
51})
52export class VideoCaptionModel extends Model<VideoCaptionModel> {
53 @CreatedAt
54 createdAt: Date
55
56 @UpdatedAt
57 updatedAt: Date
58
59 @AllowNull(false)
60 @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
61 @Column
62 language: string
63
64 @ForeignKey(() => VideoModel)
65 @Column
66 videoId: number
67
68 @BelongsTo(() => VideoModel, {
69 foreignKey: {
70 allowNull: false
71 },
72 onDelete: 'CASCADE'
73 })
74 Video: VideoModel
75
76 @BeforeDestroy
77 static async removeFiles (instance: VideoCaptionModel) {
78
79 if (instance.isOwned()) {
80 if (!instance.Video) {
81 instance.Video = await instance.$get('Video') as VideoModel
82 }
83
84 logger.debug('Removing captions %s of video %s.', instance.Video.uuid, instance.language)
85 return instance.removeCaptionFile()
86 }
87
88 return undefined
89 }
90
91 static loadByVideoIdAndLanguage (videoId: string | number, language: string) {
92 const videoInclude = {
93 model: VideoModel.unscoped(),
94 attributes: [ 'id', 'remote', 'uuid' ],
95 where: { }
96 }
97
98 if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId
99 else videoInclude.where['id'] = videoId
100
101 const query = {
102 where: {
103 language
104 },
105 include: [
106 videoInclude
107 ]
108 }
109
110 return VideoCaptionModel.findOne(query)
111 }
112
113 static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) {
114 const values = {
115 videoId,
116 language
117 }
118
119 return VideoCaptionModel.upsert(values, { transaction })
120 }
121
122 static listVideoCaptions (videoId: number) {
123 const query = {
124 order: [ [ 'language', 'ASC' ] ],
125 where: {
126 videoId
127 }
128 }
129
130 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
131 }
132
133 static getLanguageLabel (language: string) {
134 return VIDEO_LANGUAGES[language] || 'Unknown'
135 }
136
137 static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) {
138 const query = {
139 where: {
140 videoId
141 },
142 transaction
143 }
144
145 return VideoCaptionModel.destroy(query)
146 }
147
148 isOwned () {
149 return this.Video.remote === false
150 }
151
152 toFormattedJSON (): VideoCaption {
153 return {
154 language: {
155 id: this.language,
156 label: VideoCaptionModel.getLanguageLabel(this.language)
157 },
158 captionPath: this.getCaptionStaticPath()
159 }
160 }
161
162 getCaptionStaticPath () {
163 return join(STATIC_PATHS.VIDEO_CAPTIONS, this.getCaptionName())
164 }
165
166 getCaptionName () {
167 return `${this.Video.uuid}-${this.language}.vtt`
168 }
169
170 removeCaptionFile () {
171 return unlinkPromise(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName())
172 }
173}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index ab33b7c99..74a3a5d05 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -92,6 +92,7 @@ import { VideoFileModel } from './video-file'
92import { VideoShareModel } from './video-share' 92import { VideoShareModel } from './video-share'
93import { VideoTagModel } from './video-tag' 93import { VideoTagModel } from './video-tag'
94import { ScheduleVideoUpdateModel } from './schedule-video-update' 94import { ScheduleVideoUpdateModel } from './schedule-video-update'
95import { VideoCaptionModel } from './video-caption'
95 96
96export enum ScopeNames { 97export enum ScopeNames {
97 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 98 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
@@ -526,6 +527,17 @@ export class VideoModel extends Model<VideoModel> {
526 }) 527 })
527 ScheduleVideoUpdate: ScheduleVideoUpdateModel 528 ScheduleVideoUpdate: ScheduleVideoUpdateModel
528 529
530 @HasMany(() => VideoCaptionModel, {
531 foreignKey: {
532 name: 'videoId',
533 allowNull: false
534 },
535 onDelete: 'cascade',
536 hooks: true,
537 ['separate' as any]: true
538 })
539 VideoCaptions: VideoCaptionModel[]
540
529 @BeforeDestroy 541 @BeforeDestroy
530 static async sendDelete (instance: VideoModel, options) { 542 static async sendDelete (instance: VideoModel, options) {
531 if (instance.isOwned()) { 543 if (instance.isOwned()) {
@@ -550,7 +562,7 @@ export class VideoModel extends Model<VideoModel> {
550 } 562 }
551 563
552 @BeforeDestroy 564 @BeforeDestroy
553 static async removeFilesAndSendDelete (instance: VideoModel) { 565 static async removeFiles (instance: VideoModel) {
554 const tasks: Promise<any>[] = [] 566 const tasks: Promise<any>[] = []
555 567
556 logger.debug('Removing files of video %s.', instance.url) 568 logger.debug('Removing files of video %s.', instance.url)
@@ -616,6 +628,11 @@ export class VideoModel extends Model<VideoModel> {
616 }, 628 },
617 include: [ 629 include: [
618 { 630 {
631 attributes: [ 'language' ],
632 model: VideoCaptionModel.unscoped(),
633 required: false
634 },
635 {
619 attributes: [ 'id', 'url' ], 636 attributes: [ 'id', 'url' ],
620 model: VideoShareModel.unscoped(), 637 model: VideoShareModel.unscoped(),
621 required: false, 638 required: false,
@@ -1028,15 +1045,15 @@ export class VideoModel extends Model<VideoModel> {
1028 videoFile.infoHash = parsedTorrent.infoHash 1045 videoFile.infoHash = parsedTorrent.infoHash
1029 } 1046 }
1030 1047
1031 getEmbedPath () { 1048 getEmbedStaticPath () {
1032 return '/videos/embed/' + this.uuid 1049 return '/videos/embed/' + this.uuid
1033 } 1050 }
1034 1051
1035 getThumbnailPath () { 1052 getThumbnailStaticPath () {
1036 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) 1053 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
1037 } 1054 }
1038 1055
1039 getPreviewPath () { 1056 getPreviewStaticPath () {
1040 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) 1057 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1041 } 1058 }
1042 1059
@@ -1077,9 +1094,9 @@ export class VideoModel extends Model<VideoModel> {
1077 views: this.views, 1094 views: this.views,
1078 likes: this.likes, 1095 likes: this.likes,
1079 dislikes: this.dislikes, 1096 dislikes: this.dislikes,
1080 thumbnailPath: this.getThumbnailPath(), 1097 thumbnailPath: this.getThumbnailStaticPath(),
1081 previewPath: this.getPreviewPath(), 1098 previewPath: this.getPreviewStaticPath(),
1082 embedPath: this.getEmbedPath(), 1099 embedPath: this.getEmbedStaticPath(),
1083 createdAt: this.createdAt, 1100 createdAt: this.createdAt,
1084 updatedAt: this.updatedAt, 1101 updatedAt: this.updatedAt,
1085 publishedAt: this.publishedAt, 1102 publishedAt: this.publishedAt,
@@ -1247,6 +1264,14 @@ export class VideoModel extends Model<VideoModel> {
1247 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid 1264 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1248 }) 1265 })
1249 1266
1267 const subtitleLanguage = []
1268 for (const caption of this.VideoCaptions) {
1269 subtitleLanguage.push({
1270 identifier: caption.language,
1271 name: VideoCaptionModel.getLanguageLabel(caption.language)
1272 })
1273 }
1274
1250 return { 1275 return {
1251 type: 'Video' as 'Video', 1276 type: 'Video' as 'Video',
1252 id: this.url, 1277 id: this.url,
@@ -1267,6 +1292,7 @@ export class VideoModel extends Model<VideoModel> {
1267 mediaType: 'text/markdown', 1292 mediaType: 'text/markdown',
1268 content: this.getTruncatedDescription(), 1293 content: this.getTruncatedDescription(),
1269 support: this.support, 1294 support: this.support,
1295 subtitleLanguage,
1270 icon: { 1296 icon: {
1271 type: 'Image', 1297 type: 'Image',
1272 url: this.getThumbnailUrl(baseUrlHttp), 1298 url: this.getThumbnailUrl(baseUrlHttp),
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 6aa31e38d..03855237f 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -35,6 +35,9 @@ describe('Test config API validators', function () {
35 cache: { 35 cache: {
36 previews: { 36 previews: {
37 size: 2 37 size: 2
38 },
39 captions: {
40 size: 3
38 } 41 }
39 }, 42 },
40 signup: { 43 signup: {
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 4c3b372f5..c0e0302df 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -6,6 +6,7 @@ import './services'
6import './users' 6import './users'
7import './video-abuses' 7import './video-abuses'
8import './video-blacklist' 8import './video-blacklist'
9import './video-captions'
9import './video-channels' 10import './video-channels'
10import './video-comments' 11import './video-comments'
11import './videos' 12import './videos'
diff --git a/server/tests/api/check-params/video-captions.ts b/server/tests/api/check-params/video-captions.ts
new file mode 100644
index 000000000..12f890db8
--- /dev/null
+++ b/server/tests/api/check-params/video-captions.ts
@@ -0,0 +1,223 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 createUser,
7 flushTests,
8 killallServers,
9 makeDeleteRequest,
10 makeGetRequest,
11 makeUploadRequest,
12 runServer,
13 ServerInfo,
14 setAccessTokensToServers,
15 uploadVideo,
16 userLogin
17} from '../../utils'
18import { join } from 'path'
19
20describe('Test video captions API validator', function () {
21 const path = '/api/v1/videos/'
22
23 let server: ServerInfo
24 let userAccessToken: string
25 let videoUUID: string
26
27 // ---------------------------------------------------------------
28
29 before(async function () {
30 this.timeout(30000)
31
32 await flushTests()
33
34 server = await runServer(1)
35
36 await setAccessTokensToServers([ server ])
37
38 {
39 const res = await uploadVideo(server.url, server.accessToken, {})
40 videoUUID = res.body.video.uuid
41 }
42
43 {
44 const user = {
45 username: 'user1',
46 password: 'my super password'
47 }
48 await createUser(server.url, server.accessToken, user.username, user.password)
49 userAccessToken = await userLogin(server, user)
50 }
51 })
52
53 describe('When adding video caption', function () {
54 const fields = { }
55 const attaches = {
56 'captionfile': join(__dirname, '..', '..', 'fixtures', 'subtitle-good1.vtt')
57 }
58
59 it('Should fail without a valid uuid', async function () {
60 await makeUploadRequest({
61 method: 'PUT',
62 url: server.url,
63 path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions',
64 token: server.accessToken,
65 fields,
66 attaches
67 })
68 })
69
70 it('Should fail with an unknown id', async function () {
71 await makeUploadRequest({
72 method: 'PUT',
73 url: server.url,
74 path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions',
75 token: server.accessToken,
76 fields,
77 attaches
78 })
79 })
80
81 it('Should fail with a missing language in path', async function () {
82 const captionPath = path + videoUUID + '/captions'
83 await makeUploadRequest({
84 method: 'PUT',
85 url: server.url,
86 path: captionPath,
87 token: server.accessToken,
88 fields,
89 attaches
90 })
91 })
92
93 it('Should fail with an unknown language', async function () {
94 const captionPath = path + videoUUID + '/captions/15'
95 await makeUploadRequest({
96 method: 'PUT',
97 url: server.url,
98 path: captionPath,
99 token: server.accessToken,
100 fields,
101 attaches
102 })
103 })
104
105 it('Should fail without access token', async function () {
106 const captionPath = path + videoUUID + '/captions/fr'
107 await makeUploadRequest({
108 method: 'PUT',
109 url: server.url,
110 path: captionPath,
111 fields,
112 attaches,
113 statusCodeExpected: 401
114 })
115 })
116
117 it('Should fail with a bad access token', async function () {
118 const captionPath = path + videoUUID + '/captions/fr'
119 await makeUploadRequest({
120 method: 'PUT',
121 url: server.url,
122 path: captionPath,
123 token: 'blabla',
124 fields,
125 attaches,
126 statusCodeExpected: 401
127 })
128 })
129
130 it('Should success with the correct parameters', async function () {
131 const captionPath = path + videoUUID + '/captions/fr'
132 await makeUploadRequest({
133 method: 'PUT',
134 url: server.url,
135 path: captionPath,
136 token: server.accessToken,
137 fields,
138 attaches,
139 statusCodeExpected: 204
140 })
141 })
142 })
143
144 describe('When listing video captions', function () {
145 it('Should fail without a valid uuid', async function () {
146 await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions' })
147 })
148
149 it('Should fail with an unknown id', async function () {
150 await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions', statusCodeExpected: 404 })
151 })
152
153 it('Should success with the correct parameters', async function () {
154 await makeGetRequest({ url: server.url, path: path + videoUUID + '/captions', statusCodeExpected: 200 })
155 })
156 })
157
158 describe('When deleting video caption', function () {
159 it('Should fail without a valid uuid', async function () {
160 await makeDeleteRequest({
161 url: server.url,
162 path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions/fr',
163 token: server.accessToken
164 })
165 })
166
167 it('Should fail with an unknown id', async function () {
168 await makeDeleteRequest({
169 url: server.url,
170 path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/fr',
171 token: server.accessToken,
172 statusCodeExpected: 404
173 })
174 })
175
176 it('Should fail with an invalid language', async function () {
177 await makeDeleteRequest({
178 url: server.url,
179 path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/16',
180 token: server.accessToken
181 })
182 })
183
184 it('Should fail with a missing language', async function () {
185 const captionPath = path + videoUUID + '/captions'
186 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
187 })
188
189 it('Should fail with an unknown language', async function () {
190 const captionPath = path + videoUUID + '/captions/15'
191 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
192 })
193
194 it('Should fail without access token', async function () {
195 const captionPath = path + videoUUID + '/captions/fr'
196 await makeDeleteRequest({ url: server.url, path: captionPath, statusCodeExpected: 401 })
197 })
198
199 it('Should fail with a bad access token', async function () {
200 const captionPath = path + videoUUID + '/captions/fr'
201 await makeDeleteRequest({ url: server.url, path: captionPath, token: 'coucou', statusCodeExpected: 401 })
202 })
203
204 it('Should fail with another user', async function () {
205 const captionPath = path + videoUUID + '/captions/fr'
206 await makeDeleteRequest({ url: server.url, path: captionPath, token: userAccessToken, statusCodeExpected: 403 })
207 })
208
209 it('Should success with the correct parameters', async function () {
210 const captionPath = path + videoUUID + '/captions/fr'
211 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken, statusCodeExpected: 204 })
212 })
213 })
214
215 after(async function () {
216 killallServers([ server ])
217
218 // Keep the logs if the test failed
219 if (this['ok']) {
220 await flushTests()
221 }
222 })
223})
diff --git a/server/tests/api/index-fast.ts b/server/tests/api/index-fast.ts
index 2454ec2f9..d530dfc06 100644
--- a/server/tests/api/index-fast.ts
+++ b/server/tests/api/index-fast.ts
@@ -4,6 +4,7 @@ import './check-params'
4import './users/users' 4import './users/users'
5import './videos/single-server' 5import './videos/single-server'
6import './videos/video-abuse' 6import './videos/video-abuse'
7import './videos/video-captions'
7import './videos/video-blacklist' 8import './videos/video-blacklist'
8import './videos/video-blacklist-management' 9import './videos/video-blacklist-management'
9import './videos/video-description' 10import './videos/video-description'
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 4de0d6b10..79b5aaf2d 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -14,6 +14,61 @@ import {
14 registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig 14 registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig
15} from '../../utils/index' 15} from '../../utils/index'
16 16
17function checkInitialConfig (data: CustomConfig) {
18 expect(data.instance.name).to.equal('PeerTube')
19 expect(data.instance.shortDescription).to.equal(
20 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
21 'with WebTorrent and Angular.'
22 )
23 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
24 expect(data.instance.terms).to.equal('No terms for now.')
25 expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
26 expect(data.instance.defaultNSFWPolicy).to.equal('display')
27 expect(data.instance.customizations.css).to.be.empty
28 expect(data.instance.customizations.javascript).to.be.empty
29 expect(data.services.twitter.username).to.equal('@Chocobozzz')
30 expect(data.services.twitter.whitelisted).to.be.false
31 expect(data.cache.previews.size).to.equal(1)
32 expect(data.cache.captions.size).to.equal(1)
33 expect(data.signup.enabled).to.be.true
34 expect(data.signup.limit).to.equal(4)
35 expect(data.admin.email).to.equal('admin1@example.com')
36 expect(data.user.videoQuota).to.equal(5242880)
37 expect(data.transcoding.enabled).to.be.false
38 expect(data.transcoding.threads).to.equal(2)
39 expect(data.transcoding.resolutions['240p']).to.be.true
40 expect(data.transcoding.resolutions['360p']).to.be.true
41 expect(data.transcoding.resolutions['480p']).to.be.true
42 expect(data.transcoding.resolutions['720p']).to.be.true
43 expect(data.transcoding.resolutions['1080p']).to.be.true
44}
45
46function checkUpdatedConfig (data: CustomConfig) {
47 expect(data.instance.name).to.equal('PeerTube updated')
48 expect(data.instance.shortDescription).to.equal('my short description')
49 expect(data.instance.description).to.equal('my super description')
50 expect(data.instance.terms).to.equal('my super terms')
51 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
52 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
53 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
54 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
55 expect(data.services.twitter.username).to.equal('@Kuja')
56 expect(data.services.twitter.whitelisted).to.be.true
57 expect(data.cache.previews.size).to.equal(2)
58 expect(data.cache.captions.size).to.equal(3)
59 expect(data.signup.enabled).to.be.false
60 expect(data.signup.limit).to.equal(5)
61 expect(data.admin.email).to.equal('superadmin1@example.com')
62 expect(data.user.videoQuota).to.equal(5242881)
63 expect(data.transcoding.enabled).to.be.true
64 expect(data.transcoding.threads).to.equal(1)
65 expect(data.transcoding.resolutions['240p']).to.be.false
66 expect(data.transcoding.resolutions['360p']).to.be.true
67 expect(data.transcoding.resolutions['480p']).to.be.true
68 expect(data.transcoding.resolutions['720p']).to.be.false
69 expect(data.transcoding.resolutions['1080p']).to.be.false
70}
71
17describe('Test config', function () { 72describe('Test config', function () {
18 let server = null 73 let server = null
19 74
@@ -51,35 +106,11 @@ describe('Test config', function () {
51 const res = await getCustomConfig(server.url, server.accessToken) 106 const res = await getCustomConfig(server.url, server.accessToken)
52 const data = res.body as CustomConfig 107 const data = res.body as CustomConfig
53 108
54 expect(data.instance.name).to.equal('PeerTube') 109 checkInitialConfig(data)
55 expect(data.instance.shortDescription).to.equal(
56 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
57 'with WebTorrent and Angular.'
58 )
59 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
60 expect(data.instance.terms).to.equal('No terms for now.')
61 expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
62 expect(data.instance.defaultNSFWPolicy).to.equal('display')
63 expect(data.instance.customizations.css).to.be.empty
64 expect(data.instance.customizations.javascript).to.be.empty
65 expect(data.services.twitter.username).to.equal('@Chocobozzz')
66 expect(data.services.twitter.whitelisted).to.be.false
67 expect(data.cache.previews.size).to.equal(1)
68 expect(data.signup.enabled).to.be.true
69 expect(data.signup.limit).to.equal(4)
70 expect(data.admin.email).to.equal('admin1@example.com')
71 expect(data.user.videoQuota).to.equal(5242880)
72 expect(data.transcoding.enabled).to.be.false
73 expect(data.transcoding.threads).to.equal(2)
74 expect(data.transcoding.resolutions['240p']).to.be.true
75 expect(data.transcoding.resolutions['360p']).to.be.true
76 expect(data.transcoding.resolutions['480p']).to.be.true
77 expect(data.transcoding.resolutions['720p']).to.be.true
78 expect(data.transcoding.resolutions['1080p']).to.be.true
79 }) 110 })
80 111
81 it('Should update the customized configuration', async function () { 112 it('Should update the customized configuration', async function () {
82 const newCustomConfig = { 113 const newCustomConfig: CustomConfig = {
83 instance: { 114 instance: {
84 name: 'PeerTube updated', 115 name: 'PeerTube updated',
85 shortDescription: 'my short description', 116 shortDescription: 'my short description',
@@ -101,6 +132,9 @@ describe('Test config', function () {
101 cache: { 132 cache: {
102 previews: { 133 previews: {
103 size: 2 134 size: 2
135 },
136 captions: {
137 size: 3
104 } 138 }
105 }, 139 },
106 signup: { 140 signup: {
@@ -130,28 +164,7 @@ describe('Test config', function () {
130 const res = await getCustomConfig(server.url, server.accessToken) 164 const res = await getCustomConfig(server.url, server.accessToken)
131 const data = res.body 165 const data = res.body
132 166
133 expect(data.instance.name).to.equal('PeerTube updated') 167 checkUpdatedConfig(data)
134 expect(data.instance.shortDescription).to.equal('my short description')
135 expect(data.instance.description).to.equal('my super description')
136 expect(data.instance.terms).to.equal('my super terms')
137 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
138 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
139 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
140 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
141 expect(data.services.twitter.username).to.equal('@Kuja')
142 expect(data.services.twitter.whitelisted).to.be.true
143 expect(data.cache.previews.size).to.equal(2)
144 expect(data.signup.enabled).to.be.false
145 expect(data.signup.limit).to.equal(5)
146 expect(data.admin.email).to.equal('superadmin1@example.com')
147 expect(data.user.videoQuota).to.equal(5242881)
148 expect(data.transcoding.enabled).to.be.true
149 expect(data.transcoding.threads).to.equal(1)
150 expect(data.transcoding.resolutions['240p']).to.be.false
151 expect(data.transcoding.resolutions['360p']).to.be.true
152 expect(data.transcoding.resolutions['480p']).to.be.true
153 expect(data.transcoding.resolutions['720p']).to.be.false
154 expect(data.transcoding.resolutions['1080p']).to.be.false
155 }) 168 })
156 169
157 it('Should have the configuration updated after a restart', async function () { 170 it('Should have the configuration updated after a restart', async function () {
@@ -164,28 +177,7 @@ describe('Test config', function () {
164 const res = await getCustomConfig(server.url, server.accessToken) 177 const res = await getCustomConfig(server.url, server.accessToken)
165 const data = res.body 178 const data = res.body
166 179
167 expect(data.instance.name).to.equal('PeerTube updated') 180 checkUpdatedConfig(data)
168 expect(data.instance.shortDescription).to.equal('my short description')
169 expect(data.instance.description).to.equal('my super description')
170 expect(data.instance.terms).to.equal('my super terms')
171 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
172 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
173 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
174 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
175 expect(data.services.twitter.username).to.equal('@Kuja')
176 expect(data.services.twitter.whitelisted).to.be.true
177 expect(data.cache.previews.size).to.equal(2)
178 expect(data.signup.enabled).to.be.false
179 expect(data.signup.limit).to.equal(5)
180 expect(data.admin.email).to.equal('superadmin1@example.com')
181 expect(data.user.videoQuota).to.equal(5242881)
182 expect(data.transcoding.enabled).to.be.true
183 expect(data.transcoding.threads).to.equal(1)
184 expect(data.transcoding.resolutions['240p']).to.be.false
185 expect(data.transcoding.resolutions['360p']).to.be.true
186 expect(data.transcoding.resolutions['480p']).to.be.true
187 expect(data.transcoding.resolutions['720p']).to.be.false
188 expect(data.transcoding.resolutions['1080p']).to.be.false
189 }) 181 })
190 182
191 it('Should fetch the about information', async function () { 183 it('Should fetch the about information', async function () {
@@ -206,31 +198,7 @@ describe('Test config', function () {
206 const res = await getCustomConfig(server.url, server.accessToken) 198 const res = await getCustomConfig(server.url, server.accessToken)
207 const data = res.body 199 const data = res.body
208 200
209 expect(data.instance.name).to.equal('PeerTube') 201 checkInitialConfig(data)
210 expect(data.instance.shortDescription).to.equal(
211 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
212 'with WebTorrent and Angular.'
213 )
214 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
215 expect(data.instance.terms).to.equal('No terms for now.')
216 expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
217 expect(data.instance.defaultNSFWPolicy).to.equal('display')
218 expect(data.instance.customizations.css).to.be.empty
219 expect(data.instance.customizations.javascript).to.be.empty
220 expect(data.services.twitter.username).to.equal('@Chocobozzz')
221 expect(data.services.twitter.whitelisted).to.be.false
222 expect(data.cache.previews.size).to.equal(1)
223 expect(data.signup.enabled).to.be.true
224 expect(data.signup.limit).to.equal(4)
225 expect(data.admin.email).to.equal('admin1@example.com')
226 expect(data.user.videoQuota).to.equal(5242880)
227 expect(data.transcoding.enabled).to.be.false
228 expect(data.transcoding.threads).to.equal(2)
229 expect(data.transcoding.resolutions['240p']).to.be.true
230 expect(data.transcoding.resolutions['360p']).to.be.true
231 expect(data.transcoding.resolutions['480p']).to.be.true
232 expect(data.transcoding.resolutions['720p']).to.be.true
233 expect(data.transcoding.resolutions['1080p']).to.be.true
234 }) 202 })
235 203
236 after(async function () { 204 after(async function () {
diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts
index ce42df0a6..a19b47509 100644
--- a/server/tests/api/server/follows.ts
+++ b/server/tests/api/server/follows.ts
@@ -26,6 +26,8 @@ import {
26} from '../../utils/videos/video-comments' 26} from '../../utils/videos/video-comments'
27import { rateVideo } from '../../utils/videos/videos' 27import { rateVideo } from '../../utils/videos/videos'
28import { waitJobs } from '../../utils/server/jobs' 28import { waitJobs } from '../../utils/server/jobs'
29import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
30import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
29 31
30const expect = chai.expect 32const expect = chai.expect
31 33
@@ -244,6 +246,16 @@ describe('Test follows', function () {
244 const text3 = 'my second answer to thread 1' 246 const text3 = 'my second answer to thread 1'
245 await addVideoCommentReply(servers[ 2 ].url, servers[ 2 ].accessToken, video4.id, threadId, text3) 247 await addVideoCommentReply(servers[ 2 ].url, servers[ 2 ].accessToken, video4.id, threadId, text3)
246 } 248 }
249
250 {
251 await createVideoCaption({
252 url: servers[2].url,
253 accessToken: servers[2].accessToken,
254 language: 'ar',
255 videoId: video4.id,
256 fixture: 'subtitle-good2.vtt'
257 })
258 }
247 } 259 }
248 260
249 await waitJobs(servers) 261 await waitJobs(servers)
@@ -266,7 +278,7 @@ describe('Test follows', function () {
266 await expectAccountFollows(servers[2].url, 'peertube@localhost:9003', 1, 0) 278 await expectAccountFollows(servers[2].url, 'peertube@localhost:9003', 1, 0)
267 }) 279 })
268 280
269 it('Should propagate videos', async function () { 281 it('Should have propagated videos', async function () {
270 const res = await getVideosList(servers[ 0 ].url) 282 const res = await getVideosList(servers[ 0 ].url)
271 expect(res.body.total).to.equal(7) 283 expect(res.body.total).to.equal(7)
272 284
@@ -314,7 +326,7 @@ describe('Test follows', function () {
314 await completeVideoCheck(servers[ 0 ].url, video4, checkAttributes) 326 await completeVideoCheck(servers[ 0 ].url, video4, checkAttributes)
315 }) 327 })
316 328
317 it('Should propagate comments', async function () { 329 it('Should have propagated comments', async function () {
318 const res1 = await getVideoCommentThreads(servers[0].url, video4.id, 0, 5) 330 const res1 = await getVideoCommentThreads(servers[0].url, video4.id, 0, 5)
319 331
320 expect(res1.body.total).to.equal(1) 332 expect(res1.body.total).to.equal(1)
@@ -353,6 +365,18 @@ describe('Test follows', function () {
353 expect(secondChild.children).to.have.lengthOf(0) 365 expect(secondChild.children).to.have.lengthOf(0)
354 }) 366 })
355 367
368 it('Should have propagated captions', async function () {
369 const res = await listVideoCaptions(servers[0].url, video4.id)
370 expect(res.body.total).to.equal(1)
371 expect(res.body.data).to.have.lengthOf(1)
372
373 const caption1: VideoCaption = res.body.data[0]
374 expect(caption1.language.id).to.equal('ar')
375 expect(caption1.language.label).to.equal('Arabic')
376 expect(caption1.captionPath).to.equal('/static/video-captions/' + video4.uuid + '-ar.vtt')
377 await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.')
378 })
379
356 it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () { 380 it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
357 this.timeout(5000) 381 this.timeout(5000)
358 382
diff --git a/server/tests/api/videos/video-captions.ts b/server/tests/api/videos/video-captions.ts
new file mode 100644
index 000000000..cbf5268f0
--- /dev/null
+++ b/server/tests/api/videos/video-captions.ts
@@ -0,0 +1,139 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { doubleFollow, flushAndRunMultipleServers, uploadVideo } from '../../utils'
6import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
7import { waitJobs } from '../../utils/server/jobs'
8import { createVideoCaption, deleteVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
9import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
10
11const expect = chai.expect
12
13describe('Test video captions', function () {
14 let servers: ServerInfo[]
15 let videoUUID: string
16
17 before(async function () {
18 this.timeout(30000)
19
20 await flushTests()
21
22 servers = await flushAndRunMultipleServers(2)
23
24 await setAccessTokensToServers(servers)
25 await doubleFollow(servers[0], servers[1])
26
27 await waitJobs(servers)
28
29 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'my video name' })
30 videoUUID = res.body.video.uuid
31
32 await waitJobs(servers)
33 })
34
35 it('Should list the captions and return an empty list', async function () {
36 for (const server of servers) {
37 const res = await listVideoCaptions(server.url, videoUUID)
38 expect(res.body.total).to.equal(0)
39 expect(res.body.data).to.have.lengthOf(0)
40 }
41 })
42
43 it('Should create two new captions', async function () {
44 this.timeout(30000)
45
46 await createVideoCaption({
47 url: servers[0].url,
48 accessToken: servers[0].accessToken,
49 language: 'ar',
50 videoId: videoUUID,
51 fixture: 'subtitle-good1.vtt'
52 })
53
54 await createVideoCaption({
55 url: servers[0].url,
56 accessToken: servers[0].accessToken,
57 language: 'zh',
58 videoId: videoUUID,
59 fixture: 'subtitle-good2.vtt'
60 })
61
62 await waitJobs(servers)
63 })
64
65 it('Should list these uploaded captions', async function () {
66 for (const server of servers) {
67 const res = await listVideoCaptions(server.url, videoUUID)
68 expect(res.body.total).to.equal(2)
69 expect(res.body.data).to.have.lengthOf(2)
70
71 const caption1: VideoCaption = res.body.data[0]
72 expect(caption1.language.id).to.equal('ar')
73 expect(caption1.language.label).to.equal('Arabic')
74 expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
75 await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.')
76
77 const caption2: VideoCaption = res.body.data[1]
78 expect(caption2.language.id).to.equal('zh')
79 expect(caption2.language.label).to.equal('Chinese')
80 expect(caption2.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
81 await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.')
82 }
83 })
84
85 it('Should replace an existing caption', async function () {
86 this.timeout(30000)
87
88 await createVideoCaption({
89 url: servers[0].url,
90 accessToken: servers[0].accessToken,
91 language: 'ar',
92 videoId: videoUUID,
93 fixture: 'subtitle-good2.vtt'
94 })
95
96 await waitJobs(servers)
97 })
98
99 it('Should have this caption updated', async function () {
100 for (const server of servers) {
101 const res = await listVideoCaptions(server.url, videoUUID)
102 expect(res.body.total).to.equal(2)
103 expect(res.body.data).to.have.lengthOf(2)
104
105 const caption1: VideoCaption = res.body.data[0]
106 expect(caption1.language.id).to.equal('ar')
107 expect(caption1.language.label).to.equal('Arabic')
108 expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
109 await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.')
110 }
111 })
112
113 it('Should remove one caption', async function () {
114 this.timeout(30000)
115
116 await deleteVideoCaption(servers[0].url, servers[0].accessToken, videoUUID, 'ar')
117
118 await waitJobs(servers)
119 })
120
121 it('Should only list the caption that was not deleted', async function () {
122 for (const server of servers) {
123 const res = await listVideoCaptions(server.url, videoUUID)
124 expect(res.body.total).to.equal(1)
125 expect(res.body.data).to.have.lengthOf(1)
126
127 const caption: VideoCaption = res.body.data[0]
128
129 expect(caption.language.id).to.equal('zh')
130 expect(caption.language.label).to.equal('Chinese')
131 expect(caption.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
132 await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.')
133 }
134 })
135
136 after(async function () {
137 killallServers(servers)
138 })
139})
diff --git a/server/tests/fixtures/subtitle-good1.vtt b/server/tests/fixtures/subtitle-good1.vtt
new file mode 100644
index 000000000..04cd23946
--- /dev/null
+++ b/server/tests/fixtures/subtitle-good1.vtt
@@ -0,0 +1,8 @@
1WEBVTT
2
300:01.000 --> 00:04.000
4Subtitle good 1.
5
600:05.000 --> 00:09.000
7- It will perforate your stomach.
8- You could die. \ No newline at end of file
diff --git a/server/tests/fixtures/subtitle-good2.vtt b/server/tests/fixtures/subtitle-good2.vtt
new file mode 100644
index 000000000..4d3256def
--- /dev/null
+++ b/server/tests/fixtures/subtitle-good2.vtt
@@ -0,0 +1,8 @@
1WEBVTT
2
300:01.000 --> 00:04.000
4Subtitle good 2.
5
600:05.000 --> 00:09.000
7- It will perforate your stomach.
8- You could die. \ No newline at end of file
diff --git a/server/tests/utils/miscs/miscs.ts b/server/tests/utils/miscs/miscs.ts
index 7ac60a983..5e46004a7 100644
--- a/server/tests/utils/miscs/miscs.ts
+++ b/server/tests/utils/miscs/miscs.ts
@@ -5,7 +5,6 @@ import { isAbsolute, join } from 'path'
5import * as request from 'supertest' 5import * as request from 'supertest'
6import * as WebTorrent from 'webtorrent' 6import * as WebTorrent from 'webtorrent'
7import { readFileBufferPromise } from '../../../helpers/core-utils' 7import { readFileBufferPromise } from '../../../helpers/core-utils'
8import { ServerInfo } from '..'
9 8
10const expect = chai.expect 9const expect = chai.expect
11let webtorrent = new WebTorrent() 10let webtorrent = new WebTorrent()
diff --git a/server/tests/utils/videos/video-captions.ts b/server/tests/utils/videos/video-captions.ts
new file mode 100644
index 000000000..207e89632
--- /dev/null
+++ b/server/tests/utils/videos/video-captions.ts
@@ -0,0 +1,66 @@
1import { makeDeleteRequest, makeGetRequest } from '../'
2import { buildAbsoluteFixturePath, makeUploadRequest } from '../index'
3import * as request from 'supertest'
4import * as chai from 'chai'
5
6const expect = chai.expect
7
8function createVideoCaption (args: {
9 url: string,
10 accessToken: string
11 videoId: string | number
12 language: string
13 fixture: string
14}) {
15 const path = '/api/v1/videos/' + args.videoId + '/captions/' + args.language
16
17 return makeUploadRequest({
18 method: 'PUT',
19 url: args.url,
20 path,
21 token: args.accessToken,
22 fields: {},
23 attaches: {
24 captionfile: buildAbsoluteFixturePath(args.fixture)
25 },
26 statusCodeExpected: 204
27 })
28}
29
30function listVideoCaptions (url: string, videoId: string | number) {
31 const path = '/api/v1/videos/' + videoId + '/captions'
32
33 return makeGetRequest({
34 url,
35 path,
36 statusCodeExpected: 200
37 })
38}
39
40function deleteVideoCaption (url: string, token: string, videoId: string | number, language: string) {
41 const path = '/api/v1/videos/' + videoId + '/captions/' + language
42
43 return makeDeleteRequest({
44 url,
45 token,
46 path,
47 statusCodeExpected: 204
48 })
49}
50
51async function testCaptionFile (url: string, captionPath: string, containsString: string) {
52 const res = await request(url)
53 .get(captionPath)
54 .expect(200)
55
56 expect(res.text).to.contain(containsString)
57}
58
59// ---------------------------------------------------------------------------
60
61export {
62 createVideoCaption,
63 listVideoCaptions,
64 testCaptionFile,
65 deleteVideoCaption
66}
diff --git a/shared/models/activitypub/objects/video-torrent-object.ts b/shared/models/activitypub/objects/video-torrent-object.ts
index c4071a6d9..90de8967b 100644
--- a/shared/models/activitypub/objects/video-torrent-object.ts
+++ b/shared/models/activitypub/objects/video-torrent-object.ts
@@ -17,6 +17,7 @@ export interface VideoTorrentObject {
17 category: ActivityIdentifierObject 17 category: ActivityIdentifierObject
18 licence: ActivityIdentifierObject 18 licence: ActivityIdentifierObject
19 language: ActivityIdentifierObject 19 language: ActivityIdentifierObject
20 subtitleLanguage: ActivityIdentifierObject[]
20 views: number 21 views: number
21 sensitive: boolean 22 sensitive: boolean
22 commentsEnabled: boolean 23 commentsEnabled: boolean
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index a3a651cd8..9c4718e43 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -25,6 +25,10 @@ export interface CustomConfig {
25 previews: { 25 previews: {
26 size: number 26 size: number
27 } 27 }
28
29 captions: {
30 size: number
31 }
28 } 32 }
29 33
30 signup: { 34 signup: {
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index da0996dae..217d142cd 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -44,6 +44,15 @@ export interface ServerConfig {
44 } 44 }
45 } 45 }
46 46
47 videoCaption: {
48 file: {
49 size: {
50 max: number
51 },
52 extensions: string[]
53 }
54 }
55
47 user: { 56 user: {
48 videoQuota: number 57 videoQuota: number
49 } 58 }
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index 9edfb559a..cb9669772 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -14,3 +14,5 @@ export * from './video-resolution.enum'
14export * from './video-update.model' 14export * from './video-update.model'
15export * from './video.model' 15export * from './video.model'
16export * from './video-state.enum' 16export * from './video-state.enum'
17export * from './video-caption-update.model'
18export { VideoConstant } from './video-constant.model'
diff --git a/shared/models/videos/video-caption-update.model.ts b/shared/models/videos/video-caption-update.model.ts
new file mode 100644
index 000000000..ff5728715
--- /dev/null
+++ b/shared/models/videos/video-caption-update.model.ts
@@ -0,0 +1,4 @@
1export interface VideoCaptionUpdate {
2 language: string
3 captionfile: Blob
4}
diff --git a/shared/models/videos/video-caption.model.ts b/shared/models/videos/video-caption.model.ts
new file mode 100644
index 000000000..4695224ce
--- /dev/null
+++ b/shared/models/videos/video-caption.model.ts
@@ -0,0 +1,6 @@
1import { VideoConstant } from './video-constant.model'
2
3export interface VideoCaption {
4 language: VideoConstant<string>
5 captionPath: string
6}
diff --git a/shared/models/videos/video-constant.model.ts b/shared/models/videos/video-constant.model.ts
new file mode 100644
index 000000000..342a7c0cf
--- /dev/null
+++ b/shared/models/videos/video-constant.model.ts
@@ -0,0 +1,4 @@
1export interface VideoConstant<T> {
2 id: T
3 label: string
4}
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts
index 4e1f15ee3..f7bbaac76 100644
--- a/shared/models/videos/video.model.ts
+++ b/shared/models/videos/video.model.ts
@@ -4,11 +4,7 @@ import { Avatar } from '../avatars/avatar.model'
4import { VideoChannel } from './video-channel.model' 4import { VideoChannel } from './video-channel.model'
5import { VideoPrivacy } from './video-privacy.enum' 5import { VideoPrivacy } from './video-privacy.enum'
6import { VideoScheduleUpdate } from './video-schedule-update.model' 6import { VideoScheduleUpdate } from './video-schedule-update.model'
7 7import { VideoConstant } from './video-constant.model'
8export interface VideoConstant <T> {
9 id: T
10 label: string
11}
12 8
13export interface VideoFile { 9export interface VideoFile {
14 magnetUri: string 10 magnetUri: string
diff --git a/support/docker/production/config/production.yaml b/support/docker/production/config/production.yaml
index 64fc9e82c..ddac23c4e 100644
--- a/support/docker/production/config/production.yaml
+++ b/support/docker/production/config/production.yaml
@@ -38,6 +38,7 @@ storage:
38 previews: '../data/previews/' 38 previews: '../data/previews/'
39 thumbnails: '../data/thumbnails/' 39 thumbnails: '../data/thumbnails/'
40 torrents: '../data/torrents/' 40 torrents: '../data/torrents/'
41 captions: '../data/captions/'
41 cache: '../data/cache/' 42 cache: '../data/cache/'
42 43
43log: 44log: