diff options
26 files changed, 610 insertions, 285 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html index b81393731..c0e4533aa 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html | |||
@@ -345,6 +345,18 @@ | |||
345 | </ng-container> | 345 | </ng-container> |
346 | </ng-container> | 346 | </ng-container> |
347 | 347 | ||
348 | <ng-container formGroupName="videoFile"> | ||
349 | <ng-container formGroupName="update"> | ||
350 | <div class="form-group"> | ||
351 | <my-peertube-checkbox | ||
352 | inputName="videoFileUpdateEnabled" formControlName="enabled" | ||
353 | i18n-labelText labelText="Allow users to upload a new version of their video" | ||
354 | > | ||
355 | </my-peertube-checkbox> | ||
356 | </div> | ||
357 | </ng-container> | ||
358 | </ng-container> | ||
359 | |||
348 | </div> | 360 | </div> |
349 | </div> | 361 | </div> |
350 | 362 | ||
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index b381473d6..c3b85b196 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts | |||
@@ -225,6 +225,11 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
225 | enabled: null | 225 | enabled: null |
226 | } | 226 | } |
227 | }, | 227 | }, |
228 | videoFile: { | ||
229 | update: { | ||
230 | enabled: null | ||
231 | } | ||
232 | }, | ||
228 | autoBlacklist: { | 233 | autoBlacklist: { |
229 | videos: { | 234 | videos: { |
230 | ofUsers: { | 235 | 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 index 000000000..f1626b8f0 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/upload-progress.component.html | |||
@@ -0,0 +1,32 @@ | |||
1 | <!-- Upload progress/cancel/error/success header --> | ||
2 | <div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel"> | ||
3 | <div class="progress" i18n-title title="Total video uploaded"> | ||
4 | <div | ||
5 | class="progress-bar" role="progressbar" | ||
6 | [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100" | ||
7 | > | ||
8 | <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span> | ||
9 | <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span> | ||
10 | </div> | ||
11 | </div> | ||
12 | <input | ||
13 | *ngIf="videoUploaded === false" | ||
14 | type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancel.emit()" | ||
15 | /> | ||
16 | </div> | ||
17 | |||
18 | <div *ngIf="error && enableRetryAfterError" class="upload-progress-retry"> | ||
19 | <div class="progress"> | ||
20 | <div class="progress-bar red" role="progressbar" [style]="{ width: '100%' }" [attr.aria-valuenow]="100" aria-valuemin="0" [attr.aria-valuemax]="100"> | ||
21 | <span>{{ error }}</span> | ||
22 | </div> | ||
23 | </div> | ||
24 | |||
25 | <input type="button" class="peertube-button grey-button ms-1" i18n-value="Retry failed upload of a video" value="Retry" (click)="retry.emit()" /> | ||
26 | <input type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancel.emit()" /> | ||
27 | </div> | ||
28 | |||
29 | <div *ngIf="error && !enableRetryAfterError" class="alert alert-danger"> | ||
30 | <div i18n>Sorry, but something went wrong</div> | ||
31 | {{ error }} | ||
32 | </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 index 000000000..609a31ed3 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/upload-progress.component.scss | |||
@@ -0,0 +1,30 @@ | |||
1 | @use '_variables' as *; | ||
2 | @use '_mixins' as *; | ||
3 | |||
4 | .upload-progress-retry, | ||
5 | .upload-progress-cancel { | ||
6 | display: flex; | ||
7 | margin-bottom: 40px; | ||
8 | |||
9 | .progress { | ||
10 | @include progressbar; | ||
11 | |||
12 | flex-grow: 1; | ||
13 | height: 30px; | ||
14 | font-size: 14px; | ||
15 | background-color: rgba(11, 204, 41, 0.16); | ||
16 | |||
17 | .progress-bar { | ||
18 | background-color: $green; | ||
19 | line-height: 30px; | ||
20 | text-align: start; | ||
21 | font-weight: $font-semibold; | ||
22 | |||
23 | span { | ||
24 | @include margin-left(13px); | ||
25 | |||
26 | color: pvar(--mainBackgroundColor); | ||
27 | } | ||
28 | } | ||
29 | } | ||
30 | } | ||
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 index 000000000..9ce3a2cb2 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/upload-progress.component.ts | |||
@@ -0,0 +1,17 @@ | |||
1 | import { Component, EventEmitter, Input, Output } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-upload-progress', | ||
5 | templateUrl: './upload-progress.component.html', | ||
6 | styleUrls: [ './upload-progress.component.scss' ] | ||
7 | }) | ||
8 | export class UploadProgressComponent { | ||
9 | @Input() isUploadingVideo: boolean | ||
10 | @Input() videoUploadPercents: number | ||
11 | @Input() error: string | ||
12 | @Input() videoUploaded: boolean | ||
13 | @Input() enableRetryAfterError: boolean | ||
14 | |||
15 | @Output() cancel = new EventEmitter() | ||
16 | @Output() retry = new EventEmitter() | ||
17 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts b/client/src/app/+videos/+video-edit/shared/uploaderx-form-data.ts index 3392a0d8a..3392a0d8a 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts +++ b/client/src/app/+videos/+video-edit/shared/uploaderx-form-data.ts | |||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index 97b713874..579b63c6d 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html | |||
@@ -124,7 +124,7 @@ | |||
124 | <label i18n for="videoPassword">Password</label> | 124 | <label i18n for="videoPassword">Password</label> |
125 | <my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text> | 125 | <my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text> |
126 | </div> | 126 | </div> |
127 | 127 | ||
128 | <div *ngIf="schedulePublicationSelected" class="form-group"> | 128 | <div *ngIf="schedulePublicationSelected" class="form-group"> |
129 | <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> | 129 | <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> |
130 | <p-calendar | 130 | <p-calendar |
@@ -320,6 +320,8 @@ | |||
320 | <div class="row advanced-settings"> | 320 | <div class="row advanced-settings"> |
321 | <div class="col-md-12 col-xl-8"> | 321 | <div class="col-md-12 col-xl-8"> |
322 | 322 | ||
323 | <ng-content></ng-content> | ||
324 | |||
323 | <div class="form-group"> | 325 | <div class="form-group"> |
324 | <label i18n for="previewfile">Video thumbnail</label> | 326 | <label i18n for="previewfile">Video thumbnail</label> |
325 | 327 | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss index 1c6f7f5ab..b0c053019 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss | |||
@@ -112,6 +112,11 @@ p-calendar { | |||
112 | grid-gap: 30px; | 112 | grid-gap: 30px; |
113 | } | 113 | } |
114 | 114 | ||
115 | .button-file { | ||
116 | @include peertube-button-file(max-content); | ||
117 | @include orange-button; | ||
118 | } | ||
119 | |||
115 | @include on-small-main-col { | 120 | @include on-small-main-col { |
116 | .form-columns { | 121 | .form-columns { |
117 | grid-template-columns: 1fr; | 122 | grid-template-columns: 1fr; |
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index 5e5df8db7..460960a01 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts | |||
@@ -68,6 +68,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
68 | @Input() videoSource: VideoSource | 68 | @Input() videoSource: VideoSource |
69 | 69 | ||
70 | @Input() hideWaitTranscoding = false | 70 | @Input() hideWaitTranscoding = false |
71 | @Input() updateVideoFileEnabled = false | ||
71 | 72 | ||
72 | @Input() type: VideoEditType | 73 | @Input() type: VideoEditType |
73 | @Input() liveVideo: LiveVideo | 74 | @Input() liveVideo: LiveVideo |
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.module.ts b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts index d463bf633..cf9742b84 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.module.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts | |||
@@ -5,9 +5,11 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons' | |||
5 | import { SharedMainModule } from '@app/shared/shared-main' | 5 | import { SharedMainModule } from '@app/shared/shared-main' |
6 | import { SharedVideoLiveModule } from '@app/shared/shared-video-live' | 6 | import { SharedVideoLiveModule } from '@app/shared/shared-video-live' |
7 | import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' | 7 | import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' |
8 | import { UploadProgressComponent } from './upload-progress.component' | ||
8 | import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' | 9 | import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' |
9 | import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component' | 10 | import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component' |
10 | import { VideoEditComponent } from './video-edit.component' | 11 | import { VideoEditComponent } from './video-edit.component' |
12 | import { VideoUploadService } from './video-upload.service' | ||
11 | 13 | ||
12 | @NgModule({ | 14 | @NgModule({ |
13 | imports: [ | 15 | imports: [ |
@@ -22,7 +24,8 @@ import { VideoEditComponent } from './video-edit.component' | |||
22 | declarations: [ | 24 | declarations: [ |
23 | VideoEditComponent, | 25 | VideoEditComponent, |
24 | VideoCaptionAddModalComponent, | 26 | VideoCaptionAddModalComponent, |
25 | VideoCaptionEditModalContentComponent | 27 | VideoCaptionEditModalContentComponent, |
28 | UploadProgressComponent | ||
26 | ], | 29 | ], |
27 | 30 | ||
28 | exports: [ | 31 | exports: [ |
@@ -32,11 +35,13 @@ import { VideoEditComponent } from './video-edit.component' | |||
32 | SharedFormModule, | 35 | SharedFormModule, |
33 | SharedGlobalIconModule, | 36 | SharedGlobalIconModule, |
34 | 37 | ||
35 | VideoEditComponent | 38 | VideoEditComponent, |
39 | UploadProgressComponent | ||
36 | ], | 40 | ], |
37 | 41 | ||
38 | providers: [ | 42 | providers: [ |
39 | I18nPrimengCalendarService | 43 | I18nPrimengCalendarService, |
44 | VideoUploadService | ||
40 | ] | 45 | ] |
41 | }) | 46 | }) |
42 | export class VideoEditModule { } | 47 | 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 index 000000000..cb9503503 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-upload.service.ts | |||
@@ -0,0 +1,110 @@ | |||
1 | import { UploaderX, UploadState, UploadxOptions } from 'ngx-uploadx' | ||
2 | import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
5 | import { BytesPipe, VideoService } from '@app/shared/shared-main' | ||
6 | import { isIOS } from '@root-helpers/web-browser' | ||
7 | import { HttpStatusCode } from '@shared/models' | ||
8 | import { UploaderXFormData } from './uploaderx-form-data' | ||
9 | |||
10 | @Injectable() | ||
11 | export class VideoUploadService { | ||
12 | |||
13 | constructor ( | ||
14 | private server: ServerService, | ||
15 | private notifier: Notifier, | ||
16 | private authService: AuthService | ||
17 | ) { | ||
18 | |||
19 | } | ||
20 | |||
21 | getVideoExtensions () { | ||
22 | return this.server.getHTMLConfig().video.file.extensions | ||
23 | } | ||
24 | |||
25 | checkQuotaAndNotify (videoFile: File, maxQuota: number, quotaUsed: number) { | ||
26 | const bytePipes = new BytesPipe() | ||
27 | |||
28 | // Check global user quota | ||
29 | if (maxQuota !== -1 && (quotaUsed + videoFile.size) > maxQuota) { | ||
30 | const videoSizeBytes = bytePipes.transform(videoFile.size, 0) | ||
31 | const videoQuotaUsedBytes = bytePipes.transform(quotaUsed, 0) | ||
32 | const videoQuotaBytes = bytePipes.transform(maxQuota, 0) | ||
33 | |||
34 | // eslint-disable-next-line max-len | ||
35 | const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})` | ||
36 | this.notifier.error(msg) | ||
37 | |||
38 | return false | ||
39 | } | ||
40 | |||
41 | return true | ||
42 | } | ||
43 | |||
44 | isAudioFile (filename: string) { | ||
45 | const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ] | ||
46 | |||
47 | return extensions.some(e => filename.endsWith(e)) | ||
48 | } | ||
49 | |||
50 | // --------------------------------------------------------------------------- | ||
51 | |||
52 | getNewUploadxOptions (): UploadxOptions { | ||
53 | return this.getUploadxOptions( | ||
54 | VideoService.BASE_VIDEO_URL + '/upload-resumable', | ||
55 | UploaderXFormData | ||
56 | ) | ||
57 | } | ||
58 | |||
59 | getReplaceUploadxOptions (videoId: string): UploadxOptions { | ||
60 | return this.getUploadxOptions( | ||
61 | VideoService.BASE_VIDEO_URL + '/' + videoId + '/source/replace-resumable', | ||
62 | UploaderX | ||
63 | ) | ||
64 | } | ||
65 | |||
66 | private getUploadxOptions (endpoint: string, uploaderClass: typeof UploaderXFormData) { | ||
67 | // FIXME: https://github.com/Chocobozzz/PeerTube/issues/4382#issuecomment-915854167 | ||
68 | const chunkSize = isIOS() | ||
69 | ? 0 | ||
70 | : undefined // Auto chunk size | ||
71 | |||
72 | return { | ||
73 | endpoint, | ||
74 | multiple: false, | ||
75 | |||
76 | maxChunkSize: this.server.getHTMLConfig().client.videos.resumableUpload.maxChunkSize, | ||
77 | chunkSize, | ||
78 | |||
79 | token: this.authService.getAccessToken(), | ||
80 | |||
81 | uploaderClass, | ||
82 | |||
83 | retryConfig: { | ||
84 | maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below | ||
85 | maxDelay: 120_000, // 2 min | ||
86 | shouldRetry: (code: number, attempts: number) => { | ||
87 | return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6) | ||
88 | } | ||
89 | } | ||
90 | } | ||
91 | } | ||
92 | |||
93 | // --------------------------------------------------------------------------- | ||
94 | |||
95 | buildHTTPErrorResponse (state: UploadState): HttpErrorResponse { | ||
96 | const error = state.response?.error?.message || state.response?.error || 'Unknown error' | ||
97 | |||
98 | return { | ||
99 | error: new Error(error), | ||
100 | name: 'HttpErrorResponse', | ||
101 | message: error, | ||
102 | ok: false, | ||
103 | headers: new HttpHeaders(state.responseHeaders), | ||
104 | status: +state.responseStatus, | ||
105 | statusText: error, | ||
106 | type: HttpEventType.Response, | ||
107 | url: state.url | ||
108 | } | ||
109 | } | ||
110 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html index 7b6bd993c..dcbb358fa 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html | |||
@@ -2,13 +2,13 @@ | |||
2 | <div class="first-step-block"> | 2 | <div class="first-step-block"> |
3 | <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon> | 3 | <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon> |
4 | 4 | ||
5 | <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'"> | 5 | <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + getVideoExtensions() + ')'"> |
6 | <span i18n>Select the file to upload</span> | 6 | <span i18n>Select the file to upload</span> |
7 | <input | 7 | <input |
8 | aria-label="Select the file to upload" | 8 | aria-label="Select the file to upload" |
9 | i18n-aria-label | 9 | i18n-aria-label |
10 | #videofileInput | 10 | #videofileInput |
11 | [accept]="videoExtensions" | 11 | [accept]="getVideoExtensions()" |
12 | (change)="onFileChange($event)" | 12 | (change)="onFileChange($event)" |
13 | id="videofile" | 13 | id="videofile" |
14 | type="file" | 14 | type="file" |
@@ -58,35 +58,11 @@ | |||
58 | </div> | 58 | </div> |
59 | </div> | 59 | </div> |
60 | 60 | ||
61 | <!-- Upload progress/cancel/error/success header --> | 61 | <my-upload-progress |
62 | <div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel"> | 62 | [isUploadingVideo]="isUploadingVideo" [videoUploadPercents]="videoUploadPercents" [error]="error" [videoUploaded]="videoUploaded" |
63 | <div class="progress" i18n-title title="Total video uploaded"> | 63 | [enableRetryAfterError]="enableRetryAfterError" (cancel)="cancelUpload()" (retry)="retryUpload()" |
64 | <div class="progress-bar" role="progressbar" [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100"> | 64 | > |
65 | <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span> | 65 | </my-upload-progress> |
66 | <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span> | ||
67 | </div> | ||
68 | </div> | ||
69 | <input | ||
70 | *ngIf="videoUploaded === false" | ||
71 | type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" | ||
72 | /> | ||
73 | </div> | ||
74 | |||
75 | <div *ngIf="error && enableRetryAfterError" class="upload-progress-retry"> | ||
76 | <div class="progress"> | ||
77 | <div class="progress-bar red" role="progressbar" [style]="{ width: '100%' }" [attr.aria-valuenow]="100" aria-valuemin="0" [attr.aria-valuemax]="100"> | ||
78 | <span>{{ error }}</span> | ||
79 | </div> | ||
80 | </div> | ||
81 | |||
82 | <input type="button" class="peertube-button grey-button ms-1" i18n-value="Retry failed upload of a video" value="Retry" (click)="retryUpload()" /> | ||
83 | <input type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" /> | ||
84 | </div> | ||
85 | |||
86 | <div *ngIf="error && !enableRetryAfterError" class="alert alert-danger"> | ||
87 | <div i18n>Sorry, but something went wrong</div> | ||
88 | {{ error }} | ||
89 | </div> | ||
90 | 66 | ||
91 | <div *ngIf="videoUploaded && !error" class="alert pt-alert-primary" i18n> | 67 | <div *ngIf="videoUploaded && !error" class="alert pt-alert-primary" i18n> |
92 | Congratulations! Your video is now available in your private library. | 68 | Congratulations! Your video is now available in your private library. |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss index 52a77f83f..ed817bff7 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss | |||
@@ -15,31 +15,3 @@ | |||
15 | margin: 30px 0; | 15 | margin: 30px 0; |
16 | } | 16 | } |
17 | } | 17 | } |
18 | |||
19 | .upload-progress-retry, | ||
20 | .upload-progress-cancel { | ||
21 | display: flex; | ||
22 | margin-bottom: 40px; | ||
23 | |||
24 | .progress { | ||
25 | @include progressbar; | ||
26 | |||
27 | flex-grow: 1; | ||
28 | height: 30px; | ||
29 | font-size: 14px; | ||
30 | background-color: rgba(11, 204, 41, 0.16); | ||
31 | |||
32 | .progress-bar { | ||
33 | background-color: $green; | ||
34 | line-height: 30px; | ||
35 | text-align: start; | ||
36 | font-weight: $font-semibold; | ||
37 | |||
38 | span { | ||
39 | @include margin-left(13px); | ||
40 | |||
41 | color: pvar(--mainBackgroundColor); | ||
42 | } | ||
43 | } | ||
44 | } | ||
45 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts index 967fa9ed1..cfa42910b 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts | |||
@@ -1,19 +1,18 @@ | |||
1 | import { truncate } from 'lodash-es' | 1 | import { truncate } from 'lodash-es' |
2 | import { UploadState, UploadxOptions, UploadxService } from 'ngx-uploadx' | 2 | import { UploadState, UploadxService } from 'ngx-uploadx' |
3 | import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http' | 3 | import { Subscription } from 'rxjs' |
4 | import { HttpErrorResponse } from '@angular/common/http' | ||
4 | import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' | 5 | import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' |
5 | import { ActivatedRoute, Router } from '@angular/router' | 6 | import { ActivatedRoute, Router } from '@angular/router' |
6 | import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' | 7 | import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' |
7 | import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' | 8 | import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' |
8 | import { FormReactiveService } from '@app/shared/shared-forms' | 9 | import { FormReactiveService } from '@app/shared/shared-forms' |
9 | import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 10 | import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' |
10 | import { LoadingBarService } from '@ngx-loading-bar/core' | 11 | import { LoadingBarService } from '@ngx-loading-bar/core' |
11 | import { logger } from '@root-helpers/logger' | 12 | import { logger } from '@root-helpers/logger' |
12 | import { isIOS } from '@root-helpers/web-browser' | ||
13 | import { HttpStatusCode, VideoCreateResult } from '@shared/models' | 13 | import { HttpStatusCode, VideoCreateResult } from '@shared/models' |
14 | import { UploaderXFormData } from './uploaderx-form-data' | 14 | import { VideoUploadService } from '../shared/video-upload.service' |
15 | import { VideoSend } from './video-send' | 15 | import { VideoSend } from './video-send' |
16 | import { Subscription } from 'rxjs' | ||
17 | 16 | ||
18 | @Component({ | 17 | @Component({ |
19 | selector: 'my-video-upload', | 18 | selector: 'my-video-upload', |
@@ -49,9 +48,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
49 | error: string | 48 | error: string |
50 | enableRetryAfterError: boolean | 49 | enableRetryAfterError: boolean |
51 | 50 | ||
52 | // So that it can be accessed in the template | ||
53 | protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + '/upload-resumable' | ||
54 | |||
55 | private isUpdatingVideo = false | 51 | private isUpdatingVideo = false |
56 | private fileToUpload: File | 52 | private fileToUpload: File |
57 | 53 | ||
@@ -72,15 +68,12 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
72 | private hooks: HooksService, | 68 | private hooks: HooksService, |
73 | private resumableUploadService: UploadxService, | 69 | private resumableUploadService: UploadxService, |
74 | private metaService: MetaService, | 70 | private metaService: MetaService, |
75 | private route: ActivatedRoute | 71 | private route: ActivatedRoute, |
72 | private videoUploadService: VideoUploadService | ||
76 | ) { | 73 | ) { |
77 | super() | 74 | super() |
78 | } | 75 | } |
79 | 76 | ||
80 | get videoExtensions () { | ||
81 | return this.serverConfig.video.file.extensions.join(', ') | ||
82 | } | ||
83 | |||
84 | ngOnInit () { | 77 | ngOnInit () { |
85 | super.ngOnInit() | 78 | super.ngOnInit() |
86 | 79 | ||
@@ -133,28 +126,20 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
133 | } | 126 | } |
134 | } | 127 | } |
135 | 128 | ||
129 | getVideoExtensions () { | ||
130 | return this.videoUploadService.getVideoExtensions().join(', ') | ||
131 | } | ||
132 | |||
136 | onUploadVideoOngoing (state: UploadState) { | 133 | onUploadVideoOngoing (state: UploadState) { |
137 | switch (state.status) { | 134 | switch (state.status) { |
138 | case 'error': { | 135 | case 'error': { |
139 | if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) { | 136 | if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) { |
140 | this.alreadyRefreshedToken = true | 137 | this.alreadyRefreshedToken = true |
141 | 138 | ||
142 | return this.refereshTokenAndRetryUpload() | 139 | return this.refreshTokenAndRetryUpload() |
143 | } | 140 | } |
144 | 141 | ||
145 | const error = state.response?.error?.message || state.response?.error || 'Unknown error' | 142 | this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state)) |
146 | |||
147 | this.handleUploadError({ | ||
148 | error: new Error(error), | ||
149 | name: 'HttpErrorResponse', | ||
150 | message: error, | ||
151 | ok: false, | ||
152 | headers: new HttpHeaders(state.responseHeaders), | ||
153 | status: +state.responseStatus, | ||
154 | statusText: error, | ||
155 | type: HttpEventType.Response, | ||
156 | url: state.url | ||
157 | }) | ||
158 | break | 143 | break |
159 | } | 144 | } |
160 | 145 | ||
@@ -203,10 +188,12 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
203 | 188 | ||
204 | if (!file) return | 189 | if (!file) return |
205 | 190 | ||
206 | if (!this.checkGlobalUserQuota(file)) return | 191 | const user = this.authService.getUser() |
207 | if (!this.checkDailyUserQuota(file)) return | 192 | |
193 | if (!this.videoUploadService.checkQuotaAndNotify(file, user.videoQuota, this.userVideoQuotaUsed)) return | ||
194 | if (!this.videoUploadService.checkQuotaAndNotify(file, user.videoQuotaDaily, this.userVideoQuotaUsedDaily)) return | ||
208 | 195 | ||
209 | if (this.isAudioFile(file.name)) { | 196 | if (this.videoUploadService.isAudioFile(file.name)) { |
210 | this.isUploadingAudioFile = true | 197 | this.isUploadingAudioFile = true |
211 | return | 198 | return |
212 | } | 199 | } |
@@ -291,7 +278,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
291 | } | 278 | } |
292 | 279 | ||
293 | this.resumableUploadService.handleFiles(file, { | 280 | this.resumableUploadService.handleFiles(file, { |
294 | ...this.getUploadxOptions(), | 281 | ...this.videoUploadService.getNewUploadxOptions(), |
295 | 282 | ||
296 | metadata | 283 | metadata |
297 | }) | 284 | }) |
@@ -331,51 +318,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
331 | this.updateTitle() | 318 | this.updateTitle() |
332 | } | 319 | } |
333 | 320 | ||
334 | private checkGlobalUserQuota (videofile: File) { | ||
335 | const bytePipes = new BytesPipe() | ||
336 | |||
337 | // Check global user quota | ||
338 | const videoQuota = this.authService.getUser().videoQuota | ||
339 | if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) { | ||
340 | const videoSizeBytes = bytePipes.transform(videofile.size, 0) | ||
341 | const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0) | ||
342 | const videoQuotaBytes = bytePipes.transform(videoQuota, 0) | ||
343 | |||
344 | // eslint-disable-next-line max-len | ||
345 | const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})` | ||
346 | this.notifier.error(msg) | ||
347 | |||
348 | return false | ||
349 | } | ||
350 | |||
351 | return true | ||
352 | } | ||
353 | |||
354 | private checkDailyUserQuota (videofile: File) { | ||
355 | const bytePipes = new BytesPipe() | ||
356 | |||
357 | // Check daily user quota | ||
358 | const videoQuotaDaily = this.authService.getUser().videoQuotaDaily | ||
359 | if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) { | ||
360 | const videoSizeBytes = bytePipes.transform(videofile.size, 0) | ||
361 | const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0) | ||
362 | const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0) | ||
363 | // eslint-disable-next-line max-len | ||
364 | const msg = $localize`Your daily video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})` | ||
365 | this.notifier.error(msg) | ||
366 | |||
367 | return false | ||
368 | } | ||
369 | |||
370 | return true | ||
371 | } | ||
372 | |||
373 | private isAudioFile (filename: string) { | ||
374 | const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ] | ||
375 | |||
376 | return extensions.some(e => filename.endsWith(e)) | ||
377 | } | ||
378 | |||
379 | private buildVideoFilename (filename: string) { | 321 | private buildVideoFilename (filename: string) { |
380 | const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '') | 322 | const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '') |
381 | let name = nameWithoutExtension.length < 3 | 323 | let name = nameWithoutExtension.length < 3 |
@@ -390,35 +332,8 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
390 | return name | 332 | return name |
391 | } | 333 | } |
392 | 334 | ||
393 | private refereshTokenAndRetryUpload () { | 335 | private refreshTokenAndRetryUpload () { |
394 | this.authService.refreshAccessToken() | 336 | this.authService.refreshAccessToken() |
395 | .subscribe(() => this.retryUpload()) | 337 | .subscribe(() => this.retryUpload()) |
396 | } | 338 | } |
397 | |||
398 | private getUploadxOptions (): UploadxOptions { | ||
399 | // FIXME: https://github.com/Chocobozzz/PeerTube/issues/4382#issuecomment-915854167 | ||
400 | const chunkSize = isIOS() | ||
401 | ? 0 | ||
402 | : undefined // Auto chunk size | ||
403 | |||
404 | return { | ||
405 | endpoint: this.BASE_VIDEO_UPLOAD_URL, | ||
406 | multiple: false, | ||
407 | |||
408 | maxChunkSize: this.serverConfig.client.videos.resumableUpload.maxChunkSize, | ||
409 | chunkSize, | ||
410 | |||
411 | token: this.authService.getAccessToken(), | ||
412 | |||
413 | uploaderClass: UploaderXFormData, | ||
414 | |||
415 | retryConfig: { | ||
416 | maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below | ||
417 | maxDelay: 120_000, // 2 min | ||
418 | shouldRetry: (code: number, attempts: number) => { | ||
419 | return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6) | ||
420 | } | ||
421 | } | ||
422 | } | ||
423 | } | ||
424 | } | 339 | } |
diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html index af564aeb0..9a99c0c3d 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.html +++ b/client/src/app/+videos/+video-edit/video-update.component.html | |||
@@ -4,6 +4,12 @@ | |||
4 | <a [routerLink]="getVideoUrl()">{{ videoDetails?.name }}</a> | 4 | <a [routerLink]="getVideoUrl()">{{ videoDetails?.name }}</a> |
5 | </div> | 5 | </div> |
6 | 6 | ||
7 | <my-upload-progress | ||
8 | [isUploadingVideo]="isReplacingVideoFile" [videoUploadPercents]="videoUploadPercents" [error]="uploadError" [videoUploaded]="updateDone" | ||
9 | [enableRetryAfterError]="false" (cancel)="cancelUpload()" | ||
10 | > | ||
11 | </my-upload-progress> | ||
12 | |||
7 | <form novalidate [formGroup]="form"> | 13 | <form novalidate [formGroup]="form"> |
8 | 14 | ||
9 | <my-video-edit | 15 | <my-video-edit |
@@ -12,10 +18,27 @@ | |||
12 | [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()" | 18 | [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()" |
13 | type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()" | 19 | type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()" |
14 | [liveVideo]="liveVideo" [videoToUpdate]="videoDetails" | 20 | [liveVideo]="liveVideo" [videoToUpdate]="videoDetails" |
15 | [videoSource]="videoSource" | 21 | [videoSource]="videoSource" [updateVideoFileEnabled]="isUpdateVideoFileEnabled()" |
16 | 22 | ||
17 | (formBuilt)="onFormBuilt()" | 23 | (formBuilt)="onFormBuilt()" |
18 | ></my-video-edit> | 24 | > |
25 | |||
26 | <div *ngIf="isUpdateVideoFileEnabled()" class="form-group"> | ||
27 | <label class="mb-0" i18n for="videofile">Replace video file</label> | ||
28 | |||
29 | <div i18n class="form-group-description">⚠️ Uploading a new version of your video will completely erase the current version</div> | ||
30 | |||
31 | <div> | ||
32 | <my-reactive-file | ||
33 | formControlName="replaceFile" | ||
34 | i18n-inputLabel inputLabel="Select the file to upload" | ||
35 | inputName="videofile" [extensions]="getVideoExtensions()" [displayFilename]="true" [displayReset]="true" | ||
36 | [buttonTooltip]="'(extensions: ' + getVideoExtensions() + ')'" | ||
37 | theme="primary" | ||
38 | ></my-reactive-file> | ||
39 | </div> | ||
40 | </div> | ||
41 | </my-video-edit> | ||
19 | 42 | ||
20 | <div class="submit-container"> | 43 | <div class="submit-container"> |
21 | <my-button className="orange-button" i18n-label label="Update" icon="circle-tick" | 44 | <my-button className="orange-button" i18n-label label="Update" icon="circle-tick" |
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts index e51047e8c..6ad08cbad 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.ts +++ b/client/src/app/+videos/+video-edit/video-update.component.ts | |||
@@ -1,25 +1,31 @@ | |||
1 | import { of } from 'rxjs' | 1 | import debug from 'debug' |
2 | import { switchMap } from 'rxjs/operators' | 2 | import { UploadState, UploadxService } from 'ngx-uploadx' |
3 | import { of, Subject, Subscription } from 'rxjs' | ||
4 | import { catchError, map, switchMap } from 'rxjs/operators' | ||
3 | import { SelectChannelItem } from 'src/types/select-options-item.model' | 5 | import { SelectChannelItem } from 'src/types/select-options-item.model' |
4 | import { Component, HostListener, OnInit } from '@angular/core' | 6 | import { HttpErrorResponse } from '@angular/common/http' |
7 | import { Component, HostListener, OnDestroy, OnInit } from '@angular/core' | ||
5 | import { ActivatedRoute, Router } from '@angular/router' | 8 | import { ActivatedRoute, Router } from '@angular/router' |
6 | import { Notifier } from '@app/core' | 9 | import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core' |
10 | import { genericUploadErrorHandler } from '@app/helpers' | ||
7 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' | 11 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' |
8 | import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' | 12 | import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' |
9 | import { LiveVideoService } from '@app/shared/shared-video-live' | 13 | import { LiveVideoService } from '@app/shared/shared-video-live' |
10 | import { LoadingBarService } from '@ngx-loading-bar/core' | 14 | import { LoadingBarService } from '@ngx-loading-bar/core' |
11 | import { logger } from '@root-helpers/logger' | ||
12 | import { pick, simpleObjectsDeepEqual } from '@shared/core-utils' | 15 | import { pick, simpleObjectsDeepEqual } from '@shared/core-utils' |
13 | import { LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models' | 16 | import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models' |
14 | import { VideoSource } from '@shared/models/videos/video-source' | 17 | import { VideoSource } from '@shared/models/videos/video-source' |
15 | import { hydrateFormFromVideo } from './shared/video-edit-utils' | 18 | import { hydrateFormFromVideo } from './shared/video-edit-utils' |
19 | import { VideoUploadService } from './shared/video-upload.service' | ||
20 | |||
21 | const debugLogger = debug('peertube:video-update') | ||
16 | 22 | ||
17 | @Component({ | 23 | @Component({ |
18 | selector: 'my-videos-update', | 24 | selector: 'my-videos-update', |
19 | styleUrls: [ './shared/video-edit.component.scss' ], | 25 | styleUrls: [ './shared/video-edit.component.scss' ], |
20 | templateUrl: './video-update.component.html' | 26 | templateUrl: './video-update.component.html' |
21 | }) | 27 | }) |
22 | export class VideoUpdateComponent extends FormReactive implements OnInit { | 28 | export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate { |
23 | videoEdit: VideoEdit | 29 | videoEdit: VideoEdit |
24 | videoDetails: VideoDetails | 30 | videoDetails: VideoDetails |
25 | videoSource: VideoSource | 31 | videoSource: VideoSource |
@@ -27,10 +33,23 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
27 | videoCaptions: VideoCaptionEdit[] = [] | 33 | videoCaptions: VideoCaptionEdit[] = [] |
28 | liveVideo: LiveVideo | 34 | liveVideo: LiveVideo |
29 | 35 | ||
36 | userVideoQuotaUsed = 0 | ||
37 | userVideoQuotaUsedDaily = 0 | ||
38 | |||
30 | isUpdatingVideo = false | 39 | isUpdatingVideo = false |
31 | forbidScheduledPublication = false | 40 | forbidScheduledPublication = false |
32 | 41 | ||
33 | private updateDone = false | 42 | isReplacingVideoFile = false |
43 | videoUploadPercents: number | ||
44 | uploadError: string | ||
45 | |||
46 | updateDone = false | ||
47 | |||
48 | private videoReplacementUploadedSubject = new Subject<void>() | ||
49 | private alreadyRefreshedToken = false | ||
50 | |||
51 | private uploadServiceSubscription: Subscription | ||
52 | private updateSubcription: Subscription | ||
34 | 53 | ||
35 | constructor ( | 54 | constructor ( |
36 | protected formReactiveService: FormReactiveService, | 55 | protected formReactiveService: FormReactiveService, |
@@ -40,13 +59,30 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
40 | private videoService: VideoService, | 59 | private videoService: VideoService, |
41 | private loadingBar: LoadingBarService, | 60 | private loadingBar: LoadingBarService, |
42 | private videoCaptionService: VideoCaptionService, | 61 | private videoCaptionService: VideoCaptionService, |
43 | private liveVideoService: LiveVideoService | 62 | private server: ServerService, |
63 | private liveVideoService: LiveVideoService, | ||
64 | private videoUploadService: VideoUploadService, | ||
65 | private confirmService: ConfirmService, | ||
66 | private auth: AuthService, | ||
67 | private userService: UserService, | ||
68 | private resumableUploadService: UploadxService | ||
44 | ) { | 69 | ) { |
45 | super() | 70 | super() |
46 | } | 71 | } |
47 | 72 | ||
48 | ngOnInit () { | 73 | ngOnInit () { |
49 | this.buildForm({}) | 74 | this.buildForm({ |
75 | replaceFile: null | ||
76 | }) | ||
77 | |||
78 | this.userService.getMyVideoQuotaUsed() | ||
79 | .subscribe(data => { | ||
80 | this.userVideoQuotaUsed = data.videoQuotaUsed | ||
81 | this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily | ||
82 | }) | ||
83 | |||
84 | this.uploadServiceSubscription = this.resumableUploadService.events | ||
85 | .subscribe(state => this.onUploadVideoOngoing(state)) | ||
50 | 86 | ||
51 | const { videoData } = this.route.snapshot.data | 87 | const { videoData } = this.route.snapshot.data |
52 | const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData | 88 | const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData |
@@ -62,6 +98,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
62 | this.forbidScheduledPublication = this.videoEdit.privacy !== VideoPrivacy.PRIVATE | 98 | this.forbidScheduledPublication = this.videoEdit.privacy !== VideoPrivacy.PRIVATE |
63 | } | 99 | } |
64 | 100 | ||
101 | ngOnDestroy () { | ||
102 | this.resumableUploadService.disconnect() | ||
103 | |||
104 | if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe() | ||
105 | } | ||
106 | |||
65 | onFormBuilt () { | 107 | onFormBuilt () { |
66 | hydrateFormFromVideo(this.form, this.videoEdit, true) | 108 | hydrateFormFromVideo(this.form, this.videoEdit, true) |
67 | 109 | ||
@@ -88,6 +130,13 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
88 | canDeactivate (): { canDeactivate: boolean, text?: string } { | 130 | canDeactivate (): { canDeactivate: boolean, text?: string } { |
89 | if (this.updateDone === true) return { canDeactivate: true } | 131 | if (this.updateDone === true) return { canDeactivate: true } |
90 | 132 | ||
133 | if (this.isUpdatingVideo) { | ||
134 | return { | ||
135 | canDeactivate: false, | ||
136 | text: $localize`Your video is currenctly being updated. If you leave, your changes will be lost.` | ||
137 | } | ||
138 | } | ||
139 | |||
91 | const text = $localize`You have unsaved changes! If you leave, your changes will be lost.` | 140 | const text = $localize`You have unsaved changes! If you leave, your changes will be lost.` |
92 | 141 | ||
93 | for (const caption of this.videoCaptions) { | 142 | for (const caption of this.videoCaptions) { |
@@ -97,68 +146,90 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
97 | return { canDeactivate: this.formChanged === false, text } | 146 | return { canDeactivate: this.formChanged === false, text } |
98 | } | 147 | } |
99 | 148 | ||
149 | getVideoExtensions () { | ||
150 | return this.videoUploadService.getVideoExtensions() | ||
151 | } | ||
152 | |||
100 | isWaitTranscodingHidden () { | 153 | isWaitTranscodingHidden () { |
101 | return this.videoDetails.state.id !== VideoState.TO_TRANSCODE | 154 | return this.videoDetails.state.id !== VideoState.TO_TRANSCODE |
102 | } | 155 | } |
103 | 156 | ||
157 | isUpdateVideoFileEnabled () { | ||
158 | if (!this.server.getHTMLConfig().videoFile.update.enabled) return false | ||
159 | |||
160 | if (this.videoDetails.isLive) return false | ||
161 | if (this.videoDetails.state.id !== VideoState.PUBLISHED) return false | ||
162 | |||
163 | return true | ||
164 | } | ||
165 | |||
104 | async update () { | 166 | async update () { |
105 | await this.waitPendingCheck() | 167 | await this.waitPendingCheck() |
106 | this.forceCheck() | 168 | this.forceCheck() |
107 | 169 | ||
108 | if (!this.form.valid || this.isUpdatingVideo === true) { | 170 | if (!this.form.valid || this.isUpdatingVideo === true) return |
109 | return | 171 | |
110 | } | 172 | // Check and warn users about a file replacement |
173 | if (!await this.checkAndConfirmVideoFileReplacement()) return | ||
111 | 174 | ||
112 | this.videoEdit.patch(this.form.value) | 175 | this.videoEdit.patch(this.form.value) |
113 | 176 | ||
177 | this.abortUpdateIfNeeded() | ||
178 | |||
114 | this.loadingBar.useRef().start() | 179 | this.loadingBar.useRef().start() |
115 | this.isUpdatingVideo = true | 180 | this.isUpdatingVideo = true |
116 | 181 | ||
117 | // Update the video | 182 | this.updateSubcription = this.videoReplacementUploadedSubject.pipe( |
118 | this.videoService.updateVideo(this.videoEdit) | 183 | switchMap(() => this.videoService.updateVideo(this.videoEdit)), |
119 | .pipe( | 184 | |
120 | // Then update captions | 185 | // Then update captions |
121 | switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)), | 186 | switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)), |
122 | 187 | ||
123 | switchMap(() => { | 188 | switchMap(() => { |
124 | if (!this.liveVideo) return of(undefined) | 189 | if (!this.liveVideo) return of(undefined) |
125 | 190 | ||
126 | const saveReplay = !!this.form.value.saveReplay | 191 | const saveReplay = !!this.form.value.saveReplay |
127 | const replaySettings = saveReplay | 192 | const replaySettings = saveReplay |
128 | ? { privacy: this.form.value.replayPrivacy } | 193 | ? { privacy: this.form.value.replayPrivacy } |
129 | : undefined | 194 | : undefined |
130 | 195 | ||
131 | const liveVideoUpdate: LiveVideoUpdate = { | 196 | const liveVideoUpdate: LiveVideoUpdate = { |
132 | saveReplay, | 197 | saveReplay, |
133 | replaySettings, | 198 | replaySettings, |
134 | permanentLive: !!this.form.value.permanentLive, | 199 | permanentLive: !!this.form.value.permanentLive, |
135 | latencyMode: this.form.value.latencyMode | 200 | latencyMode: this.form.value.latencyMode |
136 | } | 201 | } |
137 | 202 | ||
138 | // Don't update live attributes if they did not change | 203 | // Don't update live attributes if they did not change |
139 | const baseVideo = pick(this.liveVideo, Object.keys(liveVideoUpdate) as (keyof LiveVideoUpdate)[]) | 204 | const baseVideo = pick(this.liveVideo, Object.keys(liveVideoUpdate) as (keyof LiveVideoUpdate)[]) |
140 | const liveChanged = !simpleObjectsDeepEqual(baseVideo, liveVideoUpdate) | 205 | const liveChanged = !simpleObjectsDeepEqual(baseVideo, liveVideoUpdate) |
141 | if (!liveChanged) return of(undefined) | 206 | if (!liveChanged) return of(undefined) |
142 | 207 | ||
143 | return this.liveVideoService.updateLive(this.videoEdit.id, liveVideoUpdate) | 208 | return this.liveVideoService.updateLive(this.videoEdit.id, liveVideoUpdate) |
144 | }) | 209 | }), |
145 | ) | 210 | |
146 | .subscribe({ | 211 | map(() => true), |
147 | next: () => { | 212 | |
148 | this.updateDone = true | 213 | catchError(err => { |
149 | this.isUpdatingVideo = false | 214 | this.notifier.error(err.message) |
150 | this.loadingBar.useRef().complete() | 215 | |
151 | this.notifier.success($localize`Video updated.`) | 216 | return of(false) |
152 | this.router.navigateByUrl(Video.buildWatchUrl(this.videoEdit)) | 217 | }) |
153 | }, | 218 | ) |
154 | 219 | .subscribe({ | |
155 | error: err => { | 220 | next: success => { |
156 | this.loadingBar.useRef().complete() | 221 | this.isUpdatingVideo = false |
157 | this.isUpdatingVideo = false | 222 | this.loadingBar.useRef().complete() |
158 | this.notifier.error(err.message) | 223 | |
159 | logger.error(err) | 224 | if (!success) return |
160 | } | 225 | |
161 | }) | 226 | this.updateDone = true |
227 | this.notifier.success($localize`Video updated.`) | ||
228 | this.router.navigateByUrl(Video.buildWatchUrl(this.videoEdit)) | ||
229 | } | ||
230 | }) | ||
231 | |||
232 | this.replaceFileIfNeeded() | ||
162 | } | 233 | } |
163 | 234 | ||
164 | hydratePluginFieldsFromVideo () { | 235 | hydratePluginFieldsFromVideo () { |
@@ -172,4 +243,118 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
172 | getVideoUrl () { | 243 | getVideoUrl () { |
173 | return Video.buildWatchUrl(this.videoDetails) | 244 | return Video.buildWatchUrl(this.videoDetails) |
174 | } | 245 | } |
246 | |||
247 | private async checkAndConfirmVideoFileReplacement () { | ||
248 | const replaceFile: File = this.form.value['replaceFile'] | ||
249 | if (!replaceFile) return true | ||
250 | |||
251 | const user = this.auth.getUser() | ||
252 | if (!this.videoUploadService.checkQuotaAndNotify(replaceFile, user.videoQuota, this.userVideoQuotaUsed)) return | ||
253 | if (!this.videoUploadService.checkQuotaAndNotify(replaceFile, user.videoQuotaDaily, this.userVideoQuotaUsedDaily)) return | ||
254 | |||
255 | const willBeBlocked = this.server.getHTMLConfig().autoBlacklist.videos.ofUsers.enabled === true && !this.videoDetails.blacklisted | ||
256 | let blockedWarning = '' | ||
257 | if (willBeBlocked) { | ||
258 | // eslint-disable-next-line max-len | ||
259 | blockedWarning = ' ' + $localize`Your video will also be automatically blocked since video publication requires manual validation by moderators.` | ||
260 | } | ||
261 | |||
262 | const message = $localize`Uploading a new version of your video will completely erase the current version.` + | ||
263 | blockedWarning + | ||
264 | ' ' + | ||
265 | $localize`<br /><br />Do you still want to replace your video file?` | ||
266 | |||
267 | const res = await this.confirmService.confirm(message, $localize`Replace file warning`) | ||
268 | if (res === false) return false | ||
269 | |||
270 | return true | ||
271 | } | ||
272 | |||
273 | private replaceFileIfNeeded () { | ||
274 | if (!this.form.value['replaceFile']) { | ||
275 | this.videoReplacementUploadedSubject.next() | ||
276 | return | ||
277 | } | ||
278 | |||
279 | this.uploadFileReplacement(this.form.value['replaceFile']) | ||
280 | } | ||
281 | |||
282 | private uploadFileReplacement (file: File) { | ||
283 | const metadata = { | ||
284 | filename: file.name | ||
285 | } | ||
286 | |||
287 | this.resumableUploadService.handleFiles(file, { | ||
288 | ...this.videoUploadService.getReplaceUploadxOptions(this.videoDetails.uuid), | ||
289 | |||
290 | metadata | ||
291 | }) | ||
292 | |||
293 | this.isReplacingVideoFile = true | ||
294 | } | ||
295 | |||
296 | onUploadVideoOngoing (state: UploadState) { | ||
297 | debugLogger('Upload state update', state) | ||
298 | |||
299 | switch (state.status) { | ||
300 | case 'error': { | ||
301 | if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) { | ||
302 | this.alreadyRefreshedToken = true | ||
303 | |||
304 | return this.refreshTokenAndRetryUpload() | ||
305 | } | ||
306 | |||
307 | this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state)) | ||
308 | break | ||
309 | } | ||
310 | |||
311 | case 'cancelled': | ||
312 | this.isReplacingVideoFile = false | ||
313 | this.videoUploadPercents = 0 | ||
314 | this.uploadError = '' | ||
315 | break | ||
316 | |||
317 | case 'uploading': | ||
318 | this.videoUploadPercents = state.progress || 0 | ||
319 | break | ||
320 | |||
321 | case 'complete': | ||
322 | this.isReplacingVideoFile = false | ||
323 | this.videoReplacementUploadedSubject.next() | ||
324 | this.videoUploadPercents = 100 | ||
325 | break | ||
326 | } | ||
327 | } | ||
328 | |||
329 | cancelUpload () { | ||
330 | debugLogger('Cancelling upload') | ||
331 | |||
332 | this.resumableUploadService.control({ action: 'cancel' }) | ||
333 | |||
334 | this.abortUpdateIfNeeded() | ||
335 | } | ||
336 | |||
337 | private handleUploadError (err: HttpErrorResponse) { | ||
338 | this.videoUploadPercents = 0 | ||
339 | this.isReplacingVideoFile = false | ||
340 | |||
341 | this.uploadError = genericUploadErrorHandler({ err, name: $localize`video` }) | ||
342 | |||
343 | this.videoReplacementUploadedSubject.error(err) | ||
344 | } | ||
345 | |||
346 | private refreshTokenAndRetryUpload () { | ||
347 | this.auth.refreshAccessToken() | ||
348 | .subscribe(() => this.uploadFileReplacement(this.form.value['replaceFile'])) | ||
349 | } | ||
350 | |||
351 | private abortUpdateIfNeeded () { | ||
352 | if (this.updateSubcription) { | ||
353 | this.updateSubcription.unsubscribe() | ||
354 | this.updateSubcription = undefined | ||
355 | } | ||
356 | |||
357 | this.videoReplacementUploadedSubject = new Subject<void>() | ||
358 | this.loadingBar.useRef().complete() | ||
359 | } | ||
175 | } | 360 | } |
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html index 0aa707666..bb095e09e 100644 --- a/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html +++ b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html | |||
@@ -18,6 +18,11 @@ | |||
18 | </a> | 18 | </a> |
19 | </div> | 19 | </div> |
20 | 20 | ||
21 | <div *ngIf="!!video.inputFileUpdatedAt" class="attribute attribute-re-uploaded-on"> | ||
22 | <span i18n class="attribute-label">Video re-upload</span> | ||
23 | <span class="attribute-value">{{ video.inputFileUpdatedAt | date: 'short' }}</span> | ||
24 | </div> | ||
25 | |||
21 | <div *ngIf="!!video.originallyPublishedAt" class="attribute attribute-originally-published-at"> | 26 | <div *ngIf="!!video.originallyPublishedAt" class="attribute attribute-originally-published-at"> |
22 | <span i18n class="attribute-label">Originally published</span> | 27 | <span i18n class="attribute-label">Originally published</span> |
23 | <span class="attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> | 28 | <span class="attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> |
diff --git a/client/src/app/helpers/utils/upload.ts b/client/src/app/helpers/utils/upload.ts index d7e1f7237..b60951612 100644 --- a/client/src/app/helpers/utils/upload.ts +++ b/client/src/app/helpers/utils/upload.ts | |||
@@ -5,14 +5,15 @@ import { HttpStatusCode } from '@shared/models' | |||
5 | function genericUploadErrorHandler (options: { | 5 | function genericUploadErrorHandler (options: { |
6 | err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'> | 6 | err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'> |
7 | name: string | 7 | name: string |
8 | notifier: Notifier | 8 | notifier?: Notifier |
9 | sticky?: boolean | 9 | sticky?: boolean |
10 | }) { | 10 | }) { |
11 | const { err, name, notifier, sticky = false } = options | 11 | const { err, name, notifier, sticky = false } = options |
12 | const title = $localize`Upload failed` | 12 | const title = $localize`Upload failed` |
13 | const message = buildMessage(name, err) | 13 | const message = buildMessage(name, err) |
14 | 14 | ||
15 | notifier.error(message, title, null, sticky) | 15 | if (notifier) notifier.error(message, title, null, sticky) |
16 | |||
16 | return message | 17 | return message |
17 | } | 18 | } |
18 | 19 | ||
diff --git a/client/src/app/shared/shared-forms/reactive-file.component.html b/client/src/app/shared/shared-forms/reactive-file.component.html index d18a99d46..8e38697e4 100644 --- a/client/src/app/shared/shared-forms/reactive-file.component.html +++ b/client/src/app/shared/shared-forms/reactive-file.component.html | |||
@@ -1,5 +1,5 @@ | |||
1 | <div class="root"> | 1 | <div class="root"> |
2 | <div class="button-file" [ngClass]="{ 'with-icon': !!icon }" [ngbTooltip]="buttonTooltip"> | 2 | <div class="button-file" [ngClass]="classes" [ngbTooltip]="buttonTooltip"> |
3 | <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon> | 3 | <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon> |
4 | 4 | ||
5 | <span>{{ inputLabel }}</span> | 5 | <span>{{ inputLabel }}</span> |
diff --git a/client/src/app/shared/shared-forms/reactive-file.component.scss b/client/src/app/shared/shared-forms/reactive-file.component.scss index 7643f29af..f9ba5805a 100644 --- a/client/src/app/shared/shared-forms/reactive-file.component.scss +++ b/client/src/app/shared/shared-forms/reactive-file.component.scss | |||
@@ -8,7 +8,6 @@ | |||
8 | 8 | ||
9 | .button-file { | 9 | .button-file { |
10 | @include peertube-button-file(auto); | 10 | @include peertube-button-file(auto); |
11 | @include grey-button; | ||
12 | 11 | ||
13 | &.with-icon { | 12 | &.with-icon { |
14 | @include button-with-icon; | 13 | @include button-with-icon; |
diff --git a/client/src/app/shared/shared-forms/reactive-file.component.ts b/client/src/app/shared/shared-forms/reactive-file.component.ts index 48055a51c..609aa0f40 100644 --- a/client/src/app/shared/shared-forms/reactive-file.component.ts +++ b/client/src/app/shared/shared-forms/reactive-file.component.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' | 1 | import { Component, EventEmitter, forwardRef, Input, OnChanges, OnInit, Output } from '@angular/core' |
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | import { Notifier } from '@app/core' | 3 | import { Notifier } from '@app/core' |
4 | import { GlobalIconName } from '@app/shared/shared-icons' | 4 | import { GlobalIconName } from '@app/shared/shared-icons' |
@@ -15,7 +15,8 @@ import { GlobalIconName } from '@app/shared/shared-icons' | |||
15 | } | 15 | } |
16 | ] | 16 | ] |
17 | }) | 17 | }) |
18 | export class ReactiveFileComponent implements OnInit, ControlValueAccessor { | 18 | export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAccessor { |
19 | @Input() theme: 'primary' | 'secondary' = 'secondary' | ||
19 | @Input() inputLabel: string | 20 | @Input() inputLabel: string |
20 | @Input() inputName: string | 21 | @Input() inputName: string |
21 | @Input() extensions: string[] = [] | 22 | @Input() extensions: string[] = [] |
@@ -29,6 +30,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor { | |||
29 | 30 | ||
30 | @Output() fileChanged = new EventEmitter<Blob>() | 31 | @Output() fileChanged = new EventEmitter<Blob>() |
31 | 32 | ||
33 | classes: { [id: string]: boolean } = {} | ||
32 | allowedExtensionsMessage = '' | 34 | allowedExtensionsMessage = '' |
33 | fileInputValue: any | 35 | fileInputValue: any |
34 | 36 | ||
@@ -44,6 +46,20 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor { | |||
44 | 46 | ||
45 | ngOnInit () { | 47 | ngOnInit () { |
46 | this.allowedExtensionsMessage = this.extensions.join(', ') | 48 | this.allowedExtensionsMessage = this.extensions.join(', ') |
49 | |||
50 | this.buildClasses() | ||
51 | } | ||
52 | |||
53 | ngOnChanges () { | ||
54 | this.buildClasses() | ||
55 | } | ||
56 | |||
57 | buildClasses () { | ||
58 | this.classes = { | ||
59 | 'with-icon': !!this.icon, | ||
60 | 'orange-button': this.theme === 'primary', | ||
61 | 'grey-button': this.theme === 'secondary' | ||
62 | } | ||
47 | } | 63 | } |
48 | 64 | ||
49 | fileChange (event: any) { | 65 | fileChange (event: any) { |
diff --git a/client/src/app/shared/shared-main/video/video-details.model.ts b/client/src/app/shared/shared-main/video/video-details.model.ts index 45c053507..5c36b5648 100644 --- a/client/src/app/shared/shared-main/video/video-details.model.ts +++ b/client/src/app/shared/shared-main/video/video-details.model.ts | |||
@@ -27,6 +27,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { | |||
27 | 27 | ||
28 | trackerUrls: string[] | 28 | trackerUrls: string[] |
29 | 29 | ||
30 | inputFileUpdatedAt: Date | string | ||
31 | |||
30 | files: VideoFile[] | 32 | files: VideoFile[] |
31 | streamingPlaylists: VideoStreamingPlaylist[] | 33 | streamingPlaylists: VideoStreamingPlaylist[] |
32 | 34 | ||
@@ -41,6 +43,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { | |||
41 | this.commentsEnabled = hash.commentsEnabled | 43 | this.commentsEnabled = hash.commentsEnabled |
42 | this.downloadEnabled = hash.downloadEnabled | 44 | this.downloadEnabled = hash.downloadEnabled |
43 | 45 | ||
46 | this.inputFileUpdatedAt = hash.inputFileUpdatedAt | ||
47 | |||
44 | this.trackerUrls = hash.trackerUrls | 48 | this.trackerUrls = hash.trackerUrls |
45 | 49 | ||
46 | this.buildLikeAndDislikePercents() | 50 | this.buildLikeAndDislikePercents() |
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 1ffc40411..a5bf1db8b 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts | |||
@@ -26,6 +26,7 @@ export class Video implements VideoServerModel { | |||
26 | updatedAt: Date | 26 | updatedAt: Date |
27 | publishedAt: Date | 27 | publishedAt: Date |
28 | originallyPublishedAt: Date | string | 28 | originallyPublishedAt: Date | string |
29 | |||
29 | category: VideoConstant<number> | 30 | category: VideoConstant<number> |
30 | licence: VideoConstant<number> | 31 | licence: VideoConstant<number> |
31 | language: VideoConstant<string> | 32 | language: VideoConstant<string> |
diff --git a/client/src/assets/player/shared/upnext/end-card.ts b/client/src/assets/player/shared/upnext/end-card.ts index 3589e1fd8..16883603e 100644 --- a/client/src/assets/player/shared/upnext/end-card.ts +++ b/client/src/assets/player/shared/upnext/end-card.ts | |||
@@ -48,6 +48,8 @@ class EndCard extends Component { | |||
48 | suspendedMessage: HTMLElement | 48 | suspendedMessage: HTMLElement |
49 | nextButton: HTMLElement | 49 | nextButton: HTMLElement |
50 | 50 | ||
51 | private timeout: any | ||
52 | |||
51 | private onEndedHandler: () => void | 53 | private onEndedHandler: () => void |
52 | private onPlayingHandler: () => void | 54 | private onPlayingHandler: () => void |
53 | 55 | ||
@@ -84,6 +86,8 @@ class EndCard extends Component { | |||
84 | if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler) | 86 | if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler) |
85 | if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler) | 87 | if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler) |
86 | 88 | ||
89 | if (this.timeout) clearTimeout(this.timeout) | ||
90 | |||
87 | super.dispose() | 91 | super.dispose() |
88 | } | 92 | } |
89 | 93 | ||
@@ -114,8 +118,6 @@ class EndCard extends Component { | |||
114 | } | 118 | } |
115 | 119 | ||
116 | showCard (cb: (canceled: boolean) => void) { | 120 | showCard (cb: (canceled: boolean) => void) { |
117 | let timeout: any | ||
118 | |||
119 | this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) | 121 | this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) |
120 | this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`) | 122 | this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`) |
121 | 123 | ||
@@ -126,17 +128,20 @@ class EndCard extends Component { | |||
126 | } | 128 | } |
127 | 129 | ||
128 | this.upNextEvents.one('cancel', () => { | 130 | this.upNextEvents.one('cancel', () => { |
129 | clearTimeout(timeout) | 131 | clearTimeout(this.timeout) |
132 | this.timeout = undefined | ||
130 | cb(true) | 133 | cb(true) |
131 | }) | 134 | }) |
132 | 135 | ||
133 | this.upNextEvents.one('playing', () => { | 136 | this.upNextEvents.one('playing', () => { |
134 | clearTimeout(timeout) | 137 | clearTimeout(this.timeout) |
138 | this.timeout = undefined | ||
135 | cb(true) | 139 | cb(true) |
136 | }) | 140 | }) |
137 | 141 | ||
138 | this.upNextEvents.one('next', () => { | 142 | this.upNextEvents.one('next', () => { |
139 | clearTimeout(timeout) | 143 | clearTimeout(this.timeout) |
144 | this.timeout = undefined | ||
140 | cb(false) | 145 | cb(false) |
141 | }) | 146 | }) |
142 | 147 | ||
@@ -154,19 +159,20 @@ class EndCard extends Component { | |||
154 | this.suspendedMessage.innerText = this.options_.suspendedText | 159 | this.suspendedMessage.innerText = this.options_.suspendedText |
155 | goToPercent(0) | 160 | goToPercent(0) |
156 | this.ticks = 0 | 161 | this.ticks = 0 |
157 | timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer | 162 | this.timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer |
158 | } else if (this.ticks >= this.totalTicks) { | 163 | } else if (this.ticks >= this.totalTicks) { |
159 | clearTimeout(timeout) | 164 | clearTimeout(this.timeout) |
165 | this.timeout = undefined | ||
160 | cb(false) | 166 | cb(false) |
161 | } else { | 167 | } else { |
162 | this.suspendedMessage.innerText = '' | 168 | this.suspendedMessage.innerText = '' |
163 | tick() | 169 | tick() |
164 | timeout = setTimeout(update.bind(this), this.interval) | 170 | this.timeout = setTimeout(update.bind(this), this.interval) |
165 | } | 171 | } |
166 | } | 172 | } |
167 | 173 | ||
168 | this.container.style.display = 'block' | 174 | this.container.style.display = 'block' |
169 | timeout = setTimeout(update.bind(this), this.interval) | 175 | this.timeout = setTimeout(update.bind(this), this.interval) |
170 | } | 176 | } |
171 | } | 177 | } |
172 | 178 | ||
diff --git a/server/controllers/api/videos/source.ts b/server/controllers/api/videos/source.ts index b20c4af0e..75fe68b6c 100644 --- a/server/controllers/api/videos/source.ts +++ b/server/controllers/api/videos/source.ts | |||
@@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc' | |||
14 | import { VideoModel } from '@server/models/video/video' | 14 | import { VideoModel } from '@server/models/video/video' |
15 | import { VideoSourceModel } from '@server/models/video/video-source' | 15 | import { VideoSourceModel } from '@server/models/video/video-source' |
16 | import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | 16 | import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' |
17 | import { HttpStatusCode, VideoState } from '@shared/models' | 17 | import { VideoState } from '@shared/models' |
18 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | 18 | import { logger, loggerTagsFactory } from '../../../helpers/logger' |
19 | import { | 19 | import { |
20 | asyncMiddleware, | 20 | asyncMiddleware, |
@@ -121,7 +121,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R | |||
121 | 121 | ||
122 | await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists }) | 122 | await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists }) |
123 | 123 | ||
124 | await VideoSourceModel.create({ | 124 | const source = await VideoSourceModel.create({ |
125 | filename: originalFilename, | 125 | filename: originalFilename, |
126 | videoId: video.id, | 126 | videoId: video.id, |
127 | createdAt: inputFileUpdatedAt | 127 | createdAt: inputFileUpdatedAt |
@@ -135,7 +135,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R | |||
135 | 135 | ||
136 | Hooks.runAction('action:api.video.file-updated', { video, req, res }) | 136 | Hooks.runAction('action:api.video.file-updated', { video, req, res }) |
137 | 137 | ||
138 | return res.sendStatus(HttpStatusCode.NO_CONTENT_204) | 138 | return res.json(source.toFormattedJSON()) |
139 | } finally { | 139 | } finally { |
140 | videoFileMutexReleaser() | 140 | videoFileMutexReleaser() |
141 | } | 141 | } |
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts index 6c38fa7ef..3fdbc348a 100644 --- a/shared/server-commands/videos/videos-command.ts +++ b/shared/server-commands/videos/videos-command.ts | |||
@@ -462,7 +462,7 @@ export class VideosCommand extends AbstractCommand { | |||
462 | path: string | 462 | path: string |
463 | attributes: { fixture?: string } & { [id: string]: any } | 463 | attributes: { fixture?: string } & { [id: string]: any } |
464 | }): Promise<VideoCreateResult> { | 464 | }): Promise<VideoCreateResult> { |
465 | const { path, attributes, expectedStatus } = options | 465 | const { path, attributes, expectedStatus = HttpStatusCode.OK_200 } = options |
466 | 466 | ||
467 | let size = 0 | 467 | let size = 0 |
468 | let videoFilePath: string | 468 | let videoFilePath: string |
@@ -597,43 +597,47 @@ export class VideosCommand extends AbstractCommand { | |||
597 | const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 }) | 597 | const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 }) |
598 | return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => { | 598 | return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => { |
599 | readable.on('data', async function onData (chunk) { | 599 | readable.on('data', async function onData (chunk) { |
600 | readable.pause() | 600 | try { |
601 | 601 | readable.pause() | |
602 | const headers = { | 602 | |
603 | 'Authorization': 'Bearer ' + token, | 603 | const headers = { |
604 | 'Content-Type': 'application/octet-stream', | 604 | 'Authorization': 'Bearer ' + token, |
605 | 'Content-Range': contentRangeBuilder | 605 | 'Content-Type': 'application/octet-stream', |
606 | ? contentRangeBuilder(start, chunk) | 606 | 'Content-Range': contentRangeBuilder |
607 | : `bytes ${start}-${start + chunk.length - 1}/${size}`, | 607 | ? contentRangeBuilder(start, chunk) |
608 | 'Content-Length': contentLength ? contentLength + '' : chunk.length + '' | 608 | : `bytes ${start}-${start + chunk.length - 1}/${size}`, |
609 | 'Content-Length': contentLength ? contentLength + '' : chunk.length + '' | ||
610 | } | ||
611 | |||
612 | if (digestBuilder) { | ||
613 | Object.assign(headers, { digest: digestBuilder(chunk) }) | ||
614 | } | ||
615 | |||
616 | const res = await got<{ video: VideoCreateResult }>({ | ||
617 | url, | ||
618 | method: 'put', | ||
619 | headers, | ||
620 | path: path + '?' + pathUploadId, | ||
621 | body: chunk, | ||
622 | responseType: 'json', | ||
623 | throwHttpErrors: false | ||
624 | }) | ||
625 | |||
626 | start += chunk.length | ||
627 | |||
628 | if (res.statusCode === expectedStatus) { | ||
629 | return resolve(res) | ||
630 | } | ||
631 | |||
632 | if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) { | ||
633 | readable.off('data', onData) | ||
634 | return reject(new Error('Incorrect transient behaviour sending intermediary chunks')) | ||
635 | } | ||
636 | |||
637 | readable.resume() | ||
638 | } catch (err) { | ||
639 | reject(err) | ||
609 | } | 640 | } |
610 | |||
611 | if (digestBuilder) { | ||
612 | Object.assign(headers, { digest: digestBuilder(chunk) }) | ||
613 | } | ||
614 | |||
615 | const res = await got<{ video: VideoCreateResult }>({ | ||
616 | url, | ||
617 | method: 'put', | ||
618 | headers, | ||
619 | path: path + '?' + pathUploadId, | ||
620 | body: chunk, | ||
621 | responseType: 'json', | ||
622 | throwHttpErrors: false | ||
623 | }) | ||
624 | |||
625 | start += chunk.length | ||
626 | |||
627 | if (res.statusCode === expectedStatus) { | ||
628 | return resolve(res) | ||
629 | } | ||
630 | |||
631 | if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) { | ||
632 | readable.off('data', onData) | ||
633 | return reject(new Error('Incorrect transient behaviour sending intermediary chunks')) | ||
634 | } | ||
635 | |||
636 | readable.resume() | ||
637 | }) | 641 | }) |
638 | }) | 642 | }) |
639 | } | 643 | } |
@@ -695,8 +699,7 @@ export class VideosCommand extends AbstractCommand { | |||
695 | ...options, | 699 | ...options, |
696 | 700 | ||
697 | path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable', | 701 | path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable', |
698 | attributes: { fixture: options.fixture }, | 702 | attributes: { fixture: options.fixture } |
699 | expectedStatus: HttpStatusCode.NO_CONTENT_204 | ||
700 | }) | 703 | }) |
701 | } | 704 | } |
702 | 705 | ||