diff options
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' | |||
8 | export * from './video-channel-validators.service' | 8 | export * from './video-channel-validators.service' |
9 | export * from './video-comment-validators.service' | 9 | export * from './video-comment-validators.service' |
10 | export * from './video-validators.service' | 10 | export * from './video-validators.service' |
11 | export * 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from '@app/shared' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | export * from './form-validators' | 1 | export * from './form-validators' |
2 | export * from './form-reactive' | 2 | export * from './form-reactive' |
3 | export * 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 @@ | |||
1 | import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { NotificationsService } from 'angular2-notifications' | ||
4 | import { 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 | }) | ||
18 | export 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 | ||
99 | function removeElementFromArray <T> (arr: T[], elem: T) { | ||
100 | const index = arr.indexOf(elem) | ||
101 | if (index !== -1) arr.splice(index, 1) | ||
102 | } | ||
103 | |||
99 | export { | 104 | export { |
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' | |||
37 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 37 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
38 | import { | 38 | import { |
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' |
44 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' | 44 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' |
45 | import { ScreenService } from '@app/shared/misc/screen.service' | 45 | import { ScreenService } from '@app/shared/misc/screen.service' |
46 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' | ||
47 | import { 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 @@ | |||
1 | export 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 @@ | |||
1 | import { catchError, map } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { forkJoin, Observable } from 'rxjs' | ||
5 | import { ResultList } from '../../../../../shared' | ||
6 | import { RestExtractor, RestService } from '../rest' | ||
7 | import { VideoCaption } from '../../../../../shared/models/videos/video-caption.model' | ||
8 | import { VideoService } from '@app/shared/video/video.service' | ||
9 | import { objectToFormData } from '@app/shared/misc/utils' | ||
10 | import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model' | ||
11 | |||
12 | @Injectable() | ||
13 | export 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 @@ | |||
1 | import { User } from '../' | 1 | import { User } from '../' |
2 | import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' | 2 | import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' |
3 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 3 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
4 | import { VideoConstant } from '../../../../../shared/models/videos/video.model' | 4 | import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model' |
5 | import { getAbsoluteAPIUrl } from '../misc/utils' | 5 | import { getAbsoluteAPIUrl } from '../misc/utils' |
6 | import { ServerConfig } from '../../../../../shared/models' | 6 | import { ServerConfig } from '../../../../../shared/models' |
7 | import { Actor } from '@app/shared/actor/actor.model' | 7 | import { 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() |
30 | export class VideoService { | 30 | export 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 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | ||
2 | import { ModalDirective } from 'ngx-bootstrap/modal' | ||
3 | import { FormReactive } from '@app/shared' | ||
4 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | ||
5 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' | ||
6 | import { ServerService } from '@app/core' | ||
7 | import { 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 | |||
15 | export 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 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | 1 | import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core' |
2 | import { FormGroup, ValidatorFn, Validators } from '@angular/forms' | 2 | import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared' | 4 | import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared' |
5 | import { NotificationsService } from 'angular2-notifications' | 5 | import { NotificationsService } from 'angular2-notifications' |
@@ -8,6 +8,10 @@ import { VideoEdit } from '../../../shared/video/video-edit.model' | |||
8 | import { map } from 'rxjs/operators' | 8 | import { map } from 'rxjs/operators' |
9 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 9 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
10 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' | 10 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' |
11 | import { VideoCaptionService } from '@app/shared/video-caption' | ||
12 | import { VideoCaptionAddModalComponent } from '@app/videos/+video-edit/shared/video-caption-add-modal.component' | ||
13 | import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model' | ||
14 | import { 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 | ||
18 | export class VideoEditComponent implements OnInit { | 22 | export 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/' | |||
5 | import { VideoEditComponent } from './video-edit.component' | 5 | import { VideoEditComponent } from './video-edit.component' |
6 | import { VideoImageComponent } from './video-image.component' | 6 | import { VideoImageComponent } from './video-image.component' |
7 | import { CalendarModule } from 'primeng/components/calendar/calendar' | 7 | import { CalendarModule } from 'primeng/components/calendar/calendar' |
8 | import { 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' | |||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' | 3 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' |
4 | import { ServerService } from '@app/core' | 4 | import { ServerService } from '@app/core' |
5 | import { NotificationsService } from 'angular2-notifications' | ||
6 | import { 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' | |||
15 | import { VideoService } from '../../shared/video/video.service' | 15 | import { VideoService } from '../../shared/video/video.service' |
16 | import { I18n } from '@ngx-translate/i18n-polyfill' | 16 | import { I18n } from '@ngx-translate/i18n-polyfill' |
17 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 17 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
18 | import { switchMap } from 'rxjs/operators' | ||
19 | import { 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' | |||
12 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 12 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
13 | import { I18n } from '@ngx-translate/i18n-polyfill' | 13 | import { I18n } from '@ngx-translate/i18n-polyfill' |
14 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 14 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
15 | import { 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 | ||
54 | log: | 55 | log: |
@@ -57,6 +58,8 @@ log: | |||
57 | cache: | 58 | cache: |
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 | ||
61 | admin: | 64 | admin: |
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 | ||
55 | log: | 56 | log: |
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 | ||
21 | admin: | 22 | admin: |
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 | ||
21 | admin: | 22 | admin: |
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 | ||
21 | admin: | 22 | admin: |
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 | ||
21 | admin: | 22 | admin: |
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 | ||
21 | admin: | 22 | admin: |
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 | ||
21 | admin: | 22 | admin: |
@@ -1,4 +1,6 @@ | |||
1 | // FIXME: https://github.com/nodejs/node/pull/16853 | 1 | // FIXME: https://github.com/nodejs/node/pull/16853 |
2 | import { VideosCaptionCache } from './server/lib/cache/videos-caption-cache' | ||
3 | |||
2 | require('tls').DEFAULT_ECDH_CURVE = 'auto' | 4 | require('tls').DEFAULT_ECDH_CURVE = 'auto' |
3 | 5 | ||
4 | import { isTestInstance } from './server/helpers/core-utils' | 6 | import { 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' |
28 | import { VideoCaption } from '../../../shared/models/videos/video-caption.model' | ||
29 | import { VideoCaptionModel } from '../../models/video/video-caption' | ||
28 | 30 | ||
29 | const activityPubClientRouter = express.Router() | 31 | const activityPubClientRouter = express.Router() |
30 | 32 | ||
@@ -123,6 +125,9 @@ async function accountFollowingController (req: express.Request, res: express.Re | |||
123 | async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) { | 125 | async 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' | ||
3 | import { | ||
4 | addVideoCaptionValidator, | ||
5 | deleteVideoCaptionValidator, | ||
6 | listVideoCaptionsValidator | ||
7 | } from '../../../middlewares/validators/video-captions' | ||
8 | import { createReqFiles } from '../../../helpers/express-utils' | ||
9 | import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers' | ||
10 | import { getFormattedObjects } from '../../../helpers/utils' | ||
11 | import { VideoCaptionModel } from '../../../models/video/video-caption' | ||
12 | import { renamePromise } from '../../../helpers/core-utils' | ||
13 | import { join } from 'path' | ||
14 | import { VideoModel } from '../../../models/video/video' | ||
15 | import { logger } from '../../../helpers/logger' | ||
16 | import { federateVideoIfNeeded } from '../../../lib/activitypub' | ||
17 | |||
18 | const reqVideoCaptionAdd = createReqFiles( | ||
19 | [ 'captionfile' ], | ||
20 | VIDEO_CAPTIONS_MIMETYPE_EXT, | ||
21 | { | ||
22 | captionfile: CONFIG.STORAGE.CAPTIONS_DIR | ||
23 | } | ||
24 | ) | ||
25 | |||
26 | const videoCaptionsRouter = express.Router() | ||
27 | |||
28 | videoCaptionsRouter.get('/:videoId/captions', | ||
29 | asyncMiddleware(listVideoCaptionsValidator), | ||
30 | asyncMiddleware(listVideoCaptions) | ||
31 | ) | ||
32 | videoCaptionsRouter.put('/:videoId/captions/:captionLanguage', | ||
33 | authenticate, | ||
34 | reqVideoCaptionAdd, | ||
35 | asyncMiddleware(addVideoCaptionValidator), | ||
36 | asyncRetryTransactionMiddleware(addVideoCaption) | ||
37 | ) | ||
38 | videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage', | ||
39 | authenticate, | ||
40 | asyncMiddleware(deleteVideoCaptionValidator), | ||
41 | asyncRetryTransactionMiddleware(deleteVideoCaption) | ||
42 | ) | ||
43 | |||
44 | // --------------------------------------------------------------------------- | ||
45 | |||
46 | export { | ||
47 | videoCaptionsRouter | ||
48 | } | ||
49 | |||
50 | // --------------------------------------------------------------------------- | ||
51 | |||
52 | async 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 | |||
58 | async 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 | |||
86 | async 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' | |||
53 | import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' | 53 | import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' |
54 | import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' | 54 | import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' |
55 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | 55 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' |
56 | import { videoCaptionsRouter } from './captions' | ||
56 | 57 | ||
57 | const videosRouter = express.Router() | 58 | const videosRouter = express.Router() |
58 | 59 | ||
@@ -78,6 +79,7 @@ videosRouter.use('/', abuseVideoRouter) | |||
78 | videosRouter.use('/', blacklistRouter) | 79 | videosRouter.use('/', blacklistRouter) |
79 | videosRouter.use('/', rateVideoRouter) | 80 | videosRouter.use('/', rateVideoRouter) |
80 | videosRouter.use('/', videoCommentRouter) | 81 | videosRouter.use('/', videoCommentRouter) |
82 | videosRouter.use('/', videoCaptionsRouter) | ||
81 | 83 | ||
82 | videosRouter.get('/categories', listVideoCategories) | 84 | videosRouter.get('/categories', listVideoCategories) |
83 | videosRouter.get('/licences', listVideoLicences) | 85 | videosRouter.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 '../ | |||
4 | import { VideosPreviewCache } from '../lib/cache' | 4 | import { VideosPreviewCache } from '../lib/cache' |
5 | import { asyncMiddleware, videosGetValidator } from '../middlewares' | 5 | import { asyncMiddleware, videosGetValidator } from '../middlewares' |
6 | import { VideoModel } from '../models/video/video' | 6 | import { VideoModel } from '../models/video/video' |
7 | import { VideosCaptionCache } from '../lib/cache/videos-caption-cache' | ||
7 | 8 | ||
8 | const staticRouter = express.Router() | 9 | const 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 |
53 | staticRouter.use( | 54 | staticRouter.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 | ||
60 | staticRouter.use( | ||
61 | STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt', | ||
62 | asyncMiddleware(getVideoCaption) | ||
63 | ) | ||
64 | |||
58 | // robots.txt service | 65 | // robots.txt service |
59 | staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => { | 66 | staticRouter.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 | ||
72 | async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) { | 79 | async 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 | |||
86 | async 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 | ||
102 | function 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 | |||
101 | function isRemoteNumberIdentifierValid (data: any) { | 114 | function 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 @@ | |||
1 | import { CONSTRAINTS_FIELDS, VIDEO_LANGUAGES } from '../../initializers' | ||
2 | import { exists, isFileValid } from './misc' | ||
3 | import { Response } from 'express' | ||
4 | import { VideoModel } from '../../models/video/video' | ||
5 | import { VideoCaptionModel } from '../../models/video/video-caption' | ||
6 | |||
7 | function isVideoCaptionLanguageValid (value: any) { | ||
8 | return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined | ||
9 | } | ||
10 | |||
11 | const videoCaptionTypes = CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME | ||
12 | .map(v => v.replace('.', '')) | ||
13 | .join('|') | ||
14 | const videoCaptionsTypesRegex = `text/(${videoCaptionTypes})` | ||
15 | |||
16 | function 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 | |||
20 | async 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 | |||
37 | export { | ||
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 | ||
129 | function 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 | |||
129 | async function isVideoExist (id: string, res: Response) { | 152 | async 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 | ||
180 | export { | 203 | export { |
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 | ||
366 | const VIDEO_CAPTIONS_MIMETYPE_EXT = { | ||
367 | 'text/vtt': '.vtt' | ||
368 | } | ||
369 | |||
354 | // --------------------------------------------------------------------------- | 370 | // --------------------------------------------------------------------------- |
355 | 371 | ||
356 | const SERVER_ACTOR_NAME = 'peertube' | 372 | const 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 | } |
408 | const STATIC_DOWNLOAD_PATHS = { | 425 | const 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 |
436 | const CACHE = { | 453 | const 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 | ||
491 | export { | 509 | export { |
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' | |||
23 | import { VideoTagModel } from '../models/video/video-tag' | 23 | import { VideoTagModel } from '../models/video/video-tag' |
24 | import { CONFIG } from './constants' | 24 | import { CONFIG } from './constants' |
25 | import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' | 25 | import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' |
26 | import { VideoCaptionModel } from '../models/video/video-caption' | ||
26 | 27 | ||
27 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 28 | require('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' |
21 | import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' | 21 | import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' |
22 | import { VideoCaptionModel } from '../../../models/video/video-caption' | ||
22 | 23 | ||
23 | async function processUpdateActivity (activity: ActivityUpdate) { | 24 | async 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' | |||
24 | import { crawlCollectionPage } from './crawl' | 24 | import { crawlCollectionPage } from './crawl' |
25 | import { sendCreateVideo, sendUpdateVideo } from './send' | 25 | import { sendCreateVideo, sendUpdateVideo } from './send' |
26 | import { shareVideoByServerAndChannel } from './index' | 26 | import { shareVideoByServerAndChannel } from './index' |
27 | import { isArray } from '../../helpers/custom-validators/misc' | ||
28 | import { VideoCaptionModel } from '../../models/video/video-caption' | ||
27 | 29 | ||
28 | async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { | 30 | async 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 | ||
41 | function fetchRemoteVideoPreview (video: VideoModel, reject: Function) { | 51 | function 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 @@ | |||
1 | import * as AsyncLRU from 'async-lru' | ||
2 | import { createWriteStream } from 'fs' | ||
3 | import { join } from 'path' | ||
4 | import { unlinkPromise } from '../../helpers/core-utils' | ||
5 | import { logger } from '../../helpers/logger' | ||
6 | import { CACHE, CONFIG } from '../../initializers' | ||
7 | import { VideoModel } from '../../models/video/video' | ||
8 | import { fetchRemoteVideoStaticFile } from '../activitypub' | ||
9 | import { VideoCaptionModel } from '../../models/video/video-caption' | ||
10 | |||
11 | export 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 @@ | |||
1 | import { join } from 'path' | ||
2 | import { CACHE, CONFIG } from '../../initializers' | ||
3 | import { VideoModel } from '../../models/video/video' | ||
4 | import { VideoCaptionModel } from '../../models/video/video-caption' | ||
5 | import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' | ||
6 | |||
7 | type GetPathParam = { videoId: string, language: string } | ||
8 | |||
9 | class 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 | |||
51 | export { | ||
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 @@ | |||
1 | import * as asyncLRU from 'async-lru' | ||
2 | import { createWriteStream } from 'fs' | ||
3 | import { join } from 'path' | 1 | import { join } from 'path' |
4 | import { unlinkPromise } from '../../helpers/core-utils' | 2 | import { CACHE, CONFIG, STATIC_PATHS } from '../../initializers' |
5 | import { logger } from '../../helpers/logger' | ||
6 | import { CACHE, CONFIG } from '../../initializers' | ||
7 | import { VideoModel } from '../../models/video/video' | 3 | import { VideoModel } from '../../models/video/video' |
8 | import { fetchRemoteVideoPreview } from '../activitypub' | 4 | import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' |
9 | 5 | ||
10 | class VideosPreviewCache { | 6 | class 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { areValidationErrors } from './utils' | ||
3 | import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos' | ||
4 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | ||
5 | import { body, param } from 'express-validator/check' | ||
6 | import { CONSTRAINTS_FIELDS } from '../../initializers' | ||
7 | import { UserRight } from '../../../shared' | ||
8 | import { logger } from '../../helpers/logger' | ||
9 | import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' | ||
10 | |||
11 | const 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 | |||
34 | const 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 | |||
53 | const 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 | |||
66 | export { | ||
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' |
14 | import { | 14 | import { |
15 | checkUserCanManageVideo, | ||
15 | isScheduleVideoUpdatePrivacyValid, | 16 | isScheduleVideoUpdatePrivacyValid, |
16 | isVideoAbuseReasonValid, | 17 | isVideoAbuseReasonValid, |
17 | isVideoCategoryValid, | 18 | isVideoCategoryValid, |
@@ -31,8 +32,6 @@ import { | |||
31 | import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' | 32 | import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' |
32 | import { logger } from '../../helpers/logger' | 33 | import { logger } from '../../helpers/logger' |
33 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 34 | import { CONSTRAINTS_FIELDS } from '../../initializers' |
34 | import { UserModel } from '../../models/account/user' | ||
35 | import { VideoModel } from '../../models/video/video' | ||
36 | import { VideoShareModel } from '../../models/video/video-share' | 35 | import { VideoShareModel } from '../../models/video/video-share' |
37 | import { authenticate } from '../oauth' | 36 | import { authenticate } from '../oauth' |
38 | import { areValidationErrors } from './utils' | 37 | import { areValidationErrors } from './utils' |
@@ -40,17 +39,17 @@ import { areValidationErrors } from './utils' | |||
40 | const videosAddValidator = [ | 39 | const 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 | ||
376 | function 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 | |||
399 | function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) { | 375 | function 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | import { | ||
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' | ||
15 | import { throwIfNotValid } from '../utils' | ||
16 | import { VideoModel } from './video' | ||
17 | import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' | ||
18 | import { VideoCaption } from '../../../shared/models/videos/video-caption.model' | ||
19 | import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers' | ||
20 | import { join } from 'path' | ||
21 | import { logger } from '../../helpers/logger' | ||
22 | import { unlinkPromise } from '../../helpers/core-utils' | ||
23 | |||
24 | export 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 | }) | ||
52 | export 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' | |||
92 | import { VideoShareModel } from './video-share' | 92 | import { VideoShareModel } from './video-share' |
93 | import { VideoTagModel } from './video-tag' | 93 | import { VideoTagModel } from './video-tag' |
94 | import { ScheduleVideoUpdateModel } from './schedule-video-update' | 94 | import { ScheduleVideoUpdateModel } from './schedule-video-update' |
95 | import { VideoCaptionModel } from './video-caption' | ||
95 | 96 | ||
96 | export enum ScopeNames { | 97 | export 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' | |||
6 | import './users' | 6 | import './users' |
7 | import './video-abuses' | 7 | import './video-abuses' |
8 | import './video-blacklist' | 8 | import './video-blacklist' |
9 | import './video-captions' | ||
9 | import './video-channels' | 10 | import './video-channels' |
10 | import './video-comments' | 11 | import './video-comments' |
11 | import './videos' | 12 | import './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 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { | ||
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' | ||
18 | import { join } from 'path' | ||
19 | |||
20 | describe('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' | |||
4 | import './users/users' | 4 | import './users/users' |
5 | import './videos/single-server' | 5 | import './videos/single-server' |
6 | import './videos/video-abuse' | 6 | import './videos/video-abuse' |
7 | import './videos/video-captions' | ||
7 | import './videos/video-blacklist' | 8 | import './videos/video-blacklist' |
8 | import './videos/video-blacklist-management' | 9 | import './videos/video-blacklist-management' |
9 | import './videos/video-description' | 10 | import './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 | ||
17 | function 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 | |||
46 | function 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 | |||
17 | describe('Test config', function () { | 72 | describe('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' |
27 | import { rateVideo } from '../../utils/videos/videos' | 27 | import { rateVideo } from '../../utils/videos/videos' |
28 | import { waitJobs } from '../../utils/server/jobs' | 28 | import { waitJobs } from '../../utils/server/jobs' |
29 | import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions' | ||
30 | import { VideoCaption } from '../../../../shared/models/videos/video-caption.model' | ||
29 | 31 | ||
30 | const expect = chai.expect | 32 | const 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 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { doubleFollow, flushAndRunMultipleServers, uploadVideo } from '../../utils' | ||
6 | import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' | ||
7 | import { waitJobs } from '../../utils/server/jobs' | ||
8 | import { createVideoCaption, deleteVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions' | ||
9 | import { VideoCaption } from '../../../../shared/models/videos/video-caption.model' | ||
10 | |||
11 | const expect = chai.expect | ||
12 | |||
13 | describe('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 @@ | |||
1 | WEBVTT | ||
2 | |||
3 | 00:01.000 --> 00:04.000 | ||
4 | Subtitle good 1. | ||
5 | |||
6 | 00: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 @@ | |||
1 | WEBVTT | ||
2 | |||
3 | 00:01.000 --> 00:04.000 | ||
4 | Subtitle good 2. | ||
5 | |||
6 | 00: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' | |||
5 | import * as request from 'supertest' | 5 | import * as request from 'supertest' |
6 | import * as WebTorrent from 'webtorrent' | 6 | import * as WebTorrent from 'webtorrent' |
7 | import { readFileBufferPromise } from '../../../helpers/core-utils' | 7 | import { readFileBufferPromise } from '../../../helpers/core-utils' |
8 | import { ServerInfo } from '..' | ||
9 | 8 | ||
10 | const expect = chai.expect | 9 | const expect = chai.expect |
11 | let webtorrent = new WebTorrent() | 10 | let 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 @@ | |||
1 | import { makeDeleteRequest, makeGetRequest } from '../' | ||
2 | import { buildAbsoluteFixturePath, makeUploadRequest } from '../index' | ||
3 | import * as request from 'supertest' | ||
4 | import * as chai from 'chai' | ||
5 | |||
6 | const expect = chai.expect | ||
7 | |||
8 | function 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 | |||
30 | function 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 | |||
40 | function 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 | |||
51 | async 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 | |||
61 | export { | ||
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' | |||
14 | export * from './video-update.model' | 14 | export * from './video-update.model' |
15 | export * from './video.model' | 15 | export * from './video.model' |
16 | export * from './video-state.enum' | 16 | export * from './video-state.enum' |
17 | export * from './video-caption-update.model' | ||
18 | export { 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 @@ | |||
1 | export 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 @@ | |||
1 | import { VideoConstant } from './video-constant.model' | ||
2 | |||
3 | export 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 @@ | |||
1 | export 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' | |||
4 | import { VideoChannel } from './video-channel.model' | 4 | import { VideoChannel } from './video-channel.model' |
5 | import { VideoPrivacy } from './video-privacy.enum' | 5 | import { VideoPrivacy } from './video-privacy.enum' |
6 | import { VideoScheduleUpdate } from './video-schedule-update.model' | 6 | import { VideoScheduleUpdate } from './video-schedule-update.model' |
7 | 7 | import { VideoConstant } from './video-constant.model' | |
8 | export interface VideoConstant <T> { | ||
9 | id: T | ||
10 | label: string | ||
11 | } | ||
12 | 8 | ||
13 | export interface VideoFile { | 9 | export 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 | ||
43 | log: | 44 | log: |