]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
Resumable video uploads (#3933)
[github/Chocobozzz/PeerTube.git] / client / src / app / +videos / +video-edit / video-add-components / video-upload.component.ts
index bee3679f7480b46e57f7b6954be9bc536d0459ce..2d3fc3578fb32eb1269eee57df8522911b842fe9 100644 (file)
@@ -1,14 +1,16 @@
-import { Subscription } from 'rxjs'
-import { HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http'
-import { Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
+import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
 import { Router } from '@angular/router'
-import { AuthService, CanComponentDeactivate, Notifier, ServerService, UserService } from '@app/core'
-import { scrollToTop, uploadErrorHandler } from '@app/helpers'
+import { UploadxOptions, UploadState, UploadxService } from 'ngx-uploadx'
+import { UploaderXFormData } from './uploaderx-form-data'
+import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService, UserService } from '@app/core'
+import { scrollToTop, genericUploadErrorHandler } from '@app/helpers'
 import { FormValidatorService } from '@app/shared/shared-forms'
 import { BytesPipe, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
+import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
 import { VideoPrivacy } from '@shared/models'
 import { VideoSend } from './video-send'
+import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
 
 @Component({
   selector: 'my-video-upload',
@@ -19,23 +21,18 @@ import { VideoSend } from './video-send'
     './video-send.scss'
   ]
 })
-export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate {
+export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, AfterViewInit, CanComponentDeactivate {
   @Output() firstStepDone = new EventEmitter<string>()
   @Output() firstStepError = new EventEmitter<void>()
   @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
 
-  // So that it can be accessed in the template
-  readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
-
   userVideoQuotaUsed = 0
   userVideoQuotaUsedDaily = 0
 
   isUploadingAudioFile = false
   isUploadingVideo = false
-  isUpdatingVideo = false
 
   videoUploaded = false
-  videoUploadObservable: Subscription = null
   videoUploadPercents = 0
   videoUploadedIds = {
     id: 0,
@@ -43,13 +40,18 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
   }
   formData: FormData
 
-  waitTranscodingEnabled = true
   previewfileUpload: File
 
   error: string
   enableRetryAfterError: boolean
 
+  // So that it can be accessed in the template
   protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC
+  protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + 'upload-resumable'
+
+  private uploadxOptions: UploadxOptions
+  private isUpdatingVideo = false
+  private fileToUpload: File
 
   constructor (
     protected formValidatorService: FormValidatorService,
@@ -60,15 +62,78 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     protected videoService: VideoService,
     protected videoCaptionService: VideoCaptionService,
     private userService: UserService,
-    private router: Router
-    ) {
+    private router: Router,
+    private hooks: HooksService,
+    private resumableUploadService: UploadxService
+  ) {
     super()
+
+    this.uploadxOptions = {
+      endpoint: this.BASE_VIDEO_UPLOAD_URL,
+      multiple: false,
+      token: this.authService.getAccessToken(),
+      uploaderClass: UploaderXFormData,
+      retryConfig: {
+        maxAttempts: 6,
+        shouldRetry: (code: number) => {
+          return code < 400 || code >= 501
+        }
+      }
+    }
   }
 
   get videoExtensions () {
     return this.serverConfig.video.file.extensions.join(', ')
   }
 
+  onUploadVideoOngoing (state: UploadState) {
+    switch (state.status) {
+      case 'error':
+        const error = state.response?.error || 'Unknow error'
+
+        this.handleUploadError({
+          error: new Error(error),
+          name: 'HttpErrorResponse',
+          message: error,
+          ok: false,
+          headers: new HttpHeaders(state.responseHeaders),
+          status: +state.responseStatus,
+          statusText: error,
+          type: HttpEventType.Response,
+          url: state.url
+        })
+        break
+
+      case 'cancelled':
+        this.isUploadingVideo = false
+        this.videoUploadPercents = 0
+
+        this.firstStepError.emit()
+        this.enableRetryAfterError = false
+        this.error = ''
+        break
+
+      case 'queue':
+        this.closeFirstStep(state.name)
+        break
+
+      case 'uploading':
+        this.videoUploadPercents = state.progress
+        break
+
+      case 'paused':
+        this.notifier.info($localize`Upload cancelled`)
+        break
+
+      case 'complete':
+        this.videoUploaded = true
+        this.videoUploadPercents = 100
+
+        this.videoUploadedIds = state?.response.video
+        break
+    }
+  }
+
   ngOnInit () {
     super.ngOnInit()
 
@@ -77,10 +142,17 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
           this.userVideoQuotaUsed = data.videoQuotaUsed
           this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily
         })
+
+    this.resumableUploadService.events
+      .subscribe(state => this.onUploadVideoOngoing(state))
+  }
+
+  ngAfterViewInit () {
+    this.hooks.runAction('action:video-upload.init', 'video-edit')
   }
 
   ngOnDestroy () {
-    if (this.videoUploadObservable) this.videoUploadObservable.unsubscribe()
+    this.cancelUpload()
   }
 
   canDeactivate () {
@@ -100,137 +172,43 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     }
   }
 
-  getVideoFile () {
-    return this.videofileInput.nativeElement.files[0]
-  }
-
-  setVideoFile (files: FileList) {
+  onFileDropped (files: FileList) {
     this.videofileInput.nativeElement.files = files
-    this.fileChange()
-  }
-
-  getAudioUploadLabel () {
-    const videofile = this.getVideoFile()
-    if (!videofile) return $localize`Upload`
-
-    return $localize`Upload ${videofile.name}`
-  }
 
-  fileChange () {
-    this.uploadFirstStep()
+    this.onFileChange({ target: this.videofileInput.nativeElement })
   }
 
-  retryUpload () {
-    this.enableRetryAfterError = false
-    this.error = ''
-    this.uploadVideo()
-  }
-
-  cancelUpload () {
-    if (this.videoUploadObservable !== null) {
-      this.videoUploadObservable.unsubscribe()
-
-      this.isUploadingVideo = false
-      this.videoUploadPercents = 0
-      this.videoUploadObservable = null
-
-      this.firstStepError.emit()
-      this.enableRetryAfterError = false
-      this.error = ''
+  onFileChange (event: Event | { target: HTMLInputElement }) {
+    const file = (event.target as HTMLInputElement).files[0]
 
-      this.notifier.info($localize`Upload cancelled`)
-    }
-  }
+    if (!file) return
 
-  uploadFirstStep (clickedOnButton = false) {
-    const videofile = this.getVideoFile()
-    if (!videofile) return
+    if (!this.checkGlobalUserQuota(file)) return
+    if (!this.checkDailyUserQuota(file)) return
 
-    if (!this.checkGlobalUserQuota(videofile)) return
-    if (!this.checkDailyUserQuota(videofile)) return
-
-    if (clickedOnButton === false && this.isAudioFile(videofile.name)) {
+    if (this.isAudioFile(file.name)) {
       this.isUploadingAudioFile = true
       return
     }
 
-    // Build name field
-    const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '')
-    let name: string
-
-    // If the name of the file is very small, keep the extension
-    if (nameWithoutExtension.length < 3) name = videofile.name
-    else name = nameWithoutExtension
-
-    // Force user to wait transcoding for unsupported video types in web browsers
-    if (!videofile.name.endsWith('.mp4') && !videofile.name.endsWith('.webm') && !videofile.name.endsWith('.ogv')) {
-      this.waitTranscodingEnabled = false
-    }
-
-    const nsfw = this.serverConfig.instance.isNSFW
-    const waitTranscoding = true
-    const commentsEnabled = true
-    const downloadEnabled = true
-    const channelId = this.firstStepChannelId.toString()
-
-    this.formData = new FormData()
-    this.formData.append('name', name)
-    // Put the video "private" -> we are waiting the user validation of the second step
-    this.formData.append('privacy', VideoPrivacy.PRIVATE.toString())
-    this.formData.append('nsfw', '' + nsfw)
-    this.formData.append('commentsEnabled', '' + commentsEnabled)
-    this.formData.append('downloadEnabled', '' + downloadEnabled)
-    this.formData.append('waitTranscoding', '' + waitTranscoding)
-    this.formData.append('channelId', '' + channelId)
-    this.formData.append('videofile', videofile)
-
-    if (this.previewfileUpload) {
-      this.formData.append('previewfile', this.previewfileUpload)
-      this.formData.append('thumbnailfile', this.previewfileUpload)
-    }
-
     this.isUploadingVideo = true
-    this.firstStepDone.emit(name)
-
-    this.form.patchValue({
-      name,
-      privacy: this.firstStepPrivacyId,
-      nsfw,
-      channelId: this.firstStepChannelId,
-      previewfile: this.previewfileUpload
-    })
+    this.fileToUpload = file
 
-    this.uploadVideo()
+    this.uploadFile(file)
   }
 
-  uploadVideo () {
-    this.videoUploadObservable = this.videoService.uploadVideo(this.formData).subscribe(
-      event => {
-        if (event.type === HttpEventType.UploadProgress) {
-          this.videoUploadPercents = Math.round(100 * event.loaded / event.total)
-        } else if (event instanceof HttpResponse) {
-          this.videoUploaded = true
-
-          this.videoUploadedIds = event.body.video
+  uploadAudio () {
+    this.uploadFile(this.getInputVideoFile(), this.previewfileUpload)
+  }
 
-          this.videoUploadObservable = null
-        }
-      },
+  retryUpload () {
+    this.enableRetryAfterError = false
+    this.error = ''
+    this.uploadFile(this.fileToUpload)
+  }
 
-      (err: HttpErrorResponse) => {
-        // Reset progress (but keep isUploadingVideo true)
-        this.videoUploadPercents = 0
-        this.videoUploadObservable = null
-        this.enableRetryAfterError = true
-
-        this.error = uploadErrorHandler({
-          err,
-          name: $localize`video`,
-          notifier: this.notifier,
-          sticky: false
-        })
-      }
-    )
+  cancelUpload () {
+    this.resumableUploadService.control({ action: 'cancel' })
   }
 
   isPublishingButtonDisabled () {
@@ -240,6 +218,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
       !this.videoUploadedIds.id
   }
 
+  getAudioUploadLabel () {
+    const videofile = this.getInputVideoFile()
+    if (!videofile) return $localize`Upload`
+
+    return $localize`Upload ${videofile.name}`
+  }
+
   updateSecondStep () {
     if (this.isPublishingButtonDisabled() || !this.checkForm()) {
       return
@@ -270,6 +255,62 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
         )
   }
 
+  private getInputVideoFile () {
+    return this.videofileInput.nativeElement.files[0]
+  }
+
+  private uploadFile (file: File, previewfile?: File) {
+    const metadata = {
+      waitTranscoding: true,
+      commentsEnabled: true,
+      downloadEnabled: true,
+      channelId: this.firstStepChannelId,
+      nsfw: this.serverConfig.instance.isNSFW,
+      privacy: VideoPrivacy.PRIVATE.toString(),
+      filename: file.name,
+      previewfile: previewfile as any
+    }
+
+    this.resumableUploadService.handleFiles(file, {
+      ...this.uploadxOptions,
+      metadata
+    })
+
+    this.isUploadingVideo = true
+  }
+
+  private handleUploadError (err: HttpErrorResponse) {
+    // Reset progress (but keep isUploadingVideo true)
+    this.videoUploadPercents = 0
+    this.enableRetryAfterError = true
+
+    this.error = genericUploadErrorHandler({
+      err,
+      name: $localize`video`,
+      notifier: this.notifier,
+      sticky: false
+    })
+
+    if (err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) {
+      this.cancelUpload()
+    }
+  }
+
+  private closeFirstStep (filename: string) {
+    const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '')
+    const name = nameWithoutExtension.length < 3 ? filename : nameWithoutExtension
+
+    this.form.patchValue({
+      name,
+      privacy: this.firstStepPrivacyId,
+      nsfw: this.serverConfig.instance.isNSFW,
+      channelId: this.firstStepChannelId,
+      previewfile: this.previewfileUpload
+    })
+
+    this.firstStepDone.emit(name)
+  }
+
   private checkGlobalUserQuota (videofile: File) {
     const bytePipes = new BytesPipe()
 
@@ -280,8 +321,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
       const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0)
       const videoQuotaBytes = bytePipes.transform(videoQuota, 0)
 
-      const msg = $localize`Your video quota is exceeded with this video (
-video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
+      const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
       this.notifier.error(msg)
 
       return false
@@ -299,9 +339,7 @@ video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuota
       const videoSizeBytes = bytePipes.transform(videofile.size, 0)
       const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0)
       const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0)
-
-      const msg = $localize`Your daily video quota is exceeded with this video (
-video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})`
+      const msg = $localize`Your daily video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})`
       this.notifier.error(msg)
 
       return false