Implement video file replacement in client

This commit is contained in:
Chocobozzz 2023-07-21 17:46:37 +02:00
parent 12dc3a942a
commit f42fcb4b58
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
26 changed files with 603 additions and 278 deletions

View File

@ -345,6 +345,18 @@
</ng-container>
</ng-container>
<ng-container formGroupName="videoFile">
<ng-container formGroupName="update">
<div class="form-group">
<my-peertube-checkbox
inputName="videoFileUpdateEnabled" formControlName="enabled"
i18n-labelText labelText="Allow users to upload a new version of their video"
>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>

View File

@ -225,6 +225,11 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
enabled: null
}
},
videoFile: {
update: {
enabled: null
}
},
autoBlacklist: {
videos: {
ofUsers: {

View File

@ -0,0 +1,32 @@
<!-- Upload progress/cancel/error/success header -->
<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel">
<div class="progress" i18n-title title="Total video uploaded">
<div
class="progress-bar" role="progressbar"
[style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100"
>
<span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span>
<span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
</div>
</div>
<input
*ngIf="videoUploaded === false"
type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancel.emit()"
/>
</div>
<div *ngIf="error && enableRetryAfterError" class="upload-progress-retry">
<div class="progress">
<div class="progress-bar red" role="progressbar" [style]="{ width: '100%' }" [attr.aria-valuenow]="100" aria-valuemin="0" [attr.aria-valuemax]="100">
<span>{{ error }}</span>
</div>
</div>
<input type="button" class="peertube-button grey-button ms-1" i18n-value="Retry failed upload of a video" value="Retry" (click)="retry.emit()" />
<input type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancel.emit()" />
</div>
<div *ngIf="error && !enableRetryAfterError" class="alert alert-danger">
<div i18n>Sorry, but something went wrong</div>
{{ error }}
</div>

View File

@ -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);
}
}
}
}

View File

@ -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()
}

View File

@ -124,7 +124,7 @@
<label i18n for="videoPassword">Password</label>
<my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text>
</div>
<div *ngIf="schedulePublicationSelected" class="form-group">
<label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
<p-calendar
@ -320,6 +320,8 @@
<div class="row advanced-settings">
<div class="col-md-12 col-xl-8">
<ng-content></ng-content>
<div class="form-group">
<label i18n for="previewfile">Video thumbnail</label>

View File

@ -112,6 +112,11 @@ p-calendar {
grid-gap: 30px;
}
.button-file {
@include peertube-button-file(max-content);
@include orange-button;
}
@include on-small-main-col {
.form-columns {
grid-template-columns: 1fr;

View File

@ -68,6 +68,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
@Input() videoSource: VideoSource
@Input() hideWaitTranscoding = false
@Input() updateVideoFileEnabled = false
@Input() type: VideoEditType
@Input() liveVideo: LiveVideo

View File

@ -5,9 +5,11 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { UploadProgressComponent } from './upload-progress.component'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
import { VideoEditComponent } from './video-edit.component'
import { VideoUploadService } from './video-upload.service'
@NgModule({
imports: [
@ -22,7 +24,8 @@ import { VideoEditComponent } from './video-edit.component'
declarations: [
VideoEditComponent,
VideoCaptionAddModalComponent,
VideoCaptionEditModalContentComponent
VideoCaptionEditModalContentComponent,
UploadProgressComponent
],
exports: [
@ -32,11 +35,13 @@ import { VideoEditComponent } from './video-edit.component'
SharedFormModule,
SharedGlobalIconModule,
VideoEditComponent
VideoEditComponent,
UploadProgressComponent
],
providers: [
I18nPrimengCalendarService
I18nPrimengCalendarService,
VideoUploadService
]
})
export class VideoEditModule { }

View File

@ -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
}
}
}

View File

@ -2,13 +2,13 @@
<div class="first-step-block">
<my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
<div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'">
<div class="button-file form-control" [ngbTooltip]="'(extensions: ' + getVideoExtensions() + ')'">
<span i18n>Select the file to upload</span>
<input
aria-label="Select the file to upload"
i18n-aria-label
#videofileInput
[accept]="videoExtensions"
[accept]="getVideoExtensions()"
(change)="onFileChange($event)"
id="videofile"
type="file"
@ -58,35 +58,11 @@
</div>
</div>
<!-- Upload progress/cancel/error/success header -->
<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel">
<div class="progress" i18n-title title="Total video uploaded">
<div class="progress-bar" role="progressbar" [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100">
<span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span>
<span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
</div>
</div>
<input
*ngIf="videoUploaded === false"
type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()"
/>
</div>
<div *ngIf="error && enableRetryAfterError" class="upload-progress-retry">
<div class="progress">
<div class="progress-bar red" role="progressbar" [style]="{ width: '100%' }" [attr.aria-valuenow]="100" aria-valuemin="0" [attr.aria-valuemax]="100">
<span>{{ error }}</span>
</div>
</div>
<input type="button" class="peertube-button grey-button ms-1" i18n-value="Retry failed upload of a video" value="Retry" (click)="retryUpload()" />
<input type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" />
</div>
<div *ngIf="error && !enableRetryAfterError" class="alert alert-danger">
<div i18n>Sorry, but something went wrong</div>
{{ error }}
</div>
<my-upload-progress
[isUploadingVideo]="isUploadingVideo" [videoUploadPercents]="videoUploadPercents" [error]="error" [videoUploaded]="videoUploaded"
[enableRetryAfterError]="enableRetryAfterError" (cancel)="cancelUpload()" (retry)="retryUpload()"
>
</my-upload-progress>
<div *ngIf="videoUploaded && !error" class="alert pt-alert-primary" i18n>
Congratulations! Your video is now available in your private library.

View File

@ -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);
}
}
}
}

View File

@ -1,19 +1,18 @@
import { truncate } from 'lodash-es'
import { UploadState, UploadxOptions, UploadxService } from 'ngx-uploadx'
import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
import { UploadState, UploadxService } from 'ngx-uploadx'
import { Subscription } from 'rxjs'
import { HttpErrorResponse } from '@angular/common/http'
import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
import { FormReactiveService } from '@app/shared/shared-forms'
import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { logger } from '@root-helpers/logger'
import { isIOS } from '@root-helpers/web-browser'
import { HttpStatusCode, VideoCreateResult } from '@shared/models'
import { UploaderXFormData } from './uploaderx-form-data'
import { VideoUploadService } from '../shared/video-upload.service'
import { VideoSend } from './video-send'
import { Subscription } from 'rxjs'
@Component({
selector: 'my-video-upload',
@ -49,9 +48,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
error: string
enableRetryAfterError: boolean
// So that it can be accessed in the template
protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + '/upload-resumable'
private isUpdatingVideo = false
private fileToUpload: File
@ -72,15 +68,12 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
private hooks: HooksService,
private resumableUploadService: UploadxService,
private metaService: MetaService,
private route: ActivatedRoute
private route: ActivatedRoute,
private videoUploadService: VideoUploadService
) {
super()
}
get videoExtensions () {
return this.serverConfig.video.file.extensions.join(', ')
}
ngOnInit () {
super.ngOnInit()
@ -133,28 +126,20 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
}
}
getVideoExtensions () {
return this.videoUploadService.getVideoExtensions().join(', ')
}
onUploadVideoOngoing (state: UploadState) {
switch (state.status) {
case 'error': {
if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) {
this.alreadyRefreshedToken = true
return this.refereshTokenAndRetryUpload()
return this.refreshTokenAndRetryUpload()
}
const error = state.response?.error?.message || state.response?.error || 'Unknown error'
this.handleUploadError({
error: new Error(error),
name: 'HttpErrorResponse',
message: error,
ok: false,
headers: new HttpHeaders(state.responseHeaders),
status: +state.responseStatus,
statusText: error,
type: HttpEventType.Response,
url: state.url
})
this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state))
break
}
@ -203,10 +188,12 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
if (!file) return
if (!this.checkGlobalUserQuota(file)) return
if (!this.checkDailyUserQuota(file)) return
const user = this.authService.getUser()
if (this.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)
}
}
}
}
}

View File

@ -4,6 +4,12 @@
<a [routerLink]="getVideoUrl()">{{ videoDetails?.name }}</a>
</div>
<my-upload-progress
[isUploadingVideo]="isReplacingVideoFile" [videoUploadPercents]="videoUploadPercents" [error]="uploadError" [videoUploaded]="updateDone"
[enableRetryAfterError]="false" (cancel)="cancelUpload()"
>
</my-upload-progress>
<form novalidate [formGroup]="form">
<my-video-edit
@ -12,10 +18,27 @@
[videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()"
type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()"
[liveVideo]="liveVideo" [videoToUpdate]="videoDetails"
[videoSource]="videoSource"
[videoSource]="videoSource" [updateVideoFileEnabled]="isUpdateVideoFileEnabled()"
(formBuilt)="onFormBuilt()"
></my-video-edit>
>
<div *ngIf="isUpdateVideoFileEnabled()" class="form-group">
<label class="mb-0" i18n for="videofile">Replace video file</label>
<div i18n class="form-group-description">⚠️ Uploading a new version of your video will completely erase the current version</div>
<div>
<my-reactive-file
formControlName="replaceFile"
i18n-inputLabel inputLabel="Select the file to upload"
inputName="videofile" [extensions]="getVideoExtensions()" [displayFilename]="true" [displayReset]="true"
[buttonTooltip]="'(extensions: ' + getVideoExtensions() + ')'"
theme="primary"
></my-reactive-file>
</div>
</div>
</my-video-edit>
<div class="submit-container">
<my-button className="orange-button" i18n-label label="Update" icon="circle-tick"

View File

@ -1,25 +1,31 @@
import { of } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import debug from 'debug'
import { UploadState, UploadxService } from 'ngx-uploadx'
import { of, Subject, Subscription } from 'rxjs'
import { catchError, map, switchMap } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model'
import { Component, HostListener, OnInit } from '@angular/core'
import { HttpErrorResponse } from '@angular/common/http'
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { Notifier } from '@app/core'
import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LiveVideoService } from '@app/shared/shared-video-live'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { logger } from '@root-helpers/logger'
import { pick, simpleObjectsDeepEqual } from '@shared/core-utils'
import { LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models'
import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models'
import { VideoSource } from '@shared/models/videos/video-source'
import { hydrateFormFromVideo } from './shared/video-edit-utils'
import { VideoUploadService } from './shared/video-upload.service'
const debugLogger = debug('peertube:video-update')
@Component({
selector: 'my-videos-update',
styleUrls: [ './shared/video-edit.component.scss' ],
templateUrl: './video-update.component.html'
})
export class VideoUpdateComponent extends FormReactive implements OnInit {
export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
videoEdit: VideoEdit
videoDetails: VideoDetails
videoSource: VideoSource
@ -27,10 +33,23 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
videoCaptions: VideoCaptionEdit[] = []
liveVideo: LiveVideo
userVideoQuotaUsed = 0
userVideoQuotaUsedDaily = 0
isUpdatingVideo = false
forbidScheduledPublication = false
private updateDone = false
isReplacingVideoFile = false
videoUploadPercents: number
uploadError: string
updateDone = false
private videoReplacementUploadedSubject = new Subject<void>()
private alreadyRefreshedToken = false
private uploadServiceSubscription: Subscription
private updateSubcription: Subscription
constructor (
protected formReactiveService: FormReactiveService,
@ -40,13 +59,30 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
private videoService: VideoService,
private loadingBar: LoadingBarService,
private videoCaptionService: VideoCaptionService,
private liveVideoService: LiveVideoService
private server: ServerService,
private liveVideoService: LiveVideoService,
private videoUploadService: VideoUploadService,
private confirmService: ConfirmService,
private auth: AuthService,
private userService: UserService,
private resumableUploadService: UploadxService
) {
super()
}
ngOnInit () {
this.buildForm({})
this.buildForm({
replaceFile: null
})
this.userService.getMyVideoQuotaUsed()
.subscribe(data => {
this.userVideoQuotaUsed = data.videoQuotaUsed
this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily
})
this.uploadServiceSubscription = this.resumableUploadService.events
.subscribe(state => this.onUploadVideoOngoing(state))
const { videoData } = this.route.snapshot.data
const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData
@ -62,6 +98,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
this.forbidScheduledPublication = this.videoEdit.privacy !== VideoPrivacy.PRIVATE
}
ngOnDestroy () {
this.resumableUploadService.disconnect()
if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe()
}
onFormBuilt () {
hydrateFormFromVideo(this.form, this.videoEdit, true)
@ -88,6 +130,13 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
canDeactivate (): { canDeactivate: boolean, text?: string } {
if (this.updateDone === true) return { canDeactivate: true }
if (this.isUpdatingVideo) {
return {
canDeactivate: false,
text: $localize`Your video is currenctly being updated. If you leave, your changes will be lost.`
}
}
const text = $localize`You have unsaved changes! If you leave, your changes will be lost.`
for (const caption of this.videoCaptions) {
@ -97,68 +146,90 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
return { canDeactivate: this.formChanged === false, text }
}
getVideoExtensions () {
return this.videoUploadService.getVideoExtensions()
}
isWaitTranscodingHidden () {
return this.videoDetails.state.id !== VideoState.TO_TRANSCODE
}
isUpdateVideoFileEnabled () {
if (!this.server.getHTMLConfig().videoFile.update.enabled) return false
if (this.videoDetails.isLive) return false
if (this.videoDetails.state.id !== VideoState.PUBLISHED) return false
return true
}
async update () {
await this.waitPendingCheck()
this.forceCheck()
if (!this.form.valid || this.isUpdatingVideo === true) {
return
}
if (!this.form.valid || this.isUpdatingVideo === true) return
// Check and warn users about a file replacement
if (!await this.checkAndConfirmVideoFileReplacement()) return
this.videoEdit.patch(this.form.value)
this.abortUpdateIfNeeded()
this.loadingBar.useRef().start()
this.isUpdatingVideo = true
// Update the video
this.videoService.updateVideo(this.videoEdit)
.pipe(
// Then update captions
switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)),
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`<br /><br />Do you still want to replace your video file?`
const res = await this.confirmService.confirm(message, $localize`Replace file warning`)
if (res === false) return false
return true
}
private replaceFileIfNeeded () {
if (!this.form.value['replaceFile']) {
this.videoReplacementUploadedSubject.next()
return
}
this.uploadFileReplacement(this.form.value['replaceFile'])
}
private uploadFileReplacement (file: File) {
const metadata = {
filename: file.name
}
this.resumableUploadService.handleFiles(file, {
...this.videoUploadService.getReplaceUploadxOptions(this.videoDetails.uuid),
metadata
})
this.isReplacingVideoFile = true
}
onUploadVideoOngoing (state: UploadState) {
debugLogger('Upload state update', state)
switch (state.status) {
case 'error': {
if (!this.alreadyRefreshedToken && state.responseStatus === HttpStatusCode.UNAUTHORIZED_401) {
this.alreadyRefreshedToken = true
return this.refreshTokenAndRetryUpload()
}
this.handleUploadError(this.videoUploadService.buildHTTPErrorResponse(state))
break
}
case 'cancelled':
this.isReplacingVideoFile = false
this.videoUploadPercents = 0
this.uploadError = ''
break
case 'uploading':
this.videoUploadPercents = state.progress || 0
break
case 'complete':
this.isReplacingVideoFile = false
this.videoReplacementUploadedSubject.next()
this.videoUploadPercents = 100
break
}
}
cancelUpload () {
debugLogger('Cancelling upload')
this.resumableUploadService.control({ action: 'cancel' })
this.abortUpdateIfNeeded()
}
private handleUploadError (err: HttpErrorResponse) {
this.videoUploadPercents = 0
this.isReplacingVideoFile = false
this.uploadError = genericUploadErrorHandler({ err, name: $localize`video` })
this.videoReplacementUploadedSubject.error(err)
}
private refreshTokenAndRetryUpload () {
this.auth.refreshAccessToken()
.subscribe(() => this.uploadFileReplacement(this.form.value['replaceFile']))
}
private abortUpdateIfNeeded () {
if (this.updateSubcription) {
this.updateSubcription.unsubscribe()
this.updateSubcription = undefined
}
this.videoReplacementUploadedSubject = new Subject<void>()
this.loadingBar.useRef().complete()
}
}

View File

@ -18,6 +18,11 @@
</a>
</div>
<div *ngIf="!!video.inputFileUpdatedAt" class="attribute attribute-re-uploaded-on">
<span i18n class="attribute-label">Video re-upload</span>
<span class="attribute-value">{{ video.inputFileUpdatedAt | date: 'short' }}</span>
</div>
<div *ngIf="!!video.originallyPublishedAt" class="attribute attribute-originally-published-at">
<span i18n class="attribute-label">Originally published</span>
<span class="attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>

View File

@ -5,14 +5,15 @@ import { HttpStatusCode } from '@shared/models'
function genericUploadErrorHandler (options: {
err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
name: string
notifier: Notifier
notifier?: Notifier
sticky?: boolean
}) {
const { err, name, notifier, sticky = false } = options
const title = $localize`Upload failed`
const message = buildMessage(name, err)
notifier.error(message, title, null, sticky)
if (notifier) notifier.error(message, title, null, sticky)
return message
}

View File

@ -1,5 +1,5 @@
<div class="root">
<div class="button-file" [ngClass]="{ 'with-icon': !!icon }" [ngbTooltip]="buttonTooltip">
<div class="button-file" [ngClass]="classes" [ngbTooltip]="buttonTooltip">
<my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
<span>{{ inputLabel }}</span>

View File

@ -8,7 +8,6 @@
.button-file {
@include peertube-button-file(auto);
@include grey-button;
&.with-icon {
@include button-with-icon;

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
import { Component, EventEmitter, forwardRef, Input, OnChanges, OnInit, Output } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { Notifier } from '@app/core'
import { GlobalIconName } from '@app/shared/shared-icons'
@ -15,7 +15,8 @@ import { GlobalIconName } from '@app/shared/shared-icons'
}
]
})
export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAccessor {
@Input() theme: 'primary' | 'secondary' = 'secondary'
@Input() inputLabel: string
@Input() inputName: string
@Input() extensions: string[] = []
@ -29,6 +30,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
@Output() fileChanged = new EventEmitter<Blob>()
classes: { [id: string]: boolean } = {}
allowedExtensionsMessage = ''
fileInputValue: any
@ -44,6 +46,20 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
ngOnInit () {
this.allowedExtensionsMessage = this.extensions.join(', ')
this.buildClasses()
}
ngOnChanges () {
this.buildClasses()
}
buildClasses () {
this.classes = {
'with-icon': !!this.icon,
'orange-button': this.theme === 'primary',
'grey-button': this.theme === 'secondary'
}
}
fileChange (event: any) {

View File

@ -27,6 +27,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
trackerUrls: string[]
inputFileUpdatedAt: Date | string
files: VideoFile[]
streamingPlaylists: VideoStreamingPlaylist[]
@ -41,6 +43,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
this.commentsEnabled = hash.commentsEnabled
this.downloadEnabled = hash.downloadEnabled
this.inputFileUpdatedAt = hash.inputFileUpdatedAt
this.trackerUrls = hash.trackerUrls
this.buildLikeAndDislikePercents()

View File

@ -26,6 +26,7 @@ export class Video implements VideoServerModel {
updatedAt: Date
publishedAt: Date
originallyPublishedAt: Date | string
category: VideoConstant<number>
licence: VideoConstant<number>
language: VideoConstant<string>

View File

@ -48,6 +48,8 @@ class EndCard extends Component {
suspendedMessage: HTMLElement
nextButton: HTMLElement
private timeout: any
private onEndedHandler: () => void
private onPlayingHandler: () => void
@ -84,6 +86,8 @@ class EndCard extends Component {
if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler)
if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler)
if (this.timeout) clearTimeout(this.timeout)
super.dispose()
}
@ -114,8 +118,6 @@ class EndCard extends Component {
}
showCard (cb: (canceled: boolean) => void) {
let timeout: any
this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`)
this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`)
@ -126,17 +128,20 @@ class EndCard extends Component {
}
this.upNextEvents.one('cancel', () => {
clearTimeout(timeout)
clearTimeout(this.timeout)
this.timeout = undefined
cb(true)
})
this.upNextEvents.one('playing', () => {
clearTimeout(timeout)
clearTimeout(this.timeout)
this.timeout = undefined
cb(true)
})
this.upNextEvents.one('next', () => {
clearTimeout(timeout)
clearTimeout(this.timeout)
this.timeout = undefined
cb(false)
})
@ -154,19 +159,20 @@ class EndCard extends Component {
this.suspendedMessage.innerText = this.options_.suspendedText
goToPercent(0)
this.ticks = 0
timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer
this.timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer
} else if (this.ticks >= this.totalTicks) {
clearTimeout(timeout)
clearTimeout(this.timeout)
this.timeout = undefined
cb(false)
} else {
this.suspendedMessage.innerText = ''
tick()
timeout = setTimeout(update.bind(this), this.interval)
this.timeout = setTimeout(update.bind(this), this.interval)
}
}
this.container.style.display = 'block'
timeout = setTimeout(update.bind(this), this.interval)
this.timeout = setTimeout(update.bind(this), this.interval)
}
}

View File

@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc'
import { VideoModel } from '@server/models/video/video'
import { VideoSourceModel } from '@server/models/video/video-source'
import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
import { HttpStatusCode, VideoState } from '@shared/models'
import { VideoState } from '@shared/models'
import { logger, loggerTagsFactory } from '../../../helpers/logger'
import {
asyncMiddleware,
@ -121,7 +121,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })
await VideoSourceModel.create({
const source = await VideoSourceModel.create({
filename: originalFilename,
videoId: video.id,
createdAt: inputFileUpdatedAt
@ -135,7 +135,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
Hooks.runAction('action:api.video.file-updated', { video, req, res })
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
return res.json(source.toFormattedJSON())
} finally {
videoFileMutexReleaser()
}

View File

@ -462,7 +462,7 @@ export class VideosCommand extends AbstractCommand {
path: string
attributes: { fixture?: string } & { [id: string]: any }
}): Promise<VideoCreateResult> {
const { path, attributes, expectedStatus } = options
const { path, attributes, expectedStatus = HttpStatusCode.OK_200 } = options
let size = 0
let videoFilePath: string
@ -597,43 +597,47 @@ export class VideosCommand extends AbstractCommand {
const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
readable.on('data', async function onData (chunk) {
readable.pause()
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 }
})
}