]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - 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
1 import { truncate } from 'lodash-es'
2 import { UploadState, UploadxOptions, UploadxService } from 'ngx-uploadx'
3 import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
4 import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
5 import { ActivatedRoute, Router } from '@angular/router'
6 import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
7 import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
8 import { FormValidatorService } from '@app/shared/shared-forms'
9 import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
10 import { LoadingBarService } from '@ngx-loading-bar/core'
11 import { logger } from '@root-helpers/logger'
12 import { isIOS } from '@root-helpers/web-browser'
13 import { HttpStatusCode, VideoCreateResult } from '@shared/models'
14 import { UploaderXFormData } from './uploaderx-form-data'
15 import { VideoSend } from './video-send'
16
17 @Component({
18 selector: 'my-video-upload',
19 templateUrl: './video-upload.component.html',
20 styleUrls: [
21 '../shared/video-edit.component.scss',
22 './video-upload.component.scss',
23 './video-send.scss'
24 ]
25 })
26 export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, AfterViewInit, CanComponentDeactivate {
27 @Output() firstStepDone = new EventEmitter<string>()
28 @Output() firstStepError = new EventEmitter<void>()
29 @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
30
31 userVideoQuotaUsed = 0
32 userVideoQuotaUsedDaily = 0
33
34 isUploadingAudioFile = false
35 isUploadingVideo = false
36
37 videoUploaded = false
38 videoUploadPercents = 0
39 videoUploadedIds: VideoCreateResult = {
40 id: 0,
41 uuid: '',
42 shortUUID: ''
43 }
44 formData: FormData
45
46 previewfileUpload: File
47
48 error: string
49 enableRetryAfterError: boolean
50
51 // So that it can be accessed in the template
52 protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + '/upload-resumable'
53
54 private isUpdatingVideo = false
55 private fileToUpload: File
56
57 private alreadyRefreshedToken = false
58
59 constructor (
60 protected formValidatorService: FormValidatorService,
61 protected loadingBar: LoadingBarService,
62 protected notifier: Notifier,
63 protected authService: AuthService,
64 protected serverService: ServerService,
65 protected videoService: VideoService,
66 protected videoCaptionService: VideoCaptionService,
67 private userService: UserService,
68 private router: Router,
69 private hooks: HooksService,
70 private resumableUploadService: UploadxService,
71 private metaService: MetaService,
72 private route: ActivatedRoute
73 ) {
74 super()
75 }
76
77 get videoExtensions () {
78 return this.serverConfig.video.file.extensions.join(', ')
79 }
80
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))
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) {
106 // We can't concatenate strings using $localize
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
119 updateTitle () {
120 const videoName = this.form.get('name').value
121
122 if (this.videoUploaded) {
123 this.metaService.setTitle($localize`Publish ${videoName}`)
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
131 onUploadVideoOngoing (state: UploadState) {
132 switch (state.status) {
133 case 'error': {
134 if (!this.alreadyRefreshedToken && state.response.status === HttpStatusCode.UNAUTHORIZED_401) {
135 this.alreadyRefreshedToken = true
136
137 return this.refereshTokenAndRetryUpload()
138 }
139
140 const error = state.response?.error?.message || state.response?.error || 'Unknown error'
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
154 }
155
156 case 'cancelled':
157 this.isUploadingVideo = false
158 this.videoUploadPercents = 0
159
160 this.firstStepError.emit()
161 this.enableRetryAfterError = false
162 this.error = ''
163 this.isUploadingAudioFile = false
164 break
165
166 case 'queue':
167 this.closeFirstStep(state.name)
168 break
169
170 case 'uploading':
171 // TODO: remove || 0 when // https://github.com/kukhariev/ngx-uploadx/pull/368 is released
172 this.videoUploadPercents = state.progress || 0
173 break
174
175 case 'paused':
176 this.notifier.info($localize`Upload on hold`)
177 break
178
179 case 'complete':
180 this.videoUploaded = true
181 this.videoUploadPercents = 100
182
183 this.videoUploadedIds = state?.response.video
184 break
185 }
186
187 this.updateTitle()
188 }
189
190 onFileDropped (files: FileList) {
191 this.videofileInput.nativeElement.files = files
192
193 this.onFileChange({ target: this.videofileInput.nativeElement })
194 }
195
196 onFileChange (event: Event | { target: HTMLInputElement }) {
197 const file = (event.target as HTMLInputElement).files[0]
198
199 if (!file) return
200
201 if (!this.checkGlobalUserQuota(file)) return
202 if (!this.checkDailyUserQuota(file)) return
203
204 if (this.isAudioFile(file.name)) {
205 this.isUploadingAudioFile = true
206 return
207 }
208
209 this.isUploadingVideo = true
210 this.fileToUpload = file
211
212 this.uploadFile(file)
213 }
214
215 uploadAudio () {
216 this.uploadFile(this.getInputVideoFile(), this.previewfileUpload)
217 }
218
219 retryUpload () {
220 this.enableRetryAfterError = false
221 this.error = ''
222 this.uploadFile(this.fileToUpload)
223 }
224
225 cancelUpload () {
226 this.resumableUploadService.control({ action: 'cancel' })
227 }
228
229 isPublishingButtonDisabled () {
230 return !this.form.valid ||
231 this.isUpdatingVideo === true ||
232 this.videoUploaded !== true ||
233 !this.videoUploadedIds.id
234 }
235
236 getAudioUploadLabel () {
237 const videofile = this.getInputVideoFile()
238 if (!videofile) return $localize`Upload`
239
240 return $localize`Upload ${videofile.name}`
241 }
242
243 async updateSecondStep () {
244 if (!await this.isFormValid()) return
245 if (this.isPublishingButtonDisabled()) return
246
247 const video = new VideoEdit()
248 video.patch(this.form.value)
249 video.id = this.videoUploadedIds.id
250 video.uuid = this.videoUploadedIds.uuid
251 video.shortUUID = this.videoUploadedIds.shortUUID
252
253 this.isUpdatingVideo = true
254
255 this.updateVideoAndCaptions(video)
256 .subscribe({
257 next: () => {
258 this.isUpdatingVideo = false
259 this.isUploadingVideo = false
260
261 this.notifier.success($localize`Video published.`)
262 this.router.navigateByUrl(Video.buildWatchUrl(video))
263 },
264
265 error: err => {
266 this.error = err.message
267 scrollToTop()
268 logger.error(err)
269 }
270 })
271 }
272
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,
280 channelId: this.firstStepChannelId,
281 nsfw: this.serverConfig.instance.isNSFW,
282 privacy: this.highestPrivacy.toString(),
283 name: this.buildVideoFilename(file.name),
284 filename: file.name,
285 previewfile: previewfile as any
286 }
287
288 this.resumableUploadService.handleFiles(file, {
289 ...this.getUploadxOptions(),
290
291 metadata
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) {
315 const name = this.buildVideoFilename(filename)
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)
326 this.updateTitle()
327 }
328
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) {
335 const videoSizeBytes = bytePipes.transform(videofile.size, 0)
336 const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0)
337 const videoQuotaBytes = bytePipes.transform(videoQuota, 0)
338
339 // eslint-disable-next-line max-len
340 const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
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) {
355 const videoSizeBytes = bytePipes.transform(videofile.size, 0)
356 const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0)
357 const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0)
358 // eslint-disable-next-line max-len
359 const msg = $localize`Your daily video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})`
360 this.notifier.error(msg)
361
362 return false
363 }
364
365 return true
366 }
367
368 private isAudioFile (filename: string) {
369 const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ]
370
371 return extensions.some(e => filename.endsWith(e))
372 }
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 }
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 }
419 }