aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html12
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts5
-rw-r--r--client/src/app/+videos/+video-edit/shared/upload-progress.component.html32
-rw-r--r--client/src/app/+videos/+video-edit/shared/upload-progress.component.scss30
-rw-r--r--client/src/app/+videos/+video-edit/shared/upload-progress.component.ts17
-rw-r--r--client/src/app/+videos/+video-edit/shared/uploaderx-form-data.ts (renamed from client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts)0
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html4
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.scss5
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts1
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.module.ts11
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-upload.service.ts110
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html38
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss28
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts125
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.html27
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts301
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html5
-rw-r--r--client/src/app/helpers/utils/upload.ts5
-rw-r--r--client/src/app/shared/shared-forms/reactive-file.component.html2
-rw-r--r--client/src/app/shared/shared-forms/reactive-file.component.scss1
-rw-r--r--client/src/app/shared/shared-forms/reactive-file.component.ts20
-rw-r--r--client/src/app/shared/shared-main/video/video-details.model.ts4
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts1
-rw-r--r--client/src/assets/player/shared/upnext/end-card.ts24
-rw-r--r--server/controllers/api/videos/source.ts6
-rw-r--r--shared/server-commands/videos/videos-command.ts81
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 @@
1import { 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})
8export 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'
5import { SharedMainModule } from '@app/shared/shared-main' 5import { SharedMainModule } from '@app/shared/shared-main'
6import { SharedVideoLiveModule } from '@app/shared/shared-video-live' 6import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
7import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' 7import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
8import { UploadProgressComponent } from './upload-progress.component'
8import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' 9import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
9import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component' 10import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
10import { VideoEditComponent } from './video-edit.component' 11import { VideoEditComponent } from './video-edit.component'
12import { 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})
42export class VideoEditModule { } 47export 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 @@
1import { UploaderX, UploadState, UploadxOptions } from 'ngx-uploadx'
2import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { AuthService, Notifier, ServerService } from '@app/core'
5import { BytesPipe, VideoService } from '@app/shared/shared-main'
6import { isIOS } from '@root-helpers/web-browser'
7import { HttpStatusCode } from '@shared/models'
8import { UploaderXFormData } from './uploaderx-form-data'
9
10@Injectable()
11export 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 @@
1import { truncate } from 'lodash-es' 1import { truncate } from 'lodash-es'
2import { UploadState, UploadxOptions, UploadxService } from 'ngx-uploadx' 2import { UploadState, UploadxService } from 'ngx-uploadx'
3import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http' 3import { Subscription } from 'rxjs'
4import { HttpErrorResponse } from '@angular/common/http'
4import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' 5import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
5import { ActivatedRoute, Router } from '@angular/router' 6import { ActivatedRoute, Router } from '@angular/router'
6import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' 7import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
7import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' 8import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
8import { FormReactiveService } from '@app/shared/shared-forms' 9import { FormReactiveService } from '@app/shared/shared-forms'
9import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 10import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
10import { LoadingBarService } from '@ngx-loading-bar/core' 11import { LoadingBarService } from '@ngx-loading-bar/core'
11import { logger } from '@root-helpers/logger' 12import { logger } from '@root-helpers/logger'
12import { isIOS } from '@root-helpers/web-browser'
13import { HttpStatusCode, VideoCreateResult } from '@shared/models' 13import { HttpStatusCode, VideoCreateResult } from '@shared/models'
14import { UploaderXFormData } from './uploaderx-form-data' 14import { VideoUploadService } from '../shared/video-upload.service'
15import { VideoSend } from './video-send' 15import { VideoSend } from './video-send'
16import { 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 @@
1import { of } from 'rxjs' 1import debug from 'debug'
2import { switchMap } from 'rxjs/operators' 2import { UploadState, UploadxService } from 'ngx-uploadx'
3import { of, Subject, Subscription } from 'rxjs'
4import { catchError, map, switchMap } from 'rxjs/operators'
3import { SelectChannelItem } from 'src/types/select-options-item.model' 5import { SelectChannelItem } from 'src/types/select-options-item.model'
4import { Component, HostListener, OnInit } from '@angular/core' 6import { HttpErrorResponse } from '@angular/common/http'
7import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'
5import { ActivatedRoute, Router } from '@angular/router' 8import { ActivatedRoute, Router } from '@angular/router'
6import { Notifier } from '@app/core' 9import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
10import { genericUploadErrorHandler } from '@app/helpers'
7import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 11import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
8import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' 12import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
9import { LiveVideoService } from '@app/shared/shared-video-live' 13import { LiveVideoService } from '@app/shared/shared-video-live'
10import { LoadingBarService } from '@ngx-loading-bar/core' 14import { LoadingBarService } from '@ngx-loading-bar/core'
11import { logger } from '@root-helpers/logger'
12import { pick, simpleObjectsDeepEqual } from '@shared/core-utils' 15import { pick, simpleObjectsDeepEqual } from '@shared/core-utils'
13import { LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models' 16import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models'
14import { VideoSource } from '@shared/models/videos/video-source' 17import { VideoSource } from '@shared/models/videos/video-source'
15import { hydrateFormFromVideo } from './shared/video-edit-utils' 18import { hydrateFormFromVideo } from './shared/video-edit-utils'
19import { VideoUploadService } from './shared/video-upload.service'
20
21const 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})
22export class VideoUpdateComponent extends FormReactive implements OnInit { 28export 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'
5function genericUploadErrorHandler (options: { 5function 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 @@
1import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' 1import { Component, EventEmitter, forwardRef, Input, OnChanges, OnInit, Output } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { GlobalIconName } from '@app/shared/shared-icons' 4import { GlobalIconName } from '@app/shared/shared-icons'
@@ -15,7 +15,8 @@ import { GlobalIconName } from '@app/shared/shared-icons'
15 } 15 }
16 ] 16 ]
17}) 17})
18export class ReactiveFileComponent implements OnInit, ControlValueAccessor { 18export 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'
14import { VideoModel } from '@server/models/video/video' 14import { VideoModel } from '@server/models/video/video'
15import { VideoSourceModel } from '@server/models/video/video-source' 15import { VideoSourceModel } from '@server/models/video/video-source'
16import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 16import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
17import { HttpStatusCode, VideoState } from '@shared/models' 17import { VideoState } from '@shared/models'
18import { logger, loggerTagsFactory } from '../../../helpers/logger' 18import { logger, loggerTagsFactory } from '../../../helpers/logger'
19import { 19import {
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