]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Implement video file replacement in client
authorChocobozzz <me@florianbigard.com>
Fri, 21 Jul 2023 15:46:37 +0000 (17:46 +0200)
committerChocobozzz <me@florianbigard.com>
Tue, 25 Jul 2023 09:53:08 +0000 (11:53 +0200)
26 files changed:
client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+videos/+video-edit/shared/upload-progress.component.html [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/upload-progress.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/upload-progress.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/uploaderx-form-data.ts [moved from client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts with 100% similarity]
client/src/app/+videos/+video-edit/shared/video-edit.component.html
client/src/app/+videos/+video-edit/shared/video-edit.component.scss
client/src/app/+videos/+video-edit/shared/video-edit.component.ts
client/src/app/+videos/+video-edit/shared/video-edit.module.ts
client/src/app/+videos/+video-edit/shared/video-upload.service.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
client/src/app/+videos/+video-edit/video-update.component.html
client/src/app/+videos/+video-edit/video-update.component.ts
client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html
client/src/app/helpers/utils/upload.ts
client/src/app/shared/shared-forms/reactive-file.component.html
client/src/app/shared/shared-forms/reactive-file.component.scss
client/src/app/shared/shared-forms/reactive-file.component.ts
client/src/app/shared/shared-main/video/video-details.model.ts
client/src/app/shared/shared-main/video/video.model.ts
client/src/assets/player/shared/upnext/end-card.ts
server/controllers/api/videos/source.ts
shared/server-commands/videos/videos-command.ts

index b81393731eb254ae622f3c4d24f1ec224e6c57fe..c0e4533aa931ba79cc62f81178412dd7978fa347 100644 (file)
         </ng-container>
       </ng-container>
 
+      <ng-container formGroupName="videoFile">
+        <ng-container formGroupName="update">
+          <div class="form-group">
+            <my-peertube-checkbox
+              inputName="videoFileUpdateEnabled" formControlName="enabled"
+              i18n-labelText labelText="Allow users to upload a new version of their video"
+            >
+            </my-peertube-checkbox>
+          </div>
+        </ng-container>
+      </ng-container>
+
     </div>
   </div>
 
index b381473d64bb8ac0dcb69d4afe97efd643767391..c3b85b196f9dd3a4eed995691fff58cbc79705fe 100644 (file)
@@ -225,6 +225,11 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
           enabled: null
         }
       },
+      videoFile: {
+        update: {
+          enabled: null
+        }
+      },
       autoBlacklist: {
         videos: {
           ofUsers: {
diff --git a/client/src/app/+videos/+video-edit/shared/upload-progress.component.html b/client/src/app/+videos/+video-edit/shared/upload-progress.component.html
new file mode 100644 (file)
index 0000000..f1626b8
--- /dev/null
@@ -0,0 +1,32 @@
+<!-- Upload progress/cancel/error/success header -->
+<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel">
+  <div class="progress" i18n-title title="Total video uploaded">
+    <div
+      class="progress-bar" role="progressbar"
+      [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100"
+    >
+      <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span>
+      <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
+    </div>
+  </div>
+  <input
+    *ngIf="videoUploaded === false"
+    type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancel.emit()"
+  />
+</div>
+
+<div *ngIf="error && enableRetryAfterError" class="upload-progress-retry">
+  <div class="progress">
+    <div class="progress-bar red" role="progressbar" [style]="{ width: '100%' }" [attr.aria-valuenow]="100" aria-valuemin="0" [attr.aria-valuemax]="100">
+      <span>{{ error }}</span>
+    </div>
+  </div>
+
+  <input type="button" class="peertube-button grey-button ms-1" i18n-value="Retry failed upload of a video" value="Retry" (click)="retry.emit()" />
+  <input type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancel.emit()" />
+</div>
+
+<div *ngIf="error && !enableRetryAfterError" class="alert alert-danger">
+  <div i18n>Sorry, but something went wrong</div>
+  {{ error }}
+</div>
diff --git a/client/src/app/+videos/+video-edit/shared/upload-progress.component.scss b/client/src/app/+videos/+video-edit/shared/upload-progress.component.scss
new file mode 100644 (file)
index 0000000..609a31e
--- /dev/null
@@ -0,0 +1,30 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+.upload-progress-retry,
+.upload-progress-cancel {
+  display: flex;
+  margin-bottom: 40px;
+
+  .progress {
+    @include progressbar;
+
+    flex-grow: 1;
+    height: 30px;
+    font-size: 14px;
+    background-color: rgba(11, 204, 41, 0.16);
+
+    .progress-bar {
+      background-color: $green;
+      line-height: 30px;
+      text-align: start;
+      font-weight: $font-semibold;
+
+      span {
+        @include margin-left(13px);
+
+        color: pvar(--mainBackgroundColor);
+      }
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/shared/upload-progress.component.ts b/client/src/app/+videos/+video-edit/shared/upload-progress.component.ts
new file mode 100644 (file)
index 0000000..9ce3a2c
--- /dev/null
@@ -0,0 +1,17 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core'
+
+@Component({
+  selector: 'my-upload-progress',
+  templateUrl: './upload-progress.component.html',
+  styleUrls: [ './upload-progress.component.scss' ]
+})
+export class UploadProgressComponent {
+  @Input() isUploadingVideo: boolean
+  @Input() videoUploadPercents: number
+  @Input() error: string
+  @Input() videoUploaded: boolean
+  @Input() enableRetryAfterError: boolean
+
+  @Output() cancel = new EventEmitter()
+  @Output() retry = new EventEmitter()
+}
index 97b71387489346521531d6792a302c7929c07e7f..579b63c6dc85c9d166a386bbeb2fdcedc90f331a 100644 (file)
               <label i18n for="videoPassword">Password</label>
               <my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text>
             </div>
-            
+
             <div *ngIf="schedulePublicationSelected" class="form-group">
               <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
               <p-calendar
         <div class="row advanced-settings">
           <div class="col-md-12 col-xl-8">
 
+            <ng-content></ng-content>
+
             <div class="form-group">
               <label i18n for="previewfile">Video thumbnail</label>
 
index 1c6f7f5abc763a37cfda59d33ec51bfdbae95392..b0c0530190f61f94985a3ead33630655d7583cc8 100644 (file)
@@ -112,6 +112,11 @@ p-calendar {
   grid-gap: 30px;
 }
 
+.button-file {
+  @include peertube-button-file(max-content);
+  @include orange-button;
+}
+
 @include on-small-main-col {
   .form-columns {
     grid-template-columns: 1fr;
index 5e5df8db7883dcdb9d05e289f9556f2cd718e933..460960a01a7695efc85e4171d375d7d0739bb214 100644 (file)
@@ -68,6 +68,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
   @Input() videoSource: VideoSource
 
   @Input() hideWaitTranscoding = false
+  @Input() updateVideoFileEnabled = false
 
   @Input() type: VideoEditType
   @Input() liveVideo: LiveVideo
index d463bf63334fdf23660b8893a75d04e14dfae4bd..cf9742b84339734fec2ec45302de05971a0c06d5 100644 (file)
@@ -5,9 +5,11 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons'
 import { SharedMainModule } from '@app/shared/shared-main'
 import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
 import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
+import { UploadProgressComponent } from './upload-progress.component'
 import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
 import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
 import { VideoEditComponent } from './video-edit.component'
+import { VideoUploadService } from './video-upload.service'
 
 @NgModule({
   imports: [
@@ -22,7 +24,8 @@ import { VideoEditComponent } from './video-edit.component'
   declarations: [
     VideoEditComponent,
     VideoCaptionAddModalComponent,
-    VideoCaptionEditModalContentComponent
+    VideoCaptionEditModalContentComponent,
+    UploadProgressComponent
   ],
 
   exports: [
@@ -32,11 +35,13 @@ import { VideoEditComponent } from './video-edit.component'
     SharedFormModule,
     SharedGlobalIconModule,
 
-    VideoEditComponent
+    VideoEditComponent,
+    UploadProgressComponent
   ],
 
   providers: [
-    I18nPrimengCalendarService
+    I18nPrimengCalendarService,
+    VideoUploadService
   ]
 })
 export class VideoEditModule { }
diff --git a/client/src/app/+videos/+video-edit/shared/video-upload.service.ts b/client/src/app/+videos/+video-edit/shared/video-upload.service.ts
new file mode 100644 (file)
index 0000000..cb95035
--- /dev/null
@@ -0,0 +1,110 @@
+import { UploaderX, UploadState, UploadxOptions } from 'ngx-uploadx'
+import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { BytesPipe, VideoService } from '@app/shared/shared-main'
+import { isIOS } from '@root-helpers/web-browser'
+import { HttpStatusCode } from '@shared/models'
+import { UploaderXFormData } from './uploaderx-form-data'
+
+@Injectable()
+export class VideoUploadService {
+
+  constructor (
+    private server: ServerService,
+    private notifier: Notifier,
+    private authService: AuthService
+  ) {
+
+  }
+
+  getVideoExtensions () {
+    return this.server.getHTMLConfig().video.file.extensions
+  }
+
+  checkQuotaAndNotify (videoFile: File, maxQuota: number, quotaUsed: number) {
+    const bytePipes = new BytesPipe()
+
+    // Check global user quota
+    if (maxQuota !== -1 && (quotaUsed + videoFile.size) > maxQuota) {
+      const videoSizeBytes = bytePipes.transform(videoFile.size, 0)
+      const videoQuotaUsedBytes = bytePipes.transform(quotaUsed, 0)
+      const videoQuotaBytes = bytePipes.transform(maxQuota, 0)
+
+      // eslint-disable-next-line max-len
+      const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
+      this.notifier.error(msg)
+
+      return false
+    }
+
+    return true
+  }
+
+  isAudioFile (filename: string) {
+    const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ]
+
+    return extensions.some(e => filename.endsWith(e))
+  }
+
+  // ---------------------------------------------------------------------------
+
+  getNewUploadxOptions (): UploadxOptions {
+    return this.getUploadxOptions(
+      VideoService.BASE_VIDEO_URL + '/upload-resumable',
+      UploaderXFormData
+    )
+  }
+
+  getReplaceUploadxOptions (videoId: string): UploadxOptions {
+    return this.getUploadxOptions(
+      VideoService.BASE_VIDEO_URL + '/' + videoId + '/source/replace-resumable',
+      UploaderX
+    )
+  }
+
+  private getUploadxOptions (endpoint: string, uploaderClass: typeof UploaderXFormData) {
+    // FIXME: https://github.com/Chocobozzz/PeerTube/issues/4382#issuecomment-915854167
+    const chunkSize = isIOS()
+      ? 0
+      : undefined // Auto chunk size
+
+    return {
+      endpoint,
+      multiple: false,
+
+      maxChunkSize: this.server.getHTMLConfig().client.videos.resumableUpload.maxChunkSize,
+      chunkSize,
+
+      token: this.authService.getAccessToken(),
+
+      uploaderClass,
+
+      retryConfig: {
+        maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below
+        maxDelay: 120_000, // 2 min
+        shouldRetry: (code: number, attempts: number) => {
+          return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6)
+        }
+      }
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+
+  buildHTTPErrorResponse (state: UploadState): HttpErrorResponse {
+    const error = state.response?.error?.message || state.response?.error || 'Unknown error'
+
+    return {
+      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
+    }
+  }
+}
index 7b6bd993c9890a52ec6b3dde6f9e83f5ca88933b..dcbb358faa7a8a02360f7daac68d911c6f167eea 100644 (file)
@@ -2,13 +2,13 @@
   <div class="first-step-block">
     <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
 
-    <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'">
+    <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + getVideoExtensions() + ')'">
       <span i18n>Select the file to upload</span>
       <input
         aria-label="Select the file to upload"
         i18n-aria-label
         #videofileInput
-        [accept]="videoExtensions"
+        [accept]="getVideoExtensions()"
         (change)="onFileChange($event)"
         id="videofile"
         type="file"
   </div>
 </div>
 
-<!-- Upload progress/cancel/error/success header -->
-<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel">
-  <div class="progress" i18n-title title="Total video uploaded">
-    <div class="progress-bar" role="progressbar" [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100">
-      <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span>
-      <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
-    </div>
-  </div>
-  <input
-    *ngIf="videoUploaded === false"
-    type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()"
-  />
-</div>
-
-<div *ngIf="error && enableRetryAfterError" class="upload-progress-retry">
-  <div class="progress">
-    <div class="progress-bar red" role="progressbar" [style]="{ width: '100%' }" [attr.aria-valuenow]="100" aria-valuemin="0" [attr.aria-valuemax]="100">
-      <span>{{ error }}</span>
-    </div>
-  </div>
-
-  <input type="button" class="peertube-button grey-button ms-1" i18n-value="Retry failed upload of a video" value="Retry" (click)="retryUpload()" />
-  <input type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" />
-</div>
-
-<div *ngIf="error && !enableRetryAfterError" class="alert alert-danger">
-  <div i18n>Sorry, but something went wrong</div>
-  {{ error }}
-</div>
+<my-upload-progress
+  [isUploadingVideo]="isUploadingVideo" [videoUploadPercents]="videoUploadPercents" [error]="error" [videoUploaded]="videoUploaded"
+  [enableRetryAfterError]="enableRetryAfterError" (cancel)="cancelUpload()" (retry)="retryUpload()"
+>
+</my-upload-progress>
 
 <div *ngIf="videoUploaded && !error" class="alert pt-alert-primary" i18n>
   Congratulations! Your video is now available in your private library.
index 52a77f83f925e989a12360910f7fc6a3d1b3a4a3..ed817bff730e4a766777d6800f52ce4f43e05e74 100644 (file)
     margin: 30px 0;
   }
 }
-
-.upload-progress-retry,
-.upload-progress-cancel {
-  display: flex;
-  margin-bottom: 40px;
-
-  .progress {
-    @include progressbar;
-
-    flex-grow: 1;
-    height: 30px;
-    font-size: 14px;
-    background-color: rgba(11, 204, 41, 0.16);
-
-    .progress-bar {
-      background-color: $green;
-      line-height: 30px;
-      text-align: start;
-      font-weight: $font-semibold;
-
-      span {
-        @include margin-left(13px);
-
-        color: pvar(--mainBackgroundColor);
-      }
-    }
-  }
-}
index 967fa9ed105aca064ba7d17d4786b7cde277bab1..cfa42910b982327f9cf768376f79d0ca90113fe5 100644 (file)
@@ -1,19 +1,18 @@
 import { truncate } from 'lodash-es'
-import { UploadState, UploadxOptions, UploadxService } from 'ngx-uploadx'
-import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
+import { UploadState, UploadxService } from 'ngx-uploadx'
+import { Subscription } from 'rxjs'
+import { HttpErrorResponse } from '@angular/common/http'
 import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
 import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
 import { FormReactiveService } from '@app/shared/shared-forms'
-import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
-import { isIOS } from '@root-helpers/web-browser'
 import { HttpStatusCode, VideoCreateResult } from '@shared/models'
-import { UploaderXFormData } from './uploaderx-form-data'
+import { VideoUploadService } from '../shared/video-upload.service'
 import { VideoSend } from './video-send'
-import { Subscription } from 'rxjs'
 
 @Component({
   selector: 'my-video-upload',
@@ -49,9 +48,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
   error: string
   enableRetryAfterError: boolean
 
-  // So that it can be accessed in the template
-  protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + '/upload-resumable'
-
   private isUpdatingVideo = false
   private fileToUpload: File
 
@@ -72,15 +68,12 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     private hooks: HooksService,
     private resumableUploadService: UploadxService,
     private metaService: MetaService,
-    private route: ActivatedRoute
+    private route: ActivatedRoute,
+    private videoUploadService: VideoUploadService
   ) {
     super()
   }
 
-  get videoExtensions () {
-    return this.serverConfig.video.file.extensions.join(', ')
-  }
-
   ngOnInit () {
     super.ngOnInit()
 
@@ -133,28 +126,20 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     }
   }
 
+  getVideoExtensions () {
+    return this.videoUploadService.getVideoExtensions().join(', ')
+  }
+
   onUploadVideoOngoing (state: UploadState) {
     switch (state.status) {
       case 'error': {
         if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) {
           this.alreadyRefreshedToken = true
 
-          return this.refereshTokenAndRetryUpload()
+          return this.refreshTokenAndRetryUpload()
         }
 
-        const error = state.response?.error?.message || state.response?.error || 'Unknown 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
-        })
+        this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state))
         break
       }
 
@@ -203,10 +188,12 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
 
     if (!file) return
 
-    if (!this.checkGlobalUserQuota(file)) return
-    if (!this.checkDailyUserQuota(file)) return
+    const user = this.authService.getUser()
+
+    if (!this.videoUploadService.checkQuotaAndNotify(file, user.videoQuota, this.userVideoQuotaUsed)) return
+    if (!this.videoUploadService.checkQuotaAndNotify(file, user.videoQuotaDaily, this.userVideoQuotaUsedDaily)) return
 
-    if (this.isAudioFile(file.name)) {
+    if (this.videoUploadService.isAudioFile(file.name)) {
       this.isUploadingAudioFile = true
       return
     }
@@ -291,7 +278,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     }
 
     this.resumableUploadService.handleFiles(file, {
-      ...this.getUploadxOptions(),
+      ...this.videoUploadService.getNewUploadxOptions(),
 
       metadata
     })
@@ -331,51 +318,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     this.updateTitle()
   }
 
-  private checkGlobalUserQuota (videofile: File) {
-    const bytePipes = new BytesPipe()
-
-    // Check global user quota
-    const videoQuota = this.authService.getUser().videoQuota
-    if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
-      const videoSizeBytes = bytePipes.transform(videofile.size, 0)
-      const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0)
-      const videoQuotaBytes = bytePipes.transform(videoQuota, 0)
-
-      // eslint-disable-next-line max-len
-      const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
-      this.notifier.error(msg)
-
-      return false
-    }
-
-    return true
-  }
-
-  private checkDailyUserQuota (videofile: File) {
-    const bytePipes = new BytesPipe()
-
-    // Check daily user quota
-    const videoQuotaDaily = this.authService.getUser().videoQuotaDaily
-    if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
-      const videoSizeBytes = bytePipes.transform(videofile.size, 0)
-      const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0)
-      const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0)
-      // eslint-disable-next-line max-len
-      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
-    }
-
-    return true
-  }
-
-  private isAudioFile (filename: string) {
-    const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ]
-
-    return extensions.some(e => filename.endsWith(e))
-  }
-
   private buildVideoFilename (filename: string) {
     const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '')
     let name = nameWithoutExtension.length < 3
@@ -390,35 +332,8 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     return name
   }
 
-  private refereshTokenAndRetryUpload () {
+  private refreshTokenAndRetryUpload () {
     this.authService.refreshAccessToken()
       .subscribe(() => this.retryUpload())
   }
-
-  private getUploadxOptions (): UploadxOptions {
-    // FIXME: https://github.com/Chocobozzz/PeerTube/issues/4382#issuecomment-915854167
-    const chunkSize = isIOS()
-      ? 0
-      : undefined // Auto chunk size
-
-    return {
-      endpoint: this.BASE_VIDEO_UPLOAD_URL,
-      multiple: false,
-
-      maxChunkSize: this.serverConfig.client.videos.resumableUpload.maxChunkSize,
-      chunkSize,
-
-      token: this.authService.getAccessToken(),
-
-      uploaderClass: UploaderXFormData,
-
-      retryConfig: {
-        maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below
-        maxDelay: 120_000, // 2 min
-        shouldRetry: (code: number, attempts: number) => {
-          return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6)
-        }
-      }
-    }
-  }
 }
index af564aeb0be3666c8759b71a3a4134617bfd8383..9a99c0c3dee2e415e7047ed6a15cda654cf3b7bc 100644 (file)
@@ -4,6 +4,12 @@
     <a [routerLink]="getVideoUrl()">{{ videoDetails?.name }}</a>
   </div>
 
+  <my-upload-progress
+    [isUploadingVideo]="isReplacingVideoFile" [videoUploadPercents]="videoUploadPercents" [error]="uploadError" [videoUploaded]="updateDone"
+    [enableRetryAfterError]="false" (cancel)="cancelUpload()"
+  >
+  </my-upload-progress>
+
   <form novalidate [formGroup]="form">
 
     <my-video-edit
       [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()"
       type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()"
       [liveVideo]="liveVideo" [videoToUpdate]="videoDetails"
-      [videoSource]="videoSource"
+      [videoSource]="videoSource" [updateVideoFileEnabled]="isUpdateVideoFileEnabled()"
 
       (formBuilt)="onFormBuilt()"
-    ></my-video-edit>
+    >
+
+      <div *ngIf="isUpdateVideoFileEnabled()" class="form-group">
+        <label class="mb-0" i18n for="videofile">Replace video file</label>
+
+        <div i18n class="form-group-description">⚠️ Uploading a new version of your video will completely erase the current version</div>
+
+        <div>
+          <my-reactive-file
+            formControlName="replaceFile"
+            i18n-inputLabel inputLabel="Select the file to upload"
+            inputName="videofile" [extensions]="getVideoExtensions()" [displayFilename]="true" [displayReset]="true"
+            [buttonTooltip]="'(extensions: ' + getVideoExtensions() + ')'"
+            theme="primary"
+          ></my-reactive-file>
+        </div>
+      </div>
+    </my-video-edit>
 
     <div class="submit-container">
       <my-button className="orange-button" i18n-label label="Update" icon="circle-tick"
index e51047e8cb217fac8957857f4fda1b4a2e295ebb..6ad08cbadc4f393f0821dcbc4c6755aff0b947ff 100644 (file)
@@ -1,25 +1,31 @@
-import { of } from 'rxjs'
-import { switchMap } from 'rxjs/operators'
+import debug from 'debug'
+import { UploadState, UploadxService } from 'ngx-uploadx'
+import { of, Subject, Subscription } from 'rxjs'
+import { catchError, map, switchMap } from 'rxjs/operators'
 import { SelectChannelItem } from 'src/types/select-options-item.model'
-import { Component, HostListener, OnInit } from '@angular/core'
+import { HttpErrorResponse } from '@angular/common/http'
+import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { Notifier } from '@app/core'
+import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
+import { genericUploadErrorHandler } from '@app/helpers'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { LoadingBarService } from '@ngx-loading-bar/core'
-import { logger } from '@root-helpers/logger'
 import { pick, simpleObjectsDeepEqual } from '@shared/core-utils'
-import { LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models'
+import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models'
 import { VideoSource } from '@shared/models/videos/video-source'
 import { hydrateFormFromVideo } from './shared/video-edit-utils'
+import { VideoUploadService } from './shared/video-upload.service'
+
+const debugLogger = debug('peertube:video-update')
 
 @Component({
   selector: 'my-videos-update',
   styleUrls: [ './shared/video-edit.component.scss' ],
   templateUrl: './video-update.component.html'
 })
-export class VideoUpdateComponent extends FormReactive implements OnInit {
+export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
   videoEdit: VideoEdit
   videoDetails: VideoDetails
   videoSource: VideoSource
@@ -27,10 +33,23 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   videoCaptions: VideoCaptionEdit[] = []
   liveVideo: LiveVideo
 
+  userVideoQuotaUsed = 0
+  userVideoQuotaUsedDaily = 0
+
   isUpdatingVideo = false
   forbidScheduledPublication = false
 
-  private updateDone = false
+  isReplacingVideoFile = false
+  videoUploadPercents: number
+  uploadError: string
+
+  updateDone = false
+
+  private videoReplacementUploadedSubject = new Subject<void>()
+  private alreadyRefreshedToken = false
+
+  private uploadServiceSubscription: Subscription
+  private updateSubcription: Subscription
 
   constructor (
     protected formReactiveService: FormReactiveService,
@@ -40,13 +59,30 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
     private videoService: VideoService,
     private loadingBar: LoadingBarService,
     private videoCaptionService: VideoCaptionService,
-    private liveVideoService: LiveVideoService
+    private server: ServerService,
+    private liveVideoService: LiveVideoService,
+    private videoUploadService: VideoUploadService,
+    private confirmService: ConfirmService,
+    private auth: AuthService,
+    private userService: UserService,
+    private resumableUploadService: UploadxService
   ) {
     super()
   }
 
   ngOnInit () {
-    this.buildForm({})
+    this.buildForm({
+      replaceFile: null
+    })
+
+    this.userService.getMyVideoQuotaUsed()
+      .subscribe(data => {
+        this.userVideoQuotaUsed = data.videoQuotaUsed
+        this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily
+      })
+
+    this.uploadServiceSubscription = this.resumableUploadService.events
+      .subscribe(state => this.onUploadVideoOngoing(state))
 
     const { videoData } = this.route.snapshot.data
     const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData
@@ -62,6 +98,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
     this.forbidScheduledPublication = this.videoEdit.privacy !== VideoPrivacy.PRIVATE
   }
 
+  ngOnDestroy () {
+    this.resumableUploadService.disconnect()
+
+    if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe()
+  }
+
   onFormBuilt () {
     hydrateFormFromVideo(this.form, this.videoEdit, true)
 
@@ -88,6 +130,13 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   canDeactivate (): { canDeactivate: boolean, text?: string } {
     if (this.updateDone === true) return { canDeactivate: true }
 
+    if (this.isUpdatingVideo) {
+      return {
+        canDeactivate: false,
+        text: $localize`Your video is currenctly being updated. If you leave, your changes will be lost.`
+      }
+    }
+
     const text = $localize`You have unsaved changes! If you leave, your changes will be lost.`
 
     for (const caption of this.videoCaptions) {
@@ -97,68 +146,90 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
     return { canDeactivate: this.formChanged === false, text }
   }
 
+  getVideoExtensions () {
+    return this.videoUploadService.getVideoExtensions()
+  }
+
   isWaitTranscodingHidden () {
     return this.videoDetails.state.id !== VideoState.TO_TRANSCODE
   }
 
+  isUpdateVideoFileEnabled () {
+    if (!this.server.getHTMLConfig().videoFile.update.enabled) return false
+
+    if (this.videoDetails.isLive) return false
+    if (this.videoDetails.state.id !== VideoState.PUBLISHED) return false
+
+    return true
+  }
+
   async update () {
     await this.waitPendingCheck()
     this.forceCheck()
 
-    if (!this.form.valid || this.isUpdatingVideo === true) {
-      return
-    }
+    if (!this.form.valid || this.isUpdatingVideo === true) return
+
+    // Check and warn users about a file replacement
+    if (!await this.checkAndConfirmVideoFileReplacement()) return
 
     this.videoEdit.patch(this.form.value)
 
+    this.abortUpdateIfNeeded()
+
     this.loadingBar.useRef().start()
     this.isUpdatingVideo = true
 
-    // Update the video
-    this.videoService.updateVideo(this.videoEdit)
-        .pipe(
-          // Then update captions
-          switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)),
-
-          switchMap(() => {
-            if (!this.liveVideo) return of(undefined)
-
-            const saveReplay = !!this.form.value.saveReplay
-            const replaySettings = saveReplay
-              ? { privacy: this.form.value.replayPrivacy }
-              : undefined
-
-            const liveVideoUpdate: LiveVideoUpdate = {
-              saveReplay,
-              replaySettings,
-              permanentLive: !!this.form.value.permanentLive,
-              latencyMode: this.form.value.latencyMode
-            }
-
-            // Don't update live attributes if they did not change
-            const baseVideo = pick(this.liveVideo, Object.keys(liveVideoUpdate) as (keyof LiveVideoUpdate)[])
-            const liveChanged = !simpleObjectsDeepEqual(baseVideo, liveVideoUpdate)
-            if (!liveChanged) return of(undefined)
-
-            return this.liveVideoService.updateLive(this.videoEdit.id, liveVideoUpdate)
-          })
-        )
-        .subscribe({
-          next: () => {
-            this.updateDone = true
-            this.isUpdatingVideo = false
-            this.loadingBar.useRef().complete()
-            this.notifier.success($localize`Video updated.`)
-            this.router.navigateByUrl(Video.buildWatchUrl(this.videoEdit))
-          },
-
-          error: err => {
-            this.loadingBar.useRef().complete()
-            this.isUpdatingVideo = false
-            this.notifier.error(err.message)
-            logger.error(err)
-          }
-        })
+    this.updateSubcription = this.videoReplacementUploadedSubject.pipe(
+      switchMap(() => this.videoService.updateVideo(this.videoEdit)),
+
+      // Then update captions
+      switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)),
+
+      switchMap(() => {
+        if (!this.liveVideo) return of(undefined)
+
+        const saveReplay = !!this.form.value.saveReplay
+        const replaySettings = saveReplay
+          ? { privacy: this.form.value.replayPrivacy }
+          : undefined
+
+        const liveVideoUpdate: LiveVideoUpdate = {
+          saveReplay,
+          replaySettings,
+          permanentLive: !!this.form.value.permanentLive,
+          latencyMode: this.form.value.latencyMode
+        }
+
+        // Don't update live attributes if they did not change
+        const baseVideo = pick(this.liveVideo, Object.keys(liveVideoUpdate) as (keyof LiveVideoUpdate)[])
+        const liveChanged = !simpleObjectsDeepEqual(baseVideo, liveVideoUpdate)
+        if (!liveChanged) return of(undefined)
+
+        return this.liveVideoService.updateLive(this.videoEdit.id, liveVideoUpdate)
+      }),
+
+      map(() => true),
+
+      catchError(err => {
+        this.notifier.error(err.message)
+
+        return of(false)
+      })
+    )
+    .subscribe({
+      next: success => {
+        this.isUpdatingVideo = false
+        this.loadingBar.useRef().complete()
+
+        if (!success) return
+
+        this.updateDone = true
+        this.notifier.success($localize`Video updated.`)
+        this.router.navigateByUrl(Video.buildWatchUrl(this.videoEdit))
+      }
+    })
+
+    this.replaceFileIfNeeded()
   }
 
   hydratePluginFieldsFromVideo () {
@@ -172,4 +243,118 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   getVideoUrl () {
     return Video.buildWatchUrl(this.videoDetails)
   }
+
+  private async checkAndConfirmVideoFileReplacement () {
+    const replaceFile: File = this.form.value['replaceFile']
+    if (!replaceFile) return true
+
+    const user = this.auth.getUser()
+    if (!this.videoUploadService.checkQuotaAndNotify(replaceFile, user.videoQuota, this.userVideoQuotaUsed)) return
+    if (!this.videoUploadService.checkQuotaAndNotify(replaceFile, user.videoQuotaDaily, this.userVideoQuotaUsedDaily)) return
+
+    const willBeBlocked = this.server.getHTMLConfig().autoBlacklist.videos.ofUsers.enabled === true && !this.videoDetails.blacklisted
+    let blockedWarning = ''
+    if (willBeBlocked) {
+      // eslint-disable-next-line max-len
+      blockedWarning = ' ' + $localize`Your video will also be automatically blocked since video publication requires manual validation by moderators.`
+    }
+
+    const message = $localize`Uploading a new version of your video will completely erase the current version.` +
+      blockedWarning +
+      ' ' +
+      $localize`<br /><br />Do you still want to replace your video file?`
+
+    const res = await this.confirmService.confirm(message, $localize`Replace file warning`)
+    if (res === false) return false
+
+    return true
+  }
+
+  private replaceFileIfNeeded () {
+    if (!this.form.value['replaceFile']) {
+      this.videoReplacementUploadedSubject.next()
+      return
+    }
+
+    this.uploadFileReplacement(this.form.value['replaceFile'])
+  }
+
+  private uploadFileReplacement (file: File) {
+    const metadata = {
+      filename: file.name
+    }
+
+    this.resumableUploadService.handleFiles(file, {
+      ...this.videoUploadService.getReplaceUploadxOptions(this.videoDetails.uuid),
+
+      metadata
+    })
+
+    this.isReplacingVideoFile = true
+  }
+
+  onUploadVideoOngoing (state: UploadState) {
+    debugLogger('Upload state update', state)
+
+    switch (state.status) {
+      case 'error': {
+        if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) {
+          this.alreadyRefreshedToken = true
+
+          return this.refreshTokenAndRetryUpload()
+        }
+
+        this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state))
+        break
+      }
+
+      case 'cancelled':
+        this.isReplacingVideoFile = false
+        this.videoUploadPercents = 0
+        this.uploadError = ''
+        break
+
+      case 'uploading':
+        this.videoUploadPercents = state.progress || 0
+        break
+
+      case 'complete':
+        this.isReplacingVideoFile = false
+        this.videoReplacementUploadedSubject.next()
+        this.videoUploadPercents = 100
+        break
+    }
+  }
+
+  cancelUpload () {
+    debugLogger('Cancelling upload')
+
+    this.resumableUploadService.control({ action: 'cancel' })
+
+    this.abortUpdateIfNeeded()
+  }
+
+  private handleUploadError (err: HttpErrorResponse) {
+    this.videoUploadPercents = 0
+    this.isReplacingVideoFile = false
+
+    this.uploadError = genericUploadErrorHandler({ err, name: $localize`video` })
+
+    this.videoReplacementUploadedSubject.error(err)
+  }
+
+  private refreshTokenAndRetryUpload () {
+    this.auth.refreshAccessToken()
+      .subscribe(() => this.uploadFileReplacement(this.form.value['replaceFile']))
+  }
+
+  private abortUpdateIfNeeded () {
+    if (this.updateSubcription) {
+      this.updateSubcription.unsubscribe()
+      this.updateSubcription = undefined
+    }
+
+    this.videoReplacementUploadedSubject = new Subject<void>()
+    this.loadingBar.useRef().complete()
+  }
 }
index 0aa7076663a5ffa712cc7304c681a3bcfd6653b8..bb095e09e29dea1e69a997404bfe59b274c6cb74 100644 (file)
   </a>
 </div>
 
+<div *ngIf="!!video.inputFileUpdatedAt" class="attribute attribute-re-uploaded-on">
+  <span i18n class="attribute-label">Video re-upload</span>
+  <span class="attribute-value">{{ video.inputFileUpdatedAt | date: 'short' }}</span>
+</div>
+
 <div *ngIf="!!video.originallyPublishedAt" class="attribute attribute-originally-published-at">
   <span i18n class="attribute-label">Originally published</span>
   <span class="attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
index d7e1f7237f5ade94f8f4aa4952978a6a23a68984..b609516128bd47d37ee28835d5434a77632c1ce4 100644 (file)
@@ -5,14 +5,15 @@ import { HttpStatusCode } from '@shared/models'
 function genericUploadErrorHandler (options: {
   err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
   name: string
-  notifier: Notifier
+  notifier?: Notifier
   sticky?: boolean
 }) {
   const { err, name, notifier, sticky = false } = options
   const title = $localize`Upload failed`
   const message = buildMessage(name, err)
 
-  notifier.error(message, title, null, sticky)
+  if (notifier) notifier.error(message, title, null, sticky)
+
   return message
 }
 
index d18a99d4600634d07b3be1ed9961e815e58b309c..8e38697e4449631fb403d62755413312e7c634b4 100644 (file)
@@ -1,5 +1,5 @@
 <div class="root">
-  <div class="button-file" [ngClass]="{ 'with-icon': !!icon }" [ngbTooltip]="buttonTooltip">
+  <div class="button-file" [ngClass]="classes" [ngbTooltip]="buttonTooltip">
     <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
 
     <span>{{ inputLabel }}</span>
index 7643f29afe04165b43aa69a4fda86ca774deeaa1..f9ba5805ad082224e9ec6942ead9cfe99a1ff566 100644 (file)
@@ -8,7 +8,6 @@
 
   .button-file {
     @include peertube-button-file(auto);
-    @include grey-button;
 
     &.with-icon {
       @include button-with-icon;
index 48055a51cdeb240cb3445ac5d87098ee923498fc..609aa0f402aac646c37d6d0f9e1f2775ae8cf9d6 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
+import { Component, EventEmitter, forwardRef, Input, OnChanges, OnInit, Output } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 import { Notifier } from '@app/core'
 import { GlobalIconName } from '@app/shared/shared-icons'
@@ -15,7 +15,8 @@ import { GlobalIconName } from '@app/shared/shared-icons'
     }
   ]
 })
-export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
+export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAccessor {
+  @Input() theme: 'primary' | 'secondary' = 'secondary'
   @Input() inputLabel: string
   @Input() inputName: string
   @Input() extensions: string[] = []
@@ -29,6 +30,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
 
   @Output() fileChanged = new EventEmitter<Blob>()
 
+  classes: { [id: string]: boolean } = {}
   allowedExtensionsMessage = ''
   fileInputValue: any
 
@@ -44,6 +46,20 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
 
   ngOnInit () {
     this.allowedExtensionsMessage = this.extensions.join(', ')
+
+    this.buildClasses()
+  }
+
+  ngOnChanges () {
+    this.buildClasses()
+  }
+
+  buildClasses () {
+    this.classes = {
+      'with-icon': !!this.icon,
+      'orange-button': this.theme === 'primary',
+      'grey-button': this.theme === 'secondary'
+    }
   }
 
   fileChange (event: any) {
index 45c053507a0685519d6163db2ae3e2e5666fb9d6..5c36b56486ec9a86f0cdf1d6036719bc9be8da45 100644 (file)
@@ -27,6 +27,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
 
   trackerUrls: string[]
 
+  inputFileUpdatedAt: Date | string
+
   files: VideoFile[]
   streamingPlaylists: VideoStreamingPlaylist[]
 
@@ -41,6 +43,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
     this.commentsEnabled = hash.commentsEnabled
     this.downloadEnabled = hash.downloadEnabled
 
+    this.inputFileUpdatedAt = hash.inputFileUpdatedAt
+
     this.trackerUrls = hash.trackerUrls
 
     this.buildLikeAndDislikePercents()
index 1ffc404112c71b7ea4d8fc69a9430e1a738c5040..a5bf1db8b3650b4e801d779f178674994967b1e0 100644 (file)
@@ -26,6 +26,7 @@ export class Video implements VideoServerModel {
   updatedAt: Date
   publishedAt: Date
   originallyPublishedAt: Date | string
+
   category: VideoConstant<number>
   licence: VideoConstant<number>
   language: VideoConstant<string>
index 3589e1fd808f5f917fcf6f1ce991a929cff1c91c..16883603e9ce51bc41f7e35b7b87382de2d783d9 100644 (file)
@@ -48,6 +48,8 @@ class EndCard extends Component {
   suspendedMessage: HTMLElement
   nextButton: HTMLElement
 
+  private timeout: any
+
   private onEndedHandler: () => void
   private onPlayingHandler: () => void
 
@@ -84,6 +86,8 @@ class EndCard extends Component {
     if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler)
     if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler)
 
+    if (this.timeout) clearTimeout(this.timeout)
+
     super.dispose()
   }
 
@@ -114,8 +118,6 @@ class EndCard extends Component {
   }
 
   showCard (cb: (canceled: boolean) => void) {
-    let timeout: any
-
     this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`)
     this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`)
 
@@ -126,17 +128,20 @@ class EndCard extends Component {
     }
 
     this.upNextEvents.one('cancel', () => {
-      clearTimeout(timeout)
+      clearTimeout(this.timeout)
+      this.timeout = undefined
       cb(true)
     })
 
     this.upNextEvents.one('playing', () => {
-      clearTimeout(timeout)
+      clearTimeout(this.timeout)
+      this.timeout = undefined
       cb(true)
     })
 
     this.upNextEvents.one('next', () => {
-      clearTimeout(timeout)
+      clearTimeout(this.timeout)
+      this.timeout = undefined
       cb(false)
     })
 
@@ -154,19 +159,20 @@ class EndCard extends Component {
         this.suspendedMessage.innerText = this.options_.suspendedText
         goToPercent(0)
         this.ticks = 0
-        timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer
+        this.timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer
       } else if (this.ticks >= this.totalTicks) {
-        clearTimeout(timeout)
+        clearTimeout(this.timeout)
+        this.timeout = undefined
         cb(false)
       } else {
         this.suspendedMessage.innerText = ''
         tick()
-        timeout = setTimeout(update.bind(this), this.interval)
+        this.timeout = setTimeout(update.bind(this), this.interval)
       }
     }
 
     this.container.style.display = 'block'
-    timeout = setTimeout(update.bind(this), this.interval)
+    this.timeout = setTimeout(update.bind(this), this.interval)
   }
 }
 
index b20c4af0ef4259e7eda86b4672f582c39ecd3254..75fe68b6c34e73371d2cf531bb1a57b80a208160 100644 (file)
@@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc'
 import { VideoModel } from '@server/models/video/video'
 import { VideoSourceModel } from '@server/models/video/video-source'
 import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
-import { HttpStatusCode, VideoState } from '@shared/models'
+import { VideoState } from '@shared/models'
 import { logger, loggerTagsFactory } from '../../../helpers/logger'
 import {
   asyncMiddleware,
@@ -121,7 +121,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
 
     await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })
 
-    await VideoSourceModel.create({
+    const source = await VideoSourceModel.create({
       filename: originalFilename,
       videoId: video.id,
       createdAt: inputFileUpdatedAt
@@ -135,7 +135,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
 
     Hooks.runAction('action:api.video.file-updated', { video, req, res })
 
-    return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+    return res.json(source.toFormattedJSON())
   } finally {
     videoFileMutexReleaser()
   }
index 6c38fa7ef5d91f9b86110935757fc97573a06528..3fdbc348a57f86aac2bfeec26ad570785879ae68 100644 (file)
@@ -462,7 +462,7 @@ export class VideosCommand extends AbstractCommand {
     path: string
     attributes: { fixture?: string } & { [id: string]: any }
   }): Promise<VideoCreateResult> {
-    const { path, attributes, expectedStatus } = options
+    const { path, attributes, expectedStatus = HttpStatusCode.OK_200 } = options
 
     let size = 0
     let videoFilePath: string
@@ -597,43 +597,47 @@ export class VideosCommand extends AbstractCommand {
     const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
     return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
       readable.on('data', async function onData (chunk) {
-        readable.pause()
-
-        const headers = {
-          'Authorization': 'Bearer ' + token,
-          'Content-Type': 'application/octet-stream',
-          'Content-Range': contentRangeBuilder
-            ? contentRangeBuilder(start, chunk)
-            : `bytes ${start}-${start + chunk.length - 1}/${size}`,
-          'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
+        try {
+          readable.pause()
+
+          const headers = {
+            'Authorization': 'Bearer ' + token,
+            'Content-Type': 'application/octet-stream',
+            'Content-Range': contentRangeBuilder
+              ? contentRangeBuilder(start, chunk)
+              : `bytes ${start}-${start + chunk.length - 1}/${size}`,
+            'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
+          }
+
+          if (digestBuilder) {
+            Object.assign(headers, { digest: digestBuilder(chunk) })
+          }
+
+          const res = await got<{ video: VideoCreateResult }>({
+            url,
+            method: 'put',
+            headers,
+            path: path + '?' + pathUploadId,
+            body: chunk,
+            responseType: 'json',
+            throwHttpErrors: false
+          })
+
+          start += chunk.length
+
+          if (res.statusCode === expectedStatus) {
+            return resolve(res)
+          }
+
+          if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
+            readable.off('data', onData)
+            return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
+          }
+
+          readable.resume()
+        } catch (err) {
+          reject(err)
         }
-
-        if (digestBuilder) {
-          Object.assign(headers, { digest: digestBuilder(chunk) })
-        }
-
-        const res = await got<{ video: VideoCreateResult }>({
-          url,
-          method: 'put',
-          headers,
-          path: path + '?' + pathUploadId,
-          body: chunk,
-          responseType: 'json',
-          throwHttpErrors: false
-        })
-
-        start += chunk.length
-
-        if (res.statusCode === expectedStatus) {
-          return resolve(res)
-        }
-
-        if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
-          readable.off('data', onData)
-          return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
-        }
-
-        readable.resume()
       })
     })
   }
@@ -695,8 +699,7 @@ export class VideosCommand extends AbstractCommand {
       ...options,
 
       path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable',
-      attributes: { fixture: options.fixture },
-      expectedStatus: HttpStatusCode.NO_CONTENT_204
+      attributes: { fixture: options.fixture }
     })
   }