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'
18 selector: 'my-video-upload',
19 templateUrl: './video-upload.component.html',
21 '../shared/video-edit.component.scss',
22 './video-upload.component.scss',
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>
31 userVideoQuotaUsed = 0
32 userVideoQuotaUsedDaily = 0
34 isUploadingAudioFile = false
35 isUploadingVideo = false
38 videoUploadPercents = 0
39 videoUploadedIds: VideoCreateResult = {
46 previewfileUpload: File
49 enableRetryAfterError: boolean
51 // So that it can be accessed in the template
52 protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + '/upload-resumable'
54 private isUpdatingVideo = false
55 private fileToUpload: File
57 private alreadyRefreshedToken = false
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
77 get videoExtensions () {
78 return this.serverConfig.video.file.extensions.join(', ')
84 this.userService.getMyVideoQuotaUsed()
86 this.userVideoQuotaUsed = data.videoQuotaUsed
87 this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily
90 this.resumableUploadService.events
91 .subscribe(state => this.onUploadVideoOngoing(state))
95 this.hooks.runAction('action:video-upload.init', 'video-edit')
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?`
110 text = $localize`Your video is not uploaded yet, are you sure you want to leave this page?`
114 canDeactivate: !this.isUploadingVideo,
120 const videoName = this.form.get('name').value
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}`)
127 this.metaService.update(this.route.snapshot.data['meta'])
131 onUploadVideoOngoing (state: UploadState) {
132 switch (state.status) {
134 if (!this.alreadyRefreshedToken && state.response.status === HttpStatusCode.UNAUTHORIZED_401) {
135 this.alreadyRefreshedToken = true
137 return this.refereshTokenAndRetryUpload()
140 const error = state.response?.error?.message || state.response?.error || 'Unknown error'
142 this.handleUploadError({
143 error: new Error(error),
144 name: 'HttpErrorResponse',
147 headers: new HttpHeaders(state.responseHeaders),
148 status: +state.responseStatus,
150 type: HttpEventType.Response,
157 this.isUploadingVideo = false
158 this.videoUploadPercents = 0
160 this.firstStepError.emit()
161 this.enableRetryAfterError = false
163 this.isUploadingAudioFile = false
167 this.closeFirstStep(state.name)
171 // TODO: remove || 0 when // https://github.com/kukhariev/ngx-uploadx/pull/368 is released
172 this.videoUploadPercents = state.progress || 0
176 this.notifier.info($localize`Upload on hold`)
180 this.videoUploaded = true
181 this.videoUploadPercents = 100
183 this.videoUploadedIds = state?.response.video
190 onFileDropped (files: FileList) {
191 this.videofileInput.nativeElement.files = files
193 this.onFileChange({ target: this.videofileInput.nativeElement })
196 onFileChange (event: Event | { target: HTMLInputElement }) {
197 const file = (event.target as HTMLInputElement).files[0]
201 if (!this.checkGlobalUserQuota(file)) return
202 if (!this.checkDailyUserQuota(file)) return
204 if (this.isAudioFile(file.name)) {
205 this.isUploadingAudioFile = true
209 this.isUploadingVideo = true
210 this.fileToUpload = file
212 this.uploadFile(file)
216 this.uploadFile(this.getInputVideoFile(), this.previewfileUpload)
220 this.enableRetryAfterError = false
222 this.uploadFile(this.fileToUpload)
226 this.resumableUploadService.control({ action: 'cancel' })
229 isPublishingButtonDisabled () {
230 return !this.form.valid ||
231 this.isUpdatingVideo === true ||
232 this.videoUploaded !== true ||
233 !this.videoUploadedIds.id
236 getAudioUploadLabel () {
237 const videofile = this.getInputVideoFile()
238 if (!videofile) return $localize`Upload`
240 return $localize`Upload ${videofile.name}`
243 async updateSecondStep () {
244 if (!await this.isFormValid()) return
245 if (this.isPublishingButtonDisabled()) return
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
253 this.isUpdatingVideo = true
255 this.updateVideoAndCaptions(video)
258 this.isUpdatingVideo = false
259 this.isUploadingVideo = false
261 this.notifier.success($localize`Video published.`)
262 this.router.navigateByUrl(Video.buildWatchUrl(video))
266 this.error = err.message
273 private getInputVideoFile () {
274 return this.videofileInput.nativeElement.files[0]
277 private uploadFile (file: File, previewfile?: File) {
279 waitTranscoding: true,
280 channelId: this.firstStepChannelId,
281 nsfw: this.serverConfig.instance.isNSFW,
282 privacy: this.highestPrivacy.toString(),
283 name: this.buildVideoFilename(file.name),
285 previewfile: previewfile as any
288 this.resumableUploadService.handleFiles(file, {
289 ...this.getUploadxOptions(),
294 this.isUploadingVideo = true
297 private handleUploadError (err: HttpErrorResponse) {
298 // Reset progress (but keep isUploadingVideo true)
299 this.videoUploadPercents = 0
300 this.enableRetryAfterError = true
302 this.error = genericUploadErrorHandler({
304 name: $localize`video`,
305 notifier: this.notifier,
309 if (err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) {
314 private closeFirstStep (filename: string) {
315 const name = this.buildVideoFilename(filename)
317 this.form.patchValue({
319 privacy: this.firstStepPrivacyId,
320 nsfw: this.serverConfig.instance.isNSFW,
321 channelId: this.firstStepChannelId,
322 previewfile: this.previewfileUpload
325 this.firstStepDone.emit(name)
329 private checkGlobalUserQuota (videofile: File) {
330 const bytePipes = new BytesPipe()
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)
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)
349 private checkDailyUserQuota (videofile: File) {
350 const bytePipes = new BytesPipe()
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)
368 private isAudioFile (filename: string) {
369 const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ]
371 return extensions.some(e => filename.endsWith(e))
374 private buildVideoFilename (filename: string) {
375 const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '')
376 let name = nameWithoutExtension.length < 3
378 : nameWithoutExtension
380 const videoNameMaxSize = 110
381 if (name.length > videoNameMaxSize) {
382 name = truncate(name, { length: videoNameMaxSize, omission: '' })
388 private refereshTokenAndRetryUpload () {
389 this.authService.refreshAccessToken()
390 .subscribe(() => this.retryUpload())
393 private getUploadxOptions (): UploadxOptions {
394 // FIXME: https://github.com/Chocobozzz/PeerTube/issues/4382#issuecomment-915854167
395 const chunkSize = isIOS()
397 : undefined // Auto chunk size
400 endpoint: this.BASE_VIDEO_UPLOAD_URL,
403 maxChunkSize: this.serverConfig.client.videos.resumableUpload.maxChunkSize,
406 token: this.authService.getAccessToken(),
408 uploaderClass: UploaderXFormData,
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)