</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>
enabled: null
}
},
+ videoFile: {
+ update: {
+ enabled: null
+ }
+ },
autoBlacklist: {
videos: {
ofUsers: {
--- /dev/null
+<!-- 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>
--- /dev/null
+@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);
+ }
+ }
+ }
+}
--- /dev/null
+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()
+}
<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
<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>
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;
@Input() videoSource: VideoSource
@Input() hideWaitTranscoding = false
+ @Input() updateVideoFileEnabled = false
@Input() type: VideoEditType
@Input() liveVideo: LiveVideo
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: [
declarations: [
VideoEditComponent,
VideoCaptionAddModalComponent,
- VideoCaptionEditModalContentComponent
+ VideoCaptionEditModalContentComponent,
+ UploadProgressComponent
],
exports: [
SharedFormModule,
SharedGlobalIconModule,
- VideoEditComponent
+ VideoEditComponent,
+ UploadProgressComponent
],
providers: [
- I18nPrimengCalendarService
+ I18nPrimengCalendarService,
+ VideoUploadService
]
})
export class VideoEditModule { }
--- /dev/null
+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
+ }
+ }
+}
<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"
</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.
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);
- }
- }
- }
-}
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',
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
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()
}
}
+ 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
}
if (!file) return
- if (!this.checkGlobalUserQuota(file)) return
- if (!this.checkDailyUserQuota(file)) return
+ const user = this.authService.getUser()
+
+ if (!this.videoUploadService.checkQuotaAndNotify(file, user.videoQuota, this.userVideoQuotaUsed)) return
+ if (!this.videoUploadService.checkQuotaAndNotify(file, user.videoQuotaDaily, this.userVideoQuotaUsedDaily)) return
- if (this.isAudioFile(file.name)) {
+ if (this.videoUploadService.isAudioFile(file.name)) {
this.isUploadingAudioFile = true
return
}
}
this.resumableUploadService.handleFiles(file, {
- ...this.getUploadxOptions(),
+ ...this.videoUploadService.getNewUploadxOptions(),
metadata
})
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
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)
- }
- }
- }
- }
}
<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
[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"
-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
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,
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
this.forbidScheduledPublication = this.videoEdit.privacy !== VideoPrivacy.PRIVATE
}
+ ngOnDestroy () {
+ this.resumableUploadService.disconnect()
+
+ if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe()
+ }
+
onFormBuilt () {
hydrateFormFromVideo(this.form, this.videoEdit, true)
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) {
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)),
-
- 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 = pick(this.liveVideo, Object.keys(liveVideoUpdate) as (keyof LiveVideoUpdate)[])
- const liveChanged = !simpleObjectsDeepEqual(baseVideo, liveVideoUpdate)
- if (!liveChanged) return of(undefined)
-
- 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))
- },
-
- error: err => {
- this.loadingBar.useRef().complete()
- this.isUpdatingVideo = false
- this.notifier.error(err.message)
- logger.error(err)
- }
- })
+ this.updateSubcription = this.videoReplacementUploadedSubject.pipe(
+ switchMap(() => this.videoService.updateVideo(this.videoEdit)),
+
+ // Then update captions
+ switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)),
+
+ 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 = pick(this.liveVideo, Object.keys(liveVideoUpdate) as (keyof LiveVideoUpdate)[])
+ 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 () {
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()
+ }
}
</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>
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
}
<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>
.button-file {
@include peertube-button-file(auto);
- @include grey-button;
&.with-icon {
@include button-with-icon;
-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'
}
]
})
-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[] = []
@Output() fileChanged = new EventEmitter<Blob>()
+ classes: { [id: string]: boolean } = {}
allowedExtensionsMessage = ''
fileInputValue: any
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) {
trackerUrls: string[]
+ inputFileUpdatedAt: Date | string
+
files: VideoFile[]
streamingPlaylists: VideoStreamingPlaylist[]
this.commentsEnabled = hash.commentsEnabled
this.downloadEnabled = hash.downloadEnabled
+ this.inputFileUpdatedAt = hash.inputFileUpdatedAt
+
this.trackerUrls = hash.trackerUrls
this.buildLikeAndDislikePercents()
updatedAt: Date
publishedAt: Date
originallyPublishedAt: Date | string
+
category: VideoConstant<number>
licence: VideoConstant<number>
language: VideoConstant<string>
suspendedMessage: HTMLElement
nextButton: HTMLElement
+ private timeout: any
+
private onEndedHandler: () => void
private onPlayingHandler: () => void
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()
}
}
showCard (cb: (canceled: boolean) => void) {
- let timeout: any
-
this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`)
this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`)
}
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)
})
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)
}
}
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,
await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })
- await VideoSourceModel.create({
+ const source = await VideoSourceModel.create({
filename: originalFilename,
videoId: video.id,
createdAt: inputFileUpdatedAt
Hooks.runAction('action:api.video.file-updated', { video, req, res })
- return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+ return res.json(source.toFormattedJSON())
} finally {
videoFileMutexReleaser()
}
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
const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
readable.on('data', async function onData (chunk) {
- 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 + ''
+ 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 + ''
+ }
+
+ 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()
})
})
}
...options,
path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable',
- attributes: { fixture: options.fixture },
- expectedStatus: HttpStatusCode.NO_CONTENT_204
+ attributes: { fixture: options.fixture }
})
}