]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
Merge remote-tracking branch 'weblate/develop' into develop
[github/Chocobozzz/PeerTube.git] / client / src / app / +videos / +video-edit / video-add-components / video-upload.component.ts
CommitLineData
45353742 1import { truncate } from 'lodash-es'
d4a8e7a6
C
2import { UploadState, UploadxOptions, UploadxService } from 'ngx-uploadx'
3import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
2e257e36 4import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
72dd3a7c 5import { ActivatedRoute, Router } from '@angular/router'
6import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
d4a8e7a6 7import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
67ed6552 8import { FormValidatorService } from '@app/shared/shared-forms'
d4a8e7a6 9import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
fbad87b0 10import { LoadingBarService } from '@ngx-loading-bar/core'
42b40636
C
11import { logger } from '@root-helpers/logger'
12import { isIOS } from '@root-helpers/web-browser'
f33e5159 13import { HttpStatusCode, VideoCreateResult } from '@shared/models'
d4a8e7a6 14import { UploaderXFormData } from './uploaderx-form-data'
1942f11d 15import { VideoSend } from './video-send'
fbad87b0
C
16
17@Component({
18 selector: 'my-video-upload',
19 templateUrl: './video-upload.component.html',
20 styleUrls: [
78848714 21 '../shared/video-edit.component.scss',
457bb213
C
22 './video-upload.component.scss',
23 './video-send.scss'
fbad87b0
C
24 ]
25})
f6d6e7f8 26export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, AfterViewInit, CanComponentDeactivate {
fbad87b0 27 @Output() firstStepDone = new EventEmitter<string>()
7373507f 28 @Output() firstStepError = new EventEmitter<void>()
2f5d2ec5 29 @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
fbad87b0 30
43620009 31 userVideoQuotaUsed = 0
bee0abff 32 userVideoQuotaUsedDaily = 0
43620009 33
7b992a86 34 isUploadingAudioFile = false
fbad87b0 35 isUploadingVideo = false
7b992a86 36
fbad87b0 37 videoUploaded = false
fbad87b0 38 videoUploadPercents = 0
2e80d256 39 videoUploadedIds: VideoCreateResult = {
fbad87b0 40 id: 0,
2e80d256
C
41 uuid: '',
42 shortUUID: ''
fbad87b0 43 }
d4132d3f 44 formData: FormData
7b992a86 45
7b992a86 46 previewfileUpload: File
fbad87b0 47
7373507f 48 error: string
d4132d3f 49 enableRetryAfterError: boolean
fbad87b0 50
f6d6e7f8 51 // So that it can be accessed in the template
231ff4af 52 protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + '/upload-resumable'
f6d6e7f8 53
f6d6e7f8 54 private isUpdatingVideo = false
55 private fileToUpload: File
fbad87b0 56
83664918
C
57 private alreadyRefreshedToken = false
58
fbad87b0
C
59 constructor (
60 protected formValidatorService: FormValidatorService,
43620009 61 protected loadingBar: LoadingBarService,
f8b2c1b4 62 protected notifier: Notifier,
43620009
C
63 protected authService: AuthService,
64 protected serverService: ServerService,
65 protected videoService: VideoService,
66 protected videoCaptionService: VideoCaptionService,
fbad87b0 67 private userService: UserService,
2e257e36 68 private router: Router,
f6d6e7f8 69 private hooks: HooksService,
72dd3a7c 70 private resumableUploadService: UploadxService,
71 private metaService: MetaService,
72 private route: ActivatedRoute
f6d6e7f8 73 ) {
fbad87b0
C
74 super()
75 }
76
77 get videoExtensions () {
758f0d19 78 return this.serverConfig.video.file.extensions.join(', ')
fbad87b0
C
79 }
80
3ce48a0c
C
81 ngOnInit () {
82 super.ngOnInit()
83
84 this.userService.getMyVideoQuotaUsed()
85 .subscribe(data => {
86 this.userVideoQuotaUsed = data.videoQuotaUsed
87 this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily
88 })
89
90 this.resumableUploadService.events
91 .subscribe(state => this.onUploadVideoOngoing(state))
3ce48a0c
C
92 }
93
94 ngAfterViewInit () {
95 this.hooks.runAction('action:video-upload.init', 'video-edit')
96 }
97
98 ngOnDestroy () {
99 this.cancelUpload()
100 }
101
102 canDeactivate () {
103 let text = ''
104
105 if (this.videoUploaded === true) {
60dd77c6 106 // We can't concatenate strings using $localize
3ce48a0c
C
107 text = $localize`Your video was uploaded to your account and is private.` + ' ' +
108 $localize`But associated data (tags, description...) will be lost, are you sure you want to leave this page?`
109 } else {
110 text = $localize`Your video is not uploaded yet, are you sure you want to leave this page?`
111 }
112
113 return {
114 canDeactivate: !this.isUploadingVideo,
115 text
116 }
117 }
118
72dd3a7c 119 updateTitle () {
120 const videoName = this.form.get('name').value
121
122 if (this.videoUploaded) {
25fd0b59 123 this.metaService.setTitle($localize`Publish ${videoName}`)
72dd3a7c 124 } else if (this.isUploadingAudioFile || this.isUploadingVideo) {
125 this.metaService.setTitle(`${this.videoUploadPercents}% - ${videoName}`)
126 } else {
127 this.metaService.update(this.route.snapshot.data['meta'])
128 }
129 }
130
f6d6e7f8 131 onUploadVideoOngoing (state: UploadState) {
132 switch (state.status) {
9df52d66 133 case 'error': {
83664918
C
134 if (!this.alreadyRefreshedToken && state.response.status === HttpStatusCode.UNAUTHORIZED_401) {
135 this.alreadyRefreshedToken = true
136
137 return this.refereshTokenAndRetryUpload()
138 }
139
f4120aea 140 const error = state.response?.error?.message || state.response?.error || 'Unknown error'
f6d6e7f8 141
142 this.handleUploadError({
143 error: new Error(error),
144 name: 'HttpErrorResponse',
145 message: error,
146 ok: false,
147 headers: new HttpHeaders(state.responseHeaders),
148 status: +state.responseStatus,
149 statusText: error,
150 type: HttpEventType.Response,
151 url: state.url
152 })
153 break
9df52d66 154 }
f6d6e7f8 155
156 case 'cancelled':
157 this.isUploadingVideo = false
158 this.videoUploadPercents = 0
159
160 this.firstStepError.emit()
161 this.enableRetryAfterError = false
162 this.error = ''
decbd0b6 163 this.isUploadingAudioFile = false
f6d6e7f8 164 break
165
166 case 'queue':
167 this.closeFirstStep(state.name)
168 break
169
170 case 'uploading':
72dd3a7c 171 // TODO: remove || 0 when // https://github.com/kukhariev/ngx-uploadx/pull/368 is released
172 this.videoUploadPercents = state.progress || 0
f6d6e7f8 173 break
174
175 case 'paused':
71fb8b5a 176 this.notifier.info($localize`Upload on hold`)
f6d6e7f8 177 break
178
179 case 'complete':
180 this.videoUploaded = true
181 this.videoUploadPercents = 100
182
183 this.videoUploadedIds = state?.response.video
184 break
185 }
72dd3a7c 186
187 this.updateTitle()
f6d6e7f8 188 }
189
f6d6e7f8 190 onFileDropped (files: FileList) {
c9ff8a08 191 this.videofileInput.nativeElement.files = files
7b992a86 192
f6d6e7f8 193 this.onFileChange({ target: this.videofileInput.nativeElement })
7b992a86
C
194 }
195
f6d6e7f8 196 onFileChange (event: Event | { target: HTMLInputElement }) {
197 const file = (event.target as HTMLInputElement).files[0]
e713698f 198
f6d6e7f8 199 if (!file) return
e713698f 200
f6d6e7f8 201 if (!this.checkGlobalUserQuota(file)) return
202 if (!this.checkDailyUserQuota(file)) return
fbad87b0 203
f6d6e7f8 204 if (this.isAudioFile(file.name)) {
7b992a86 205 this.isUploadingAudioFile = true
bee0abff
FA
206 return
207 }
208
fbad87b0 209 this.isUploadingVideo = true
f6d6e7f8 210 this.fileToUpload = file
fbad87b0 211
f6d6e7f8 212 this.uploadFile(file)
d4132d3f
RK
213 }
214
f6d6e7f8 215 uploadAudio () {
216 this.uploadFile(this.getInputVideoFile(), this.previewfileUpload)
217 }
fbad87b0 218
f6d6e7f8 219 retryUpload () {
220 this.enableRetryAfterError = false
221 this.error = ''
222 this.uploadFile(this.fileToUpload)
223 }
f2eb23cd 224
f6d6e7f8 225 cancelUpload () {
226 this.resumableUploadService.control({ action: 'cancel' })
fbad87b0
C
227 }
228
59c9c5d9 229 isPublishingButtonDisabled () {
cc4bf76c 230 return !this.form.valid ||
59c9c5d9 231 this.isUpdatingVideo === true ||
c7a53f61
C
232 this.videoUploaded !== true ||
233 !this.videoUploadedIds.id
59c9c5d9
C
234 }
235
f6d6e7f8 236 getAudioUploadLabel () {
237 const videofile = this.getInputVideoFile()
238 if (!videofile) return $localize`Upload`
239
240 return $localize`Upload ${videofile.name}`
241 }
242
cc4bf76c
C
243 async updateSecondStep () {
244 if (!await this.isFormValid()) return
245 if (this.isPublishingButtonDisabled()) return
fbad87b0
C
246
247 const video = new VideoEdit()
248 video.patch(this.form.value)
249 video.id = this.videoUploadedIds.id
250 video.uuid = this.videoUploadedIds.uuid
2e80d256 251 video.shortUUID = this.videoUploadedIds.shortUUID
fbad87b0
C
252
253 this.isUpdatingVideo = true
43620009
C
254
255 this.updateVideoAndCaptions(video)
1378c0d3
C
256 .subscribe({
257 next: () => {
fbad87b0
C
258 this.isUpdatingVideo = false
259 this.isUploadingVideo = false
fbad87b0 260
66357162 261 this.notifier.success($localize`Video published.`)
d4a8e7a6 262 this.router.navigateByUrl(Video.buildWatchUrl(video))
fbad87b0
C
263 },
264
1378c0d3 265 error: err => {
7373507f
C
266 this.error = err.message
267 scrollToTop()
42b40636 268 logger.error(err)
fbad87b0 269 }
1378c0d3 270 })
fbad87b0 271 }
7b992a86 272
f6d6e7f8 273 private getInputVideoFile () {
274 return this.videofileInput.nativeElement.files[0]
275 }
276
277 private uploadFile (file: File, previewfile?: File) {
278 const metadata = {
279 waitTranscoding: true,
f6d6e7f8 280 channelId: this.firstStepChannelId,
281 nsfw: this.serverConfig.instance.isNSFW,
a3f45a2a 282 privacy: this.highestPrivacy.toString(),
45353742 283 name: this.buildVideoFilename(file.name),
f6d6e7f8 284 filename: file.name,
285 previewfile: previewfile as any
286 }
287
288 this.resumableUploadService.handleFiles(file, {
83664918
C
289 ...this.getUploadxOptions(),
290
291 metadata
f6d6e7f8 292 })
293
294 this.isUploadingVideo = true
295 }
296
297 private handleUploadError (err: HttpErrorResponse) {
298 // Reset progress (but keep isUploadingVideo true)
299 this.videoUploadPercents = 0
300 this.enableRetryAfterError = true
301
302 this.error = genericUploadErrorHandler({
303 err,
304 name: $localize`video`,
305 notifier: this.notifier,
306 sticky: false
307 })
308
309 if (err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) {
310 this.cancelUpload()
311 }
312 }
313
314 private closeFirstStep (filename: string) {
45353742 315 const name = this.buildVideoFilename(filename)
f6d6e7f8 316
317 this.form.patchValue({
318 name,
319 privacy: this.firstStepPrivacyId,
320 nsfw: this.serverConfig.instance.isNSFW,
321 channelId: this.firstStepChannelId,
322 previewfile: this.previewfileUpload
323 })
324
325 this.firstStepDone.emit(name)
72dd3a7c 326 this.updateTitle()
f6d6e7f8 327 }
328
7b992a86
C
329 private checkGlobalUserQuota (videofile: File) {
330 const bytePipes = new BytesPipe()
331
332 // Check global user quota
333 const videoQuota = this.authService.getUser().videoQuota
334 if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
66357162
C
335 const videoSizeBytes = bytePipes.transform(videofile.size, 0)
336 const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0)
337 const videoQuotaBytes = bytePipes.transform(videoQuota, 0)
338
9df52d66 339 // eslint-disable-next-line max-len
f6d6e7f8 340 const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
7b992a86
C
341 this.notifier.error(msg)
342
343 return false
344 }
345
346 return true
347 }
348
349 private checkDailyUserQuota (videofile: File) {
350 const bytePipes = new BytesPipe()
351
352 // Check daily user quota
353 const videoQuotaDaily = this.authService.getUser().videoQuotaDaily
354 if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
66357162
C
355 const videoSizeBytes = bytePipes.transform(videofile.size, 0)
356 const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0)
357 const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0)
9df52d66 358 // eslint-disable-next-line max-len
f6d6e7f8 359 const msg = $localize`Your daily video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})`
7b992a86
C
360 this.notifier.error(msg)
361
362 return false
363 }
364
365 return true
366 }
367
368 private isAudioFile (filename: string) {
99d362de
C
369 const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ]
370
371 return extensions.some(e => filename.endsWith(e))
7b992a86 372 }
45353742
C
373
374 private buildVideoFilename (filename: string) {
375 const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '')
376 let name = nameWithoutExtension.length < 3
377 ? filename
378 : nameWithoutExtension
379
380 const videoNameMaxSize = 110
381 if (name.length > videoNameMaxSize) {
382 name = truncate(name, { length: videoNameMaxSize, omission: '' })
383 }
384
385 return name
386 }
83664918
C
387
388 private refereshTokenAndRetryUpload () {
389 this.authService.refreshAccessToken()
390 .subscribe(() => this.retryUpload())
391 }
392
393 private getUploadxOptions (): UploadxOptions {
394 // FIXME: https://github.com/Chocobozzz/PeerTube/issues/4382#issuecomment-915854167
395 const chunkSize = isIOS()
396 ? 0
397 : undefined // Auto chunk size
398
399 return {
400 endpoint: this.BASE_VIDEO_UPLOAD_URL,
401 multiple: false,
402
403 maxChunkSize: this.serverConfig.client.videos.resumableUpload.maxChunkSize,
404 chunkSize,
405
406 token: this.authService.getAccessToken(),
407
408 uploaderClass: UploaderXFormData,
409
410 retryConfig: {
411 maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below
412 maxDelay: 120_000, // 2 min
413 shouldRetry: (code: number, attempts: number) => {
414 return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6)
415 }
416 }
417 }
418 }
fbad87b0 419}