From f42fcb4b58f146c2e5ace236548a99d361ade55f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 21 Jul 2023 17:46:37 +0200 Subject: [PATCH] Implement video file replacement in client --- .../edit-basic-configuration.component.html | 12 + .../edit-custom-config.component.ts | 5 + .../shared/upload-progress.component.html | 32 ++ .../shared/upload-progress.component.scss | 30 ++ .../shared/upload-progress.component.ts | 17 ++ .../uploaderx-form-data.ts | 0 .../shared/video-edit.component.html | 4 +- .../shared/video-edit.component.scss | 5 + .../shared/video-edit.component.ts | 1 + .../+video-edit/shared/video-edit.module.ts | 11 +- .../shared/video-upload.service.ts | 110 +++++++ .../video-upload.component.html | 38 +-- .../video-upload.component.scss | 28 -- .../video-upload.component.ts | 125 ++------ .../+video-edit/video-update.component.html | 27 +- .../+video-edit/video-update.component.ts | 289 ++++++++++++++---- .../metadata/video-attributes.component.html | 5 + client/src/app/helpers/utils/upload.ts | 5 +- .../shared-forms/reactive-file.component.html | 2 +- .../shared-forms/reactive-file.component.scss | 1 - .../shared-forms/reactive-file.component.ts | 20 +- .../shared-main/video/video-details.model.ts | 4 + .../shared/shared-main/video/video.model.ts | 1 + .../assets/player/shared/upnext/end-card.ts | 24 +- server/controllers/api/videos/source.ts | 6 +- .../server-commands/videos/videos-command.ts | 79 ++--- 26 files changed, 603 insertions(+), 278 deletions(-) create mode 100644 client/src/app/+videos/+video-edit/shared/upload-progress.component.html create mode 100644 client/src/app/+videos/+video-edit/shared/upload-progress.component.scss create mode 100644 client/src/app/+videos/+video-edit/shared/upload-progress.component.ts rename client/src/app/+videos/+video-edit/{video-add-components => shared}/uploaderx-form-data.ts (100%) create mode 100644 client/src/app/+videos/+video-edit/shared/video-upload.service.ts 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 @@ + + +
+ + +
+
+
+ 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 { enabled: null } }, + videoFile: { + update: { + enabled: null + } + }, autoBlacklist: { videos: { ofUsers: { diff --git a/client/src/app/+videos/+video-edit/shared/upload-progress.component.html b/client/src/app/+videos/+video-edit/shared/upload-progress.component.html new file mode 100644 index 000000000..f1626b8f0 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/upload-progress.component.html @@ -0,0 +1,32 @@ + +
+
+
+ Processing… + {{ videoUploadPercents }}% +
+
+ +
+ +
+
+
+ {{ error }} +
+
+ + + +
+ +
+
Sorry, but something went wrong
+ {{ error }} +
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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.upload-progress-retry, +.upload-progress-cancel { + display: flex; + margin-bottom: 40px; + + .progress { + @include progressbar; + + flex-grow: 1; + height: 30px; + font-size: 14px; + background-color: rgba(11, 204, 41, 0.16); + + .progress-bar { + background-color: $green; + line-height: 30px; + text-align: start; + font-weight: $font-semibold; + + span { + @include margin-left(13px); + + color: pvar(--mainBackgroundColor); + } + } + } +} diff --git a/client/src/app/+videos/+video-edit/shared/upload-progress.component.ts b/client/src/app/+videos/+video-edit/shared/upload-progress.component.ts new file mode 100644 index 000000000..9ce3a2cb2 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/upload-progress.component.ts @@ -0,0 +1,17 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core' + +@Component({ + selector: 'my-upload-progress', + templateUrl: './upload-progress.component.html', + styleUrls: [ './upload-progress.component.scss' ] +}) +export class UploadProgressComponent { + @Input() isUploadingVideo: boolean + @Input() videoUploadPercents: number + @Input() error: string + @Input() videoUploaded: boolean + @Input() enableRetryAfterError: boolean + + @Output() cancel = new EventEmitter() + @Output() retry = new EventEmitter() +} 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 similarity index 100% rename from client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts rename to 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 @@ - +
+ +
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 { grid-gap: 30px; } +.button-file { + @include peertube-button-file(max-content); + @include orange-button; +} + @include on-small-main-col { .form-columns { grid-template-columns: 1fr; 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 { @Input() videoSource: VideoSource @Input() hideWaitTranscoding = false + @Input() updateVideoFileEnabled = false @Input() type: VideoEditType @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' import { SharedMainModule } from '@app/shared/shared-main' import { SharedVideoLiveModule } from '@app/shared/shared-video-live' import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' +import { UploadProgressComponent } from './upload-progress.component' import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component' import { VideoEditComponent } from './video-edit.component' +import { VideoUploadService } from './video-upload.service' @NgModule({ imports: [ @@ -22,7 +24,8 @@ import { VideoEditComponent } from './video-edit.component' declarations: [ VideoEditComponent, VideoCaptionAddModalComponent, - VideoCaptionEditModalContentComponent + VideoCaptionEditModalContentComponent, + UploadProgressComponent ], exports: [ @@ -32,11 +35,13 @@ import { VideoEditComponent } from './video-edit.component' SharedFormModule, SharedGlobalIconModule, - VideoEditComponent + VideoEditComponent, + UploadProgressComponent ], providers: [ - I18nPrimengCalendarService + I18nPrimengCalendarService, + VideoUploadService ] }) export class VideoEditModule { } diff --git a/client/src/app/+videos/+video-edit/shared/video-upload.service.ts b/client/src/app/+videos/+video-edit/shared/video-upload.service.ts new file mode 100644 index 000000000..cb9503503 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-upload.service.ts @@ -0,0 +1,110 @@ +import { UploaderX, UploadState, UploadxOptions } from 'ngx-uploadx' +import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { AuthService, Notifier, ServerService } from '@app/core' +import { BytesPipe, VideoService } from '@app/shared/shared-main' +import { isIOS } from '@root-helpers/web-browser' +import { HttpStatusCode } from '@shared/models' +import { UploaderXFormData } from './uploaderx-form-data' + +@Injectable() +export class VideoUploadService { + + constructor ( + private server: ServerService, + private notifier: Notifier, + private authService: AuthService + ) { + + } + + getVideoExtensions () { + return this.server.getHTMLConfig().video.file.extensions + } + + checkQuotaAndNotify (videoFile: File, maxQuota: number, quotaUsed: number) { + const bytePipes = new BytesPipe() + + // Check global user quota + if (maxQuota !== -1 && (quotaUsed + videoFile.size) > maxQuota) { + const videoSizeBytes = bytePipes.transform(videoFile.size, 0) + const videoQuotaUsedBytes = bytePipes.transform(quotaUsed, 0) + const videoQuotaBytes = bytePipes.transform(maxQuota, 0) + + // eslint-disable-next-line max-len + const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})` + this.notifier.error(msg) + + return false + } + + return true + } + + isAudioFile (filename: string) { + const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ] + + return extensions.some(e => filename.endsWith(e)) + } + + // --------------------------------------------------------------------------- + + getNewUploadxOptions (): UploadxOptions { + return this.getUploadxOptions( + VideoService.BASE_VIDEO_URL + '/upload-resumable', + UploaderXFormData + ) + } + + getReplaceUploadxOptions (videoId: string): UploadxOptions { + return this.getUploadxOptions( + VideoService.BASE_VIDEO_URL + '/' + videoId + '/source/replace-resumable', + UploaderX + ) + } + + private getUploadxOptions (endpoint: string, uploaderClass: typeof UploaderXFormData) { + // FIXME: https://github.com/Chocobozzz/PeerTube/issues/4382#issuecomment-915854167 + const chunkSize = isIOS() + ? 0 + : undefined // Auto chunk size + + return { + endpoint, + multiple: false, + + maxChunkSize: this.server.getHTMLConfig().client.videos.resumableUpload.maxChunkSize, + chunkSize, + + token: this.authService.getAccessToken(), + + uploaderClass, + + retryConfig: { + maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below + maxDelay: 120_000, // 2 min + shouldRetry: (code: number, attempts: number) => { + return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6) + } + } + } + } + + // --------------------------------------------------------------------------- + + buildHTTPErrorResponse (state: UploadState): HttpErrorResponse { + const error = state.response?.error?.message || state.response?.error || 'Unknown error' + + return { + error: new Error(error), + name: 'HttpErrorResponse', + message: error, + ok: false, + headers: new HttpHeaders(state.responseHeaders), + status: +state.responseStatus, + statusText: error, + type: HttpEventType.Response, + url: state.url + } + } +} 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 @@
-
+
Select the file to upload
- -
-
-
- Processing… - {{ videoUploadPercents }}% -
-
- -
- -
-
-
- {{ error }} -
-
- - - -
- -
-
Sorry, but something went wrong
- {{ error }} -
+ +
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 @@ margin: 30px 0; } } - -.upload-progress-retry, -.upload-progress-cancel { - display: flex; - margin-bottom: 40px; - - .progress { - @include progressbar; - - flex-grow: 1; - height: 30px; - font-size: 14px; - background-color: rgba(11, 204, 41, 0.16); - - .progress-bar { - background-color: $green; - line-height: 30px; - text-align: start; - font-weight: $font-semibold; - - span { - @include margin-left(13px); - - color: pvar(--mainBackgroundColor); - } - } - } -} 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 @@ import { truncate } from 'lodash-es' -import { UploadState, UploadxOptions, UploadxService } from 'ngx-uploadx' -import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http' +import { UploadState, UploadxService } from 'ngx-uploadx' +import { Subscription } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' import { FormReactiveService } from '@app/shared/shared-forms' -import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' import { LoadingBarService } from '@ngx-loading-bar/core' import { logger } from '@root-helpers/logger' -import { isIOS } from '@root-helpers/web-browser' import { HttpStatusCode, VideoCreateResult } from '@shared/models' -import { UploaderXFormData } from './uploaderx-form-data' +import { VideoUploadService } from '../shared/video-upload.service' import { VideoSend } from './video-send' -import { Subscription } from 'rxjs' @Component({ selector: 'my-video-upload', @@ -49,9 +48,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy error: string enableRetryAfterError: boolean - // So that it can be accessed in the template - protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + '/upload-resumable' - private isUpdatingVideo = false private fileToUpload: File @@ -72,15 +68,12 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy private hooks: HooksService, private resumableUploadService: UploadxService, private metaService: MetaService, - private route: ActivatedRoute + private route: ActivatedRoute, + private videoUploadService: VideoUploadService ) { super() } - get videoExtensions () { - return this.serverConfig.video.file.extensions.join(', ') - } - ngOnInit () { super.ngOnInit() @@ -133,28 +126,20 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy } } + getVideoExtensions () { + return this.videoUploadService.getVideoExtensions().join(', ') + } + onUploadVideoOngoing (state: UploadState) { switch (state.status) { case 'error': { if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) { this.alreadyRefreshedToken = true - return this.refereshTokenAndRetryUpload() + return this.refreshTokenAndRetryUpload() } - const error = state.response?.error?.message || state.response?.error || 'Unknown error' - - this.handleUploadError({ - error: new Error(error), - name: 'HttpErrorResponse', - message: error, - ok: false, - headers: new HttpHeaders(state.responseHeaders), - status: +state.responseStatus, - statusText: error, - type: HttpEventType.Response, - url: state.url - }) + this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state)) break } @@ -203,10 +188,12 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy if (!file) return - if (!this.checkGlobalUserQuota(file)) return - if (!this.checkDailyUserQuota(file)) return + const user = this.authService.getUser() - if (this.isAudioFile(file.name)) { + if (!this.videoUploadService.checkQuotaAndNotify(file, user.videoQuota, this.userVideoQuotaUsed)) return + if (!this.videoUploadService.checkQuotaAndNotify(file, user.videoQuotaDaily, this.userVideoQuotaUsedDaily)) return + + if (this.videoUploadService.isAudioFile(file.name)) { this.isUploadingAudioFile = true return } @@ -291,7 +278,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy } this.resumableUploadService.handleFiles(file, { - ...this.getUploadxOptions(), + ...this.videoUploadService.getNewUploadxOptions(), metadata }) @@ -331,51 +318,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy this.updateTitle() } - private checkGlobalUserQuota (videofile: File) { - const bytePipes = new BytesPipe() - - // Check global user quota - const videoQuota = this.authService.getUser().videoQuota - if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) { - const videoSizeBytes = bytePipes.transform(videofile.size, 0) - const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0) - const videoQuotaBytes = bytePipes.transform(videoQuota, 0) - - // eslint-disable-next-line max-len - const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})` - this.notifier.error(msg) - - return false - } - - return true - } - - private checkDailyUserQuota (videofile: File) { - const bytePipes = new BytesPipe() - - // Check daily user quota - const videoQuotaDaily = this.authService.getUser().videoQuotaDaily - if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) { - const videoSizeBytes = bytePipes.transform(videofile.size, 0) - const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0) - const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0) - // eslint-disable-next-line max-len - const msg = $localize`Your daily video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})` - this.notifier.error(msg) - - return false - } - - return true - } - - private isAudioFile (filename: string) { - const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ] - - return extensions.some(e => filename.endsWith(e)) - } - private buildVideoFilename (filename: string) { const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '') let name = nameWithoutExtension.length < 3 @@ -390,35 +332,8 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy return name } - private refereshTokenAndRetryUpload () { + private refreshTokenAndRetryUpload () { this.authService.refreshAccessToken() .subscribe(() => this.retryUpload()) } - - private getUploadxOptions (): UploadxOptions { - // FIXME: https://github.com/Chocobozzz/PeerTube/issues/4382#issuecomment-915854167 - const chunkSize = isIOS() - ? 0 - : undefined // Auto chunk size - - return { - endpoint: this.BASE_VIDEO_UPLOAD_URL, - multiple: false, - - maxChunkSize: this.serverConfig.client.videos.resumableUpload.maxChunkSize, - chunkSize, - - token: this.authService.getAccessToken(), - - uploaderClass: UploaderXFormData, - - retryConfig: { - maxAttempts: 30, // maximum attempts for 503 codes, otherwise set to 6, see below - maxDelay: 120_000, // 2 min - shouldRetry: (code: number, attempts: number) => { - return code === HttpStatusCode.SERVICE_UNAVAILABLE_503 || ((code < 400 || code > 500) && attempts < 6) - } - } - } - } } 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 @@ {{ videoDetails?.name }}
+ + +
+ > + +
+ + +
⚠️ Uploading a new version of your video will completely erase the current version
+ +
+ +
+
+
() + private alreadyRefreshedToken = false + + private uploadServiceSubscription: Subscription + private updateSubcription: Subscription constructor ( protected formReactiveService: FormReactiveService, @@ -40,13 +59,30 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { private videoService: VideoService, private loadingBar: LoadingBarService, private videoCaptionService: VideoCaptionService, - private liveVideoService: LiveVideoService + private server: ServerService, + private liveVideoService: LiveVideoService, + private videoUploadService: VideoUploadService, + private confirmService: ConfirmService, + private auth: AuthService, + private userService: UserService, + private resumableUploadService: UploadxService ) { super() } ngOnInit () { - this.buildForm({}) + this.buildForm({ + replaceFile: null + }) + + this.userService.getMyVideoQuotaUsed() + .subscribe(data => { + this.userVideoQuotaUsed = data.videoQuotaUsed + this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily + }) + + this.uploadServiceSubscription = this.resumableUploadService.events + .subscribe(state => this.onUploadVideoOngoing(state)) const { videoData } = this.route.snapshot.data const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData @@ -62,6 +98,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { this.forbidScheduledPublication = this.videoEdit.privacy !== VideoPrivacy.PRIVATE } + ngOnDestroy () { + this.resumableUploadService.disconnect() + + if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe() + } + onFormBuilt () { hydrateFormFromVideo(this.form, this.videoEdit, true) @@ -88,6 +130,13 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { canDeactivate (): { canDeactivate: boolean, text?: string } { if (this.updateDone === true) return { canDeactivate: true } + if (this.isUpdatingVideo) { + return { + canDeactivate: false, + text: $localize`Your video is currenctly being updated. If you leave, your changes will be lost.` + } + } + const text = $localize`You have unsaved changes! If you leave, your changes will be lost.` for (const caption of this.videoCaptions) { @@ -97,68 +146,90 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { return { canDeactivate: this.formChanged === false, text } } + getVideoExtensions () { + return this.videoUploadService.getVideoExtensions() + } + isWaitTranscodingHidden () { return this.videoDetails.state.id !== VideoState.TO_TRANSCODE } + isUpdateVideoFileEnabled () { + if (!this.server.getHTMLConfig().videoFile.update.enabled) return false + + if (this.videoDetails.isLive) return false + if (this.videoDetails.state.id !== VideoState.PUBLISHED) return false + + return true + } + async update () { await this.waitPendingCheck() this.forceCheck() - if (!this.form.valid || this.isUpdatingVideo === true) { - return - } + if (!this.form.valid || this.isUpdatingVideo === true) return + + // Check and warn users about a file replacement + if (!await this.checkAndConfirmVideoFileReplacement()) return this.videoEdit.patch(this.form.value) + this.abortUpdateIfNeeded() + this.loadingBar.useRef().start() this.isUpdatingVideo = true - // Update the video - this.videoService.updateVideo(this.videoEdit) - .pipe( - // Then update captions - switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)), + this.updateSubcription = this.videoReplacementUploadedSubject.pipe( + switchMap(() => this.videoService.updateVideo(this.videoEdit)), - switchMap(() => { - if (!this.liveVideo) return of(undefined) + // Then update captions + switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)), - const saveReplay = !!this.form.value.saveReplay - const replaySettings = saveReplay - ? { privacy: this.form.value.replayPrivacy } - : undefined + switchMap(() => { + if (!this.liveVideo) return of(undefined) - const liveVideoUpdate: LiveVideoUpdate = { - saveReplay, - replaySettings, - permanentLive: !!this.form.value.permanentLive, - latencyMode: this.form.value.latencyMode - } + const saveReplay = !!this.form.value.saveReplay + const replaySettings = saveReplay + ? { privacy: this.form.value.replayPrivacy } + : undefined - // Don't update live attributes if they did not change - const baseVideo = pick(this.liveVideo, Object.keys(liveVideoUpdate) as (keyof LiveVideoUpdate)[]) - const liveChanged = !simpleObjectsDeepEqual(baseVideo, liveVideoUpdate) - if (!liveChanged) return of(undefined) + const liveVideoUpdate: LiveVideoUpdate = { + saveReplay, + replaySettings, + permanentLive: !!this.form.value.permanentLive, + latencyMode: this.form.value.latencyMode + } - return this.liveVideoService.updateLive(this.videoEdit.id, liveVideoUpdate) - }) - ) - .subscribe({ - next: () => { - this.updateDone = true - this.isUpdatingVideo = false - this.loadingBar.useRef().complete() - this.notifier.success($localize`Video updated.`) - this.router.navigateByUrl(Video.buildWatchUrl(this.videoEdit)) - }, + // Don't update live attributes if they did not change + const baseVideo = pick(this.liveVideo, Object.keys(liveVideoUpdate) as (keyof LiveVideoUpdate)[]) + const liveChanged = !simpleObjectsDeepEqual(baseVideo, liveVideoUpdate) + if (!liveChanged) return of(undefined) - error: err => { - this.loadingBar.useRef().complete() - this.isUpdatingVideo = false - this.notifier.error(err.message) - logger.error(err) - } - }) + return this.liveVideoService.updateLive(this.videoEdit.id, liveVideoUpdate) + }), + + map(() => true), + + catchError(err => { + this.notifier.error(err.message) + + return of(false) + }) + ) + .subscribe({ + next: success => { + this.isUpdatingVideo = false + this.loadingBar.useRef().complete() + + if (!success) return + + this.updateDone = true + this.notifier.success($localize`Video updated.`) + this.router.navigateByUrl(Video.buildWatchUrl(this.videoEdit)) + } + }) + + this.replaceFileIfNeeded() } hydratePluginFieldsFromVideo () { @@ -172,4 +243,118 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { getVideoUrl () { return Video.buildWatchUrl(this.videoDetails) } + + private async checkAndConfirmVideoFileReplacement () { + const replaceFile: File = this.form.value['replaceFile'] + if (!replaceFile) return true + + const user = this.auth.getUser() + if (!this.videoUploadService.checkQuotaAndNotify(replaceFile, user.videoQuota, this.userVideoQuotaUsed)) return + if (!this.videoUploadService.checkQuotaAndNotify(replaceFile, user.videoQuotaDaily, this.userVideoQuotaUsedDaily)) return + + const willBeBlocked = this.server.getHTMLConfig().autoBlacklist.videos.ofUsers.enabled === true && !this.videoDetails.blacklisted + let blockedWarning = '' + if (willBeBlocked) { + // eslint-disable-next-line max-len + blockedWarning = ' ' + $localize`Your video will also be automatically blocked since video publication requires manual validation by moderators.` + } + + const message = $localize`Uploading a new version of your video will completely erase the current version.` + + blockedWarning + + ' ' + + $localize`

Do you still want to replace your video file?` + + const res = await this.confirmService.confirm(message, $localize`Replace file warning`) + if (res === false) return false + + return true + } + + private replaceFileIfNeeded () { + if (!this.form.value['replaceFile']) { + this.videoReplacementUploadedSubject.next() + return + } + + this.uploadFileReplacement(this.form.value['replaceFile']) + } + + private uploadFileReplacement (file: File) { + const metadata = { + filename: file.name + } + + this.resumableUploadService.handleFiles(file, { + ...this.videoUploadService.getReplaceUploadxOptions(this.videoDetails.uuid), + + metadata + }) + + this.isReplacingVideoFile = true + } + + onUploadVideoOngoing (state: UploadState) { + debugLogger('Upload state update', state) + + switch (state.status) { + case 'error': { + if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) { + this.alreadyRefreshedToken = true + + return this.refreshTokenAndRetryUpload() + } + + this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state)) + break + } + + case 'cancelled': + this.isReplacingVideoFile = false + this.videoUploadPercents = 0 + this.uploadError = '' + break + + case 'uploading': + this.videoUploadPercents = state.progress || 0 + break + + case 'complete': + this.isReplacingVideoFile = false + this.videoReplacementUploadedSubject.next() + this.videoUploadPercents = 100 + break + } + } + + cancelUpload () { + debugLogger('Cancelling upload') + + this.resumableUploadService.control({ action: 'cancel' }) + + this.abortUpdateIfNeeded() + } + + private handleUploadError (err: HttpErrorResponse) { + this.videoUploadPercents = 0 + this.isReplacingVideoFile = false + + this.uploadError = genericUploadErrorHandler({ err, name: $localize`video` }) + + this.videoReplacementUploadedSubject.error(err) + } + + private refreshTokenAndRetryUpload () { + this.auth.refreshAccessToken() + .subscribe(() => this.uploadFileReplacement(this.form.value['replaceFile'])) + } + + private abortUpdateIfNeeded () { + if (this.updateSubcription) { + this.updateSubcription.unsubscribe() + this.updateSubcription = undefined + } + + this.videoReplacementUploadedSubject = new Subject() + this.loadingBar.useRef().complete() + } } 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 @@
+
+ Video re-upload + {{ video.inputFileUpdatedAt | date: 'short' }} +
+
Originally published {{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }} 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' function genericUploadErrorHandler (options: { err: Pick name: string - notifier: Notifier + notifier?: Notifier sticky?: boolean }) { const { err, name, notifier, sticky = false } = options const title = $localize`Upload failed` const message = buildMessage(name, err) - notifier.error(message, title, null, sticky) + if (notifier) notifier.error(message, title, null, sticky) + return message } 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 @@
-
+
{{ inputLabel }} 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 @@ .button-file { @include peertube-button-file(auto); - @include grey-button; &.with-icon { @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 @@ -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' +import { Component, EventEmitter, forwardRef, Input, OnChanges, OnInit, Output } from '@angular/core' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { Notifier } from '@app/core' import { GlobalIconName } from '@app/shared/shared-icons' @@ -15,7 +15,8 @@ import { GlobalIconName } from '@app/shared/shared-icons' } ] }) -export class ReactiveFileComponent implements OnInit, ControlValueAccessor { +export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAccessor { + @Input() theme: 'primary' | 'secondary' = 'secondary' @Input() inputLabel: string @Input() inputName: string @Input() extensions: string[] = [] @@ -29,6 +30,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor { @Output() fileChanged = new EventEmitter() + classes: { [id: string]: boolean } = {} allowedExtensionsMessage = '' fileInputValue: any @@ -44,6 +46,20 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor { ngOnInit () { this.allowedExtensionsMessage = this.extensions.join(', ') + + this.buildClasses() + } + + ngOnChanges () { + this.buildClasses() + } + + buildClasses () { + this.classes = { + 'with-icon': !!this.icon, + 'orange-button': this.theme === 'primary', + 'grey-button': this.theme === 'secondary' + } } fileChange (event: any) { 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 { trackerUrls: string[] + inputFileUpdatedAt: Date | string + files: VideoFile[] streamingPlaylists: VideoStreamingPlaylist[] @@ -41,6 +43,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { this.commentsEnabled = hash.commentsEnabled this.downloadEnabled = hash.downloadEnabled + this.inputFileUpdatedAt = hash.inputFileUpdatedAt + this.trackerUrls = hash.trackerUrls this.buildLikeAndDislikePercents() 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 { updatedAt: Date publishedAt: Date originallyPublishedAt: Date | string + category: VideoConstant licence: VideoConstant language: VideoConstant 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 { suspendedMessage: HTMLElement nextButton: HTMLElement + private timeout: any + private onEndedHandler: () => void private onPlayingHandler: () => void @@ -84,6 +86,8 @@ class EndCard extends Component { if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler) if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler) + if (this.timeout) clearTimeout(this.timeout) + super.dispose() } @@ -114,8 +118,6 @@ class EndCard extends Component { } showCard (cb: (canceled: boolean) => void) { - let timeout: any - this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`) @@ -126,17 +128,20 @@ class EndCard extends Component { } this.upNextEvents.one('cancel', () => { - clearTimeout(timeout) + clearTimeout(this.timeout) + this.timeout = undefined cb(true) }) this.upNextEvents.one('playing', () => { - clearTimeout(timeout) + clearTimeout(this.timeout) + this.timeout = undefined cb(true) }) this.upNextEvents.one('next', () => { - clearTimeout(timeout) + clearTimeout(this.timeout) + this.timeout = undefined cb(false) }) @@ -154,19 +159,20 @@ class EndCard extends Component { this.suspendedMessage.innerText = this.options_.suspendedText goToPercent(0) this.ticks = 0 - timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer + this.timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer } else if (this.ticks >= this.totalTicks) { - clearTimeout(timeout) + clearTimeout(this.timeout) + this.timeout = undefined cb(false) } else { this.suspendedMessage.innerText = '' tick() - timeout = setTimeout(update.bind(this), this.interval) + this.timeout = setTimeout(update.bind(this), this.interval) } } this.container.style.display = 'block' - timeout = setTimeout(update.bind(this), this.interval) + this.timeout = setTimeout(update.bind(this), this.interval) } } 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' import { VideoModel } from '@server/models/video/video' import { VideoSourceModel } from '@server/models/video/video-source' import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' -import { HttpStatusCode, VideoState } from '@shared/models' +import { VideoState } from '@shared/models' import { logger, loggerTagsFactory } from '../../../helpers/logger' import { asyncMiddleware, @@ -121,7 +121,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists }) - await VideoSourceModel.create({ + const source = await VideoSourceModel.create({ filename: originalFilename, videoId: video.id, createdAt: inputFileUpdatedAt @@ -135,7 +135,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R Hooks.runAction('action:api.video.file-updated', { video, req, res }) - return res.sendStatus(HttpStatusCode.NO_CONTENT_204) + return res.json(source.toFormattedJSON()) } finally { videoFileMutexReleaser() } 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 { path: string attributes: { fixture?: string } & { [id: string]: any } }): Promise { - const { path, attributes, expectedStatus } = options + const { path, attributes, expectedStatus = HttpStatusCode.OK_200 } = options let size = 0 let videoFilePath: string @@ -597,43 +597,47 @@ export class VideosCommand extends AbstractCommand { const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 }) return new Promise>((resolve, reject) => { readable.on('data', async function onData (chunk) { - readable.pause() + try { + readable.pause() - const headers = { - 'Authorization': 'Bearer ' + token, - 'Content-Type': 'application/octet-stream', - 'Content-Range': contentRangeBuilder - ? contentRangeBuilder(start, chunk) - : `bytes ${start}-${start + chunk.length - 1}/${size}`, - 'Content-Length': contentLength ? contentLength + '' : chunk.length + '' + const headers = { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/octet-stream', + 'Content-Range': contentRangeBuilder + ? contentRangeBuilder(start, chunk) + : `bytes ${start}-${start + chunk.length - 1}/${size}`, + 'Content-Length': contentLength ? contentLength + '' : chunk.length + '' + } + + if (digestBuilder) { + Object.assign(headers, { digest: digestBuilder(chunk) }) + } + + const res = await got<{ video: VideoCreateResult }>({ + url, + method: 'put', + headers, + path: path + '?' + pathUploadId, + body: chunk, + responseType: 'json', + throwHttpErrors: false + }) + + start += chunk.length + + if (res.statusCode === expectedStatus) { + return resolve(res) + } + + if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) { + readable.off('data', onData) + return reject(new Error('Incorrect transient behaviour sending intermediary chunks')) + } + + readable.resume() + } catch (err) { + reject(err) } - - if (digestBuilder) { - Object.assign(headers, { digest: digestBuilder(chunk) }) - } - - const res = await got<{ video: VideoCreateResult }>({ - url, - method: 'put', - headers, - path: path + '?' + pathUploadId, - body: chunk, - responseType: 'json', - throwHttpErrors: false - }) - - start += chunk.length - - if (res.statusCode === expectedStatus) { - return resolve(res) - } - - if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) { - readable.off('data', onData) - return reject(new Error('Incorrect transient behaviour sending intermediary chunks')) - } - - readable.resume() }) }) } @@ -695,8 +699,7 @@ export class VideosCommand extends AbstractCommand { ...options, path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable', - attributes: { fixture: options.fixture }, - expectedStatus: HttpStatusCode.NO_CONTENT_204 + attributes: { fixture: options.fixture } }) }