client/src/app/+videos/+video-edit/video-update.component.ts
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 { HttpErrorResponse } from '@angular/common/http'
import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
import { buildHTTPErrorResponse, genericUploadErrorHandler } from '@app/helpers'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { simpleObjectsDeepEqual } from '@peertube/peertube-core-utils'
import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models'
import { hydrateFormFromVideo } from './shared/video-edit-utils'
import { VideoUploadService } from './shared/video-upload.service'
import { VideoEditComponent } from './shared/video-edit.component'
import { ButtonComponent } from '../../shared/shared-main/buttons/button.component'
import { ReactiveFileComponent } from '../../shared/shared-forms/reactive-file.component'
import { NgIf } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { UploadProgressComponent } from '../../shared/standalone-upload/upload-progress.component'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { VideoEdit } from '@app/shared/shared-main/video/video-edit.model'
import { VideoCaptionEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model'
import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video-caption.service'
import { VideoChapterService } from '@app/shared/shared-main/video/video-chapter.service'
import { VideoChaptersEdit } from '@app/shared/shared-main/video/video-chapters-edit.model'
import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
const debugLogger = debug('peertube:video-update')
@Component({
selector: 'my-videos-update',
styleUrls: [ './shared/video-edit.component.scss' ],
templateUrl: './video-update.component.html',
standalone: true,
imports: [
RouterLink,
UploadProgressComponent,
FormsModule,
ReactiveFormsModule,
VideoEditComponent,
NgIf,
ReactiveFileComponent,
ButtonComponent
]
})
export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
@ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent
videoEdit: VideoEdit
videoDetails: VideoDetails
videoSource: VideoSource
userVideoChannels: SelectChannelItem[] = []
videoCaptions: VideoCaptionEdit[] = []
liveVideo: LiveVideo
userVideoQuotaUsed = 0
userVideoQuotaUsedDaily = 0
isUpdatingVideo = false
forbidScheduledPublication = false
isReplacingVideoFile = false
videoUploadPercents: number
uploadError: string
updateDone = false
private videoReplacementUploadedSubject = new Subject<void>()
private alreadyRefreshedToken = false
private uploadServiceSubscription: Subscription
private updateSubcription: Subscription
private chaptersEdit = new VideoChaptersEdit()
constructor (
protected formReactiveService: FormReactiveService,
private route: ActivatedRoute,
private router: Router,
private notifier: Notifier,
private videoService: VideoService,
private loadingBar: LoadingBarService,
private videoCaptionService: VideoCaptionService,
private videoChapterService: VideoChapterService,
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({
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, videoChapters, videoSource, liveVideo, videoPassword } = videoData
this.videoDetails = video
this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
this.chaptersEdit.loadFromAPI(videoChapters)
this.userVideoChannels = videoChannels
this.videoCaptions = videoCaptions
this.videoSource = videoSource
this.liveVideo = liveVideo
this.forbidScheduledPublication = this.videoEdit.privacy !== VideoPrivacy.PRIVATE
}
ngOnDestroy () {
this.resumableUploadService.disconnect()
if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe()
}
onFormBuilt () {
hydrateFormFromVideo(this.form, this.videoEdit, true)
setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit))
if (this.liveVideo) {
this.form.patchValue({
saveReplay: this.liveVideo.saveReplay,
replayPrivacy: this.liveVideo.replaySettings ? this.liveVideo.replaySettings.privacy : VideoPrivacy.PRIVATE,
latencyMode: this.liveVideo.latencyMode,
permanentLive: this.liveVideo.permanentLive
})
}
}
@HostListener('window:beforeunload', [ '$event' ])
onUnload (event: any) {
const { text, canDeactivate } = this.canDeactivate()
if (canDeactivate) return
event.returnValue = text
return text
}
canDeactivate (): { canDeactivate: boolean, text?: string } {
if (this.updateDone === true) return { canDeactivate: true }
if (this.isUpdatingVideo) {
return {
canDeactivate: false,
text: $localize`Your video is currently 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) {
if (caption.action) return { canDeactivate: false, text }
}
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
// Check and warn users about a file replacement
if (!await this.checkAndConfirmVideoFileReplacement()) return
this.videoEdit.patch(this.form.value)
this.chaptersEdit.patch(this.form.value)
this.abortUpdateIfNeeded()
this.loadingBar.useRef().start()
this.isUpdatingVideo = true
this.updateSubcription = this.videoReplacementUploadedSubject.pipe(
switchMap(() => this.videoService.updateVideo(this.videoEdit)),
switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.uuid, this.videoCaptions)),
switchMap(() => {
if (this.liveVideo) return of(true)
return this.videoChapterService.updateChapters(this.videoEdit.uuid, this.chaptersEdit)
}),
switchMap(() => {
if (!this.liveVideo) return of(undefined)
const saveReplay = !!this.form.value.saveReplay
const replaySettings = saveReplay
? { privacy: this.form.value.replayPrivacy }
: undefined
const liveVideoUpdate: LiveVideoUpdate = {
saveReplay,
replaySettings,
permanentLive: !!this.form.value.permanentLive,
latencyMode: this.form.value.latencyMode
}
// Don't update live attributes if they did not change
const baseVideo = {
saveReplay: this.liveVideo.saveReplay,
replaySettings: this.liveVideo.replaySettings,
permanentLive: this.liveVideo.permanentLive,
latencyMode: this.liveVideo.latencyMode
}
const liveChanged = !simpleObjectsDeepEqual(baseVideo, liveVideoUpdate)
if (!liveChanged) return of(undefined)
return this.liveVideoService.updateLive(this.videoEdit.id, liveVideoUpdate)
}),
map(() => true),
catchError(err => {
this.notifier.error(err.message)
return of(false)
})
)
.subscribe({
next: success => {
this.isUpdatingVideo = false
this.loadingBar.useRef().complete()
if (!success) return
this.updateDone = true
this.notifier.success($localize`Video updated.`)
this.router.navigateByUrl(Video.buildWatchUrl(this.videoEdit))
}
})
this.replaceFileIfNeeded()
}
hydratePluginFieldsFromVideo () {
if (!this.videoEdit.pluginData) return
this.form.patchValue({
pluginData: this.videoEdit.pluginData
})
}
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(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()
}
}