aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorkontrollanten <6680299+kontrollanten@users.noreply.github.com>2021-05-10 11:13:41 +0200
committerGitHub <noreply@github.com>2021-05-10 11:13:41 +0200
commitf6d6e7f861189a4446f406efb775a29688764b48 (patch)
treec3dda9958c3f189d4c39e8743c738d8c1fef4c2d
parentd29ced1a8582d99b776f664475a157adcf555d98 (diff)
downloadPeerTube-f6d6e7f861189a4446f406efb775a29688764b48.tar.gz
PeerTube-f6d6e7f861189a4446f406efb775a29688764b48.tar.zst
PeerTube-f6d6e7f861189a4446f406efb775a29688764b48.zip
Resumable video uploads (#3933)
* WIP: resumable video uploads relates to #324 * fix review comments * video upload: error handling * fix audio upload * fixes after self review * Update server/controllers/api/videos/index.ts Co-authored-by: Rigel Kent <par@rigelk.eu> * Update server/middlewares/validators/videos/videos.ts Co-authored-by: Rigel Kent <par@rigelk.eu> * Update server/controllers/api/videos/index.ts Co-authored-by: Rigel Kent <par@rigelk.eu> * update after code review * refactor upload route - restore multipart upload route - move resumable to dedicated upload-resumable route - move checks to middleware - do not leak internal fs structure in response * fix yarn.lock upon rebase * factorize addVideo for reuse in both endpoints * add resumable upload API to openapi spec * add initial test and test helper for resumable upload * typings for videoAddResumable middleware * avoid including aws and google packages via node-uploadx, by only including uploadx/core * rename ex-isAudioBg to more explicit name mentioning it is a preview file for audio * add video-upload-tmp-folder-cleaner job * stronger typing of video upload middleware * reduce dependency to @uploadx/core * add audio upload test * refactor resumable uploads cleanup from job to scheduler * refactor resumable uploads scheduler to compare to last execution time * make resumable upload validator to always cleanup on failure * move legacy upload request building outside of uploadVideo test helper * filter upload-resumable middlewares down to POST, PUT, DELETE also begin to type metadata * merge add duration functions * stronger typings and documentation for uploadx behaviour, move init validator up * refactor(client/video-edit): options > uploadxOptions * refactor(client/video-edit): remove obsolete else * scheduler/remove-dangling-resum: rename tag * refactor(server/video): add UploadVideoFiles type * refactor(mw/validators): restructure eslint disable * refactor(mw/validators/videos): rename import * refactor(client/vid-upload): rename html elem id * refactor(sched/remove-dangl): move fn to method * refactor(mw/async): add method typing * refactor(mw/vali/video): double quote > single * refactor(server/upload-resum): express use > all * proper http methud enum server/middlewares/async.ts * properly type http methods * factorize common video upload validation steps * add check for maximum partially uploaded file size * fix audioBg use * fix extname(filename) in addVideo * document parameters for uploadx's resumable protocol * clear META files in scheduler * last audio refactor before cramming preview in the initial POST form data * refactor as mulitpart/form-data initial post request this allows preview/thumbnail uploads alongside the initial request, and cleans up the upload form * Add more tests for resumable uploads * Refactor remove dangling resumable uploads * Prepare changelog * Add more resumable upload tests * Remove user quota check for resumable uploads * Fix upload error handler * Update nginx template for upload-resumable * Cleanup comment * Remove unused express methods * Prefer to use got instead of raw http * Don't retry on error 500 Co-authored-by: Rigel Kent <par@rigelk.eu> Co-authored-by: Rigel Kent <sendmemail@rigelk.eu> Co-authored-by: Chocobozzz <me@florianbigard.com>
-rw-r--r--client/package.json1
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.ts4
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts6
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts48
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html20
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss4
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts295
-rw-r--r--client/src/app/+videos/+video-edit/video-add.module.ts5
-rw-r--r--client/src/app/helpers/utils.ts9
-rw-r--r--client/yarn.lock7
-rw-r--r--package.json1
-rw-r--r--server.ts2
-rw-r--r--server/controllers/api/server/debug.ts18
-rw-r--r--server/controllers/api/videos/index.ts105
-rw-r--r--server/helpers/custom-validators/misc.ts5
-rw-r--r--server/helpers/custom-validators/videos.ts9
-rw-r--r--server/helpers/express-utils.ts8
-rw-r--r--server/helpers/upload.ts21
-rw-r--r--server/helpers/utils.ts4
-rw-r--r--server/initializers/constants.ts6
-rw-r--r--server/initializers/installer.ts5
-rw-r--r--server/lib/moderation.ts5
-rw-r--r--server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts61
-rw-r--r--server/lib/video.ts3
-rw-r--r--server/middlewares/async.ts1
-rw-r--r--server/middlewares/validators/videos/videos.ts184
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/upload-quota.ts152
-rw-r--r--server/tests/api/check-params/users.ts105
-rw-r--r--server/tests/api/check-params/videos.ts393
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/multiple-servers.ts2
-rw-r--r--server/tests/api/videos/resumable-upload.ts187
-rw-r--r--server/tests/api/videos/single-server.ts724
-rw-r--r--server/tests/api/videos/video-transcoder.ts159
-rw-r--r--server/typings/express/index.d.ts140
-rw-r--r--shared/core-utils/miscs/http-methods.ts21
-rw-r--r--shared/core-utils/miscs/index.ts1
-rw-r--r--shared/extra-utils/server/debug.ts18
-rw-r--r--shared/extra-utils/server/servers.ts2
-rw-r--r--shared/extra-utils/videos/video-channels.ts11
-rw-r--r--shared/extra-utils/videos/videos.ts258
-rw-r--r--shared/models/server/debug.model.ts4
-rw-r--r--support/doc/api/openapi.yaml414
-rw-r--r--support/nginx/peertube7
-rw-r--r--yarn.lock50
46 files changed, 2324 insertions, 1163 deletions
diff --git a/client/package.json b/client/package.json
index 140fc3095..8486ace22 100644
--- a/client/package.json
+++ b/client/package.json
@@ -96,6 +96,7 @@
96 "lodash-es": "^4.17.4", 96 "lodash-es": "^4.17.4",
97 "markdown-it": "12.0.4", 97 "markdown-it": "12.0.4",
98 "mini-css-extract-plugin": "^1.3.1", 98 "mini-css-extract-plugin": "^1.3.1",
99 "ngx-uploadx": "^4.1.0",
99 "p2p-media-loader-hlsjs": "^0.6.2", 100 "p2p-media-loader-hlsjs": "^0.6.2",
100 "path-browserify": "^1.0.0", 101 "path-browserify": "^1.0.0",
101 "primeng": "^11.0.0-rc.1", 102 "primeng": "^11.0.0-rc.1",
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
index c16368952..a0f2f28f8 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
@@ -2,7 +2,7 @@ import { ViewportScroller } from '@angular/common'
2import { HttpErrorResponse } from '@angular/common/http' 2import { HttpErrorResponse } from '@angular/common/http'
3import { AfterViewChecked, Component, OnInit } from '@angular/core' 3import { AfterViewChecked, Component, OnInit } from '@angular/core'
4import { AuthService, Notifier, User, UserService } from '@app/core' 4import { AuthService, Notifier, User, UserService } from '@app/core'
5import { uploadErrorHandler } from '@app/helpers' 5import { genericUploadErrorHandler } from '@app/helpers'
6 6
7@Component({ 7@Component({
8 selector: 'my-account-settings', 8 selector: 'my-account-settings',
@@ -46,7 +46,7 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked {
46 this.user.updateAccountAvatar(data.avatar) 46 this.user.updateAccountAvatar(data.avatar)
47 }, 47 },
48 48
49 (err: HttpErrorResponse) => uploadErrorHandler({ 49 (err: HttpErrorResponse) => genericUploadErrorHandler({
50 err, 50 err,
51 name: $localize`avatar`, 51 name: $localize`avatar`,
52 notifier: this.notifier 52 notifier: this.notifier
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts
index a29af176c..c9173039a 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts
@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http'
3import { Component, OnDestroy, OnInit } from '@angular/core' 3import { Component, OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, Notifier, ServerService } from '@app/core' 5import { AuthService, Notifier, ServerService } from '@app/core'
6import { uploadErrorHandler } from '@app/helpers' 6import { genericUploadErrorHandler } from '@app/helpers'
7import { 7import {
8 VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, 8 VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
9 VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, 9 VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
@@ -109,7 +109,7 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
109 this.videoChannel.updateAvatar(data.avatar) 109 this.videoChannel.updateAvatar(data.avatar)
110 }, 110 },
111 111
112 (err: HttpErrorResponse) => uploadErrorHandler({ 112 (err: HttpErrorResponse) => genericUploadErrorHandler({
113 err, 113 err,
114 name: $localize`avatar`, 114 name: $localize`avatar`,
115 notifier: this.notifier 115 notifier: this.notifier
@@ -139,7 +139,7 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
139 this.videoChannel.updateBanner(data.banner) 139 this.videoChannel.updateBanner(data.banner)
140 }, 140 },
141 141
142 (err: HttpErrorResponse) => uploadErrorHandler({ 142 (err: HttpErrorResponse) => genericUploadErrorHandler({
143 err, 143 err,
144 name: $localize`banner`, 144 name: $localize`banner`,
145 notifier: this.notifier 145 notifier: this.notifier
diff --git a/client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts b/client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts
new file mode 100644
index 000000000..3392a0d8a
--- /dev/null
+++ b/client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts
@@ -0,0 +1,48 @@
1import { objectToFormData } from '@app/helpers'
2import { resolveUrl, UploaderX } from 'ngx-uploadx'
3
4/**
5 * multipart/form-data uploader extending the UploaderX implementation of Google Resumable
6 * for use with multer
7 *
8 * @see https://github.com/kukhariev/ngx-uploadx/blob/637e258fe366b8095203f387a6101a230ee4f8e6/src/uploadx/lib/uploaderx.ts
9 * @example
10 *
11 * options: UploadxOptions = {
12 * uploaderClass: UploaderXFormData
13 * };
14 */
15export class UploaderXFormData extends UploaderX {
16
17 async getFileUrl (): Promise<string> {
18 const headers = {
19 'X-Upload-Content-Length': this.size.toString(),
20 'X-Upload-Content-Type': this.file.type || 'application/octet-stream'
21 }
22
23 const previewfile = this.metadata.previewfile as any as File
24 delete this.metadata.previewfile
25
26 const data = objectToFormData(this.metadata)
27 if (previewfile !== undefined) {
28 data.append('previewfile', previewfile, previewfile.name)
29 data.append('thumbnailfile', previewfile, previewfile.name)
30 }
31
32 await this.request({
33 method: 'POST',
34 body: data,
35 url: this.endpoint,
36 headers
37 })
38
39 const location = this.getValueFromResponse('location')
40 if (!location) {
41 throw new Error('Invalid or missing Location header')
42 }
43
44 this.offset = this.responseStatus === 201 ? 0 : undefined
45
46 return resolveUrl(location, this.endpoint)
47 }
48}
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
index 4c0b09894..86a779f8a 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
@@ -1,12 +1,17 @@
1<div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="setVideoFile($event)"> 1<div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="onFileDropped($event)">
2 <div class="first-step-block"> 2 <div class="first-step-block">
3 <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon> 3 <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
4 4
5 <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'"> 5 <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'">
6 <span i18n>Select the file to upload</span> 6 <span i18n>Select the file to upload</span>
7 <input 7 <input
8 aria-label="Select the file to upload" i18n-aria-label 8 aria-label="Select the file to upload"
9 #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" autofocus 9 i18n-aria-label
10 #videofileInput
11 [accept]="videoExtensions"
12 (change)="onFileChange($event)"
13 id="videofile"
14 type="file"
10 /> 15 />
11 </div> 16 </div>
12 17
@@ -41,7 +46,13 @@
41 </div> 46 </div>
42 47
43 <div class="form-group upload-audio-button"> 48 <div class="form-group upload-audio-button">
44 <my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button> 49 <my-button
50 className="orange-button"
51 [label]="getAudioUploadLabel()"
52 icon="upload"
53 (click)="uploadAudio()"
54 >
55 </my-button>
45 </div> 56 </div>
46 </ng-container> 57 </ng-container>
47 </div> 58 </div>
@@ -64,6 +75,7 @@
64 <span>{{ error }}</span> 75 <span>{{ error }}</span>
65 </div> 76 </div>
66 </div> 77 </div>
78
67 <div class="btn-group" role="group"> 79 <div class="btn-group" role="group">
68 <input type="button" class="btn" i18n-value="Retry failed upload of a video" value="Retry" (click)="retryUpload()" /> 80 <input type="button" class="btn" i18n-value="Retry failed upload of a video" value="Retry" (click)="retryUpload()" />
69 <input type="button" class="btn" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" /> 81 <input type="button" class="btn" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" />
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss
index 9549257f6..d9f348a70 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss
@@ -47,8 +47,4 @@
47 47
48 margin-left: 10px; 48 margin-left: 10px;
49 } 49 }
50
51 .btn-group > input:not(:first-child) {
52 margin-left: 0;
53 }
54} 50}
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
index effb37077..2d3fc3578 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
@@ -1,15 +1,16 @@
1import { Subscription } from 'rxjs'
2import { HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http'
3import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' 1import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
4import { Router } from '@angular/router' 2import { Router } from '@angular/router'
3import { UploadxOptions, UploadState, UploadxService } from 'ngx-uploadx'
4import { UploaderXFormData } from './uploaderx-form-data'
5import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService, UserService } from '@app/core' 5import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService, UserService } from '@app/core'
6import { scrollToTop, uploadErrorHandler } from '@app/helpers' 6import { scrollToTop, genericUploadErrorHandler } from '@app/helpers'
7import { FormValidatorService } from '@app/shared/shared-forms' 7import { FormValidatorService } from '@app/shared/shared-forms'
8import { BytesPipe, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 8import { BytesPipe, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
9import { LoadingBarService } from '@ngx-loading-bar/core' 9import { LoadingBarService } from '@ngx-loading-bar/core'
10import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' 10import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
11import { VideoPrivacy } from '@shared/models' 11import { VideoPrivacy } from '@shared/models'
12import { VideoSend } from './video-send' 12import { VideoSend } from './video-send'
13import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
13 14
14@Component({ 15@Component({
15 selector: 'my-video-upload', 16 selector: 'my-video-upload',
@@ -20,23 +21,18 @@ import { VideoSend } from './video-send'
20 './video-send.scss' 21 './video-send.scss'
21 ] 22 ]
22}) 23})
23export class VideoUploadComponent extends VideoSend implements OnInit, AfterViewInit, OnDestroy, CanComponentDeactivate { 24export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, AfterViewInit, CanComponentDeactivate {
24 @Output() firstStepDone = new EventEmitter<string>() 25 @Output() firstStepDone = new EventEmitter<string>()
25 @Output() firstStepError = new EventEmitter<void>() 26 @Output() firstStepError = new EventEmitter<void>()
26 @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement> 27 @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
27 28
28 // So that it can be accessed in the template
29 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
30
31 userVideoQuotaUsed = 0 29 userVideoQuotaUsed = 0
32 userVideoQuotaUsedDaily = 0 30 userVideoQuotaUsedDaily = 0
33 31
34 isUploadingAudioFile = false 32 isUploadingAudioFile = false
35 isUploadingVideo = false 33 isUploadingVideo = false
36 isUpdatingVideo = false
37 34
38 videoUploaded = false 35 videoUploaded = false
39 videoUploadObservable: Subscription = null
40 videoUploadPercents = 0 36 videoUploadPercents = 0
41 videoUploadedIds = { 37 videoUploadedIds = {
42 id: 0, 38 id: 0,
@@ -49,7 +45,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
49 error: string 45 error: string
50 enableRetryAfterError: boolean 46 enableRetryAfterError: boolean
51 47
48 // So that it can be accessed in the template
52 protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC 49 protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC
50 protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + 'upload-resumable'
51
52 private uploadxOptions: UploadxOptions
53 private isUpdatingVideo = false
54 private fileToUpload: File
53 55
54 constructor ( 56 constructor (
55 protected formValidatorService: FormValidatorService, 57 protected formValidatorService: FormValidatorService,
@@ -61,15 +63,77 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
61 protected videoCaptionService: VideoCaptionService, 63 protected videoCaptionService: VideoCaptionService,
62 private userService: UserService, 64 private userService: UserService,
63 private router: Router, 65 private router: Router,
64 private hooks: HooksService 66 private hooks: HooksService,
65 ) { 67 private resumableUploadService: UploadxService
68 ) {
66 super() 69 super()
70
71 this.uploadxOptions = {
72 endpoint: this.BASE_VIDEO_UPLOAD_URL,
73 multiple: false,
74 token: this.authService.getAccessToken(),
75 uploaderClass: UploaderXFormData,
76 retryConfig: {
77 maxAttempts: 6,
78 shouldRetry: (code: number) => {
79 return code < 400 || code >= 501
80 }
81 }
82 }
67 } 83 }
68 84
69 get videoExtensions () { 85 get videoExtensions () {
70 return this.serverConfig.video.file.extensions.join(', ') 86 return this.serverConfig.video.file.extensions.join(', ')
71 } 87 }
72 88
89 onUploadVideoOngoing (state: UploadState) {
90 switch (state.status) {
91 case 'error':
92 const error = state.response?.error || 'Unknow error'
93
94 this.handleUploadError({
95 error: new Error(error),
96 name: 'HttpErrorResponse',
97 message: error,
98 ok: false,
99 headers: new HttpHeaders(state.responseHeaders),
100 status: +state.responseStatus,
101 statusText: error,
102 type: HttpEventType.Response,
103 url: state.url
104 })
105 break
106
107 case 'cancelled':
108 this.isUploadingVideo = false
109 this.videoUploadPercents = 0
110
111 this.firstStepError.emit()
112 this.enableRetryAfterError = false
113 this.error = ''
114 break
115
116 case 'queue':
117 this.closeFirstStep(state.name)
118 break
119
120 case 'uploading':
121 this.videoUploadPercents = state.progress
122 break
123
124 case 'paused':
125 this.notifier.info($localize`Upload cancelled`)
126 break
127
128 case 'complete':
129 this.videoUploaded = true
130 this.videoUploadPercents = 100
131
132 this.videoUploadedIds = state?.response.video
133 break
134 }
135 }
136
73 ngOnInit () { 137 ngOnInit () {
74 super.ngOnInit() 138 super.ngOnInit()
75 139
@@ -78,6 +142,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
78 this.userVideoQuotaUsed = data.videoQuotaUsed 142 this.userVideoQuotaUsed = data.videoQuotaUsed
79 this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily 143 this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily
80 }) 144 })
145
146 this.resumableUploadService.events
147 .subscribe(state => this.onUploadVideoOngoing(state))
81 } 148 }
82 149
83 ngAfterViewInit () { 150 ngAfterViewInit () {
@@ -85,7 +152,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
85 } 152 }
86 153
87 ngOnDestroy () { 154 ngOnDestroy () {
88 if (this.videoUploadObservable) this.videoUploadObservable.unsubscribe() 155 this.cancelUpload()
89 } 156 }
90 157
91 canDeactivate () { 158 canDeactivate () {
@@ -105,137 +172,43 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
105 } 172 }
106 } 173 }
107 174
108 getVideoFile () { 175 onFileDropped (files: FileList) {
109 return this.videofileInput.nativeElement.files[0]
110 }
111
112 setVideoFile (files: FileList) {
113 this.videofileInput.nativeElement.files = files 176 this.videofileInput.nativeElement.files = files
114 this.fileChange()
115 }
116
117 getAudioUploadLabel () {
118 const videofile = this.getVideoFile()
119 if (!videofile) return $localize`Upload`
120 177
121 return $localize`Upload ${videofile.name}` 178 this.onFileChange({ target: this.videofileInput.nativeElement })
122 } 179 }
123 180
124 fileChange () { 181 onFileChange (event: Event | { target: HTMLInputElement }) {
125 this.uploadFirstStep() 182 const file = (event.target as HTMLInputElement).files[0]
126 }
127
128 retryUpload () {
129 this.enableRetryAfterError = false
130 this.error = ''
131 this.uploadVideo()
132 }
133
134 cancelUpload () {
135 if (this.videoUploadObservable !== null) {
136 this.videoUploadObservable.unsubscribe()
137 }
138
139 this.isUploadingVideo = false
140 this.videoUploadPercents = 0
141 this.videoUploadObservable = null
142 183
143 this.firstStepError.emit() 184 if (!file) return
144 this.enableRetryAfterError = false
145 this.error = ''
146 185
147 this.notifier.info($localize`Upload cancelled`) 186 if (!this.checkGlobalUserQuota(file)) return
148 } 187 if (!this.checkDailyUserQuota(file)) return
149 188
150 uploadFirstStep (clickedOnButton = false) { 189 if (this.isAudioFile(file.name)) {
151 const videofile = this.getVideoFile()
152 if (!videofile) return
153
154 if (!this.checkGlobalUserQuota(videofile)) return
155 if (!this.checkDailyUserQuota(videofile)) return
156
157 if (clickedOnButton === false && this.isAudioFile(videofile.name)) {
158 this.isUploadingAudioFile = true 190 this.isUploadingAudioFile = true
159 return 191 return
160 } 192 }
161 193
162 // Build name field
163 const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '')
164 let name: string
165
166 // If the name of the file is very small, keep the extension
167 if (nameWithoutExtension.length < 3) name = videofile.name
168 else name = nameWithoutExtension
169
170 const nsfw = this.serverConfig.instance.isNSFW
171 const waitTranscoding = true
172 const commentsEnabled = true
173 const downloadEnabled = true
174 const channelId = this.firstStepChannelId.toString()
175
176 this.formData = new FormData()
177 this.formData.append('name', name)
178 // Put the video "private" -> we are waiting the user validation of the second step
179 this.formData.append('privacy', VideoPrivacy.PRIVATE.toString())
180 this.formData.append('nsfw', '' + nsfw)
181 this.formData.append('commentsEnabled', '' + commentsEnabled)
182 this.formData.append('downloadEnabled', '' + downloadEnabled)
183 this.formData.append('waitTranscoding', '' + waitTranscoding)
184 this.formData.append('channelId', '' + channelId)
185 this.formData.append('videofile', videofile)
186
187 if (this.previewfileUpload) {
188 this.formData.append('previewfile', this.previewfileUpload)
189 this.formData.append('thumbnailfile', this.previewfileUpload)
190 }
191
192 this.isUploadingVideo = true 194 this.isUploadingVideo = true
193 this.firstStepDone.emit(name) 195 this.fileToUpload = file
194
195 this.form.patchValue({
196 name,
197 privacy: this.firstStepPrivacyId,
198 nsfw,
199 channelId: this.firstStepChannelId,
200 previewfile: this.previewfileUpload
201 })
202 196
203 this.uploadVideo() 197 this.uploadFile(file)
204 } 198 }
205 199
206 uploadVideo () { 200 uploadAudio () {
207 this.videoUploadObservable = this.videoService.uploadVideo(this.formData).subscribe( 201 this.uploadFile(this.getInputVideoFile(), this.previewfileUpload)
208 event => { 202 }
209 if (event.type === HttpEventType.UploadProgress) {
210 this.videoUploadPercents = Math.round(100 * event.loaded / event.total)
211 } else if (event instanceof HttpResponse) {
212 this.videoUploaded = true
213
214 this.videoUploadedIds = event.body.video
215
216 this.videoUploadObservable = null
217 }
218 },
219 203
220 (err: HttpErrorResponse) => { 204 retryUpload () {
221 // Reset progress (but keep isUploadingVideo true) 205 this.enableRetryAfterError = false
222 this.videoUploadPercents = 0 206 this.error = ''
223 this.videoUploadObservable = null 207 this.uploadFile(this.fileToUpload)
224 this.enableRetryAfterError = true 208 }
225
226 this.error = uploadErrorHandler({
227 err,
228 name: $localize`video`,
229 notifier: this.notifier,
230 sticky: false
231 })
232 209
233 if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413 || 210 cancelUpload () {
234 err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) { 211 this.resumableUploadService.control({ action: 'cancel' })
235 this.cancelUpload()
236 }
237 }
238 )
239 } 212 }
240 213
241 isPublishingButtonDisabled () { 214 isPublishingButtonDisabled () {
@@ -245,6 +218,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
245 !this.videoUploadedIds.id 218 !this.videoUploadedIds.id
246 } 219 }
247 220
221 getAudioUploadLabel () {
222 const videofile = this.getInputVideoFile()
223 if (!videofile) return $localize`Upload`
224
225 return $localize`Upload ${videofile.name}`
226 }
227
248 updateSecondStep () { 228 updateSecondStep () {
249 if (this.isPublishingButtonDisabled() || !this.checkForm()) { 229 if (this.isPublishingButtonDisabled() || !this.checkForm()) {
250 return 230 return
@@ -275,6 +255,62 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
275 ) 255 )
276 } 256 }
277 257
258 private getInputVideoFile () {
259 return this.videofileInput.nativeElement.files[0]
260 }
261
262 private uploadFile (file: File, previewfile?: File) {
263 const metadata = {
264 waitTranscoding: true,
265 commentsEnabled: true,
266 downloadEnabled: true,
267 channelId: this.firstStepChannelId,
268 nsfw: this.serverConfig.instance.isNSFW,
269 privacy: VideoPrivacy.PRIVATE.toString(),
270 filename: file.name,
271 previewfile: previewfile as any
272 }
273
274 this.resumableUploadService.handleFiles(file, {
275 ...this.uploadxOptions,
276 metadata
277 })
278
279 this.isUploadingVideo = true
280 }
281
282 private handleUploadError (err: HttpErrorResponse) {
283 // Reset progress (but keep isUploadingVideo true)
284 this.videoUploadPercents = 0
285 this.enableRetryAfterError = true
286
287 this.error = genericUploadErrorHandler({
288 err,
289 name: $localize`video`,
290 notifier: this.notifier,
291 sticky: false
292 })
293
294 if (err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) {
295 this.cancelUpload()
296 }
297 }
298
299 private closeFirstStep (filename: string) {
300 const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '')
301 const name = nameWithoutExtension.length < 3 ? filename : nameWithoutExtension
302
303 this.form.patchValue({
304 name,
305 privacy: this.firstStepPrivacyId,
306 nsfw: this.serverConfig.instance.isNSFW,
307 channelId: this.firstStepChannelId,
308 previewfile: this.previewfileUpload
309 })
310
311 this.firstStepDone.emit(name)
312 }
313
278 private checkGlobalUserQuota (videofile: File) { 314 private checkGlobalUserQuota (videofile: File) {
279 const bytePipes = new BytesPipe() 315 const bytePipes = new BytesPipe()
280 316
@@ -285,8 +321,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
285 const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0) 321 const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0)
286 const videoQuotaBytes = bytePipes.transform(videoQuota, 0) 322 const videoQuotaBytes = bytePipes.transform(videoQuota, 0)
287 323
288 const msg = $localize`Your video quota is exceeded with this video ( 324 const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
289video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
290 this.notifier.error(msg) 325 this.notifier.error(msg)
291 326
292 return false 327 return false
@@ -304,9 +339,7 @@ video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuota
304 const videoSizeBytes = bytePipes.transform(videofile.size, 0) 339 const videoSizeBytes = bytePipes.transform(videofile.size, 0)
305 const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0) 340 const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0)
306 const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0) 341 const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0)
307 342 const msg = $localize`Your daily video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})`
308 const msg = $localize`Your daily video quota is exceeded with this video (
309video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})`
310 this.notifier.error(msg) 343 this.notifier.error(msg)
311 344
312 return false 345 return false
diff --git a/client/src/app/+videos/+video-edit/video-add.module.ts b/client/src/app/+videos/+video-edit/video-add.module.ts
index da651119b..e836cf81e 100644
--- a/client/src/app/+videos/+video-edit/video-add.module.ts
+++ b/client/src/app/+videos/+video-edit/video-add.module.ts
@@ -1,5 +1,6 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { CanDeactivateGuard } from '@app/core' 2import { CanDeactivateGuard } from '@app/core'
3import { UploadxModule } from 'ngx-uploadx'
3import { VideoEditModule } from './shared/video-edit.module' 4import { VideoEditModule } from './shared/video-edit.module'
4import { DragDropDirective } from './video-add-components/drag-drop.directive' 5import { DragDropDirective } from './video-add-components/drag-drop.directive'
5import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' 6import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
@@ -13,7 +14,9 @@ import { VideoAddComponent } from './video-add.component'
13 imports: [ 14 imports: [
14 VideoAddRoutingModule, 15 VideoAddRoutingModule,
15 16
16 VideoEditModule 17 VideoEditModule,
18
19 UploadxModule
17 ], 20 ],
18 21
19 declarations: [ 22 declarations: [
diff --git a/client/src/app/helpers/utils.ts b/client/src/app/helpers/utils.ts
index 17eb5effc..d6ac5b9b4 100644
--- a/client/src/app/helpers/utils.ts
+++ b/client/src/app/helpers/utils.ts
@@ -173,8 +173,8 @@ function isXPercentInViewport (el: HTMLElement, percentVisible: number) {
173 ) 173 )
174} 174}
175 175
176function uploadErrorHandler (parameters: { 176function genericUploadErrorHandler (parameters: {
177 err: HttpErrorResponse 177 err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
178 name: string 178 name: string
179 notifier: Notifier 179 notifier: Notifier
180 sticky?: boolean 180 sticky?: boolean
@@ -186,6 +186,9 @@ function uploadErrorHandler (parameters: {
186 if (err instanceof ErrorEvent) { // network error 186 if (err instanceof ErrorEvent) { // network error
187 message = $localize`The connection was interrupted` 187 message = $localize`The connection was interrupted`
188 notifier.error(message, title, null, sticky) 188 notifier.error(message, title, null, sticky)
189 } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
190 message = $localize`The server encountered an error`
191 notifier.error(message, title, null, sticky)
189 } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { 192 } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
190 message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)` 193 message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)`
191 notifier.error(message, title, null, sticky) 194 notifier.error(message, title, null, sticky)
@@ -216,5 +219,5 @@ export {
216 isInViewport, 219 isInViewport,
217 isXPercentInViewport, 220 isXPercentInViewport,
218 listUserChannels, 221 listUserChannels,
219 uploadErrorHandler 222 genericUploadErrorHandler
220} 223}
diff --git a/client/yarn.lock b/client/yarn.lock
index 571314f22..1b1455cc8 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -7793,6 +7793,13 @@ next-tick@~1.0.0:
7793 resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" 7793 resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
7794 integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= 7794 integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
7795 7795
7796ngx-uploadx@^4.1.0:
7797 version "4.1.0"
7798 resolved "https://registry.yarnpkg.com/ngx-uploadx/-/ngx-uploadx-4.1.0.tgz#b3ed4566a2505239026bbdc10c2345aae28d67df"
7799 integrity sha512-KCG0NT4SBc/5MRl8aR6joHHg+WeTdrkhLeC1DrNgVxrTBuuenlEwOVDpkLJMPX/8HE6Bq33rx1U2NNZYVl9NMQ==
7800 dependencies:
7801 tslib "^1.9.0"
7802
7796nice-try@^1.0.4: 7803nice-try@^1.0.4:
7797 version "1.0.5" 7804 version "1.0.5"
7798 resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" 7805 resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
diff --git a/package.json b/package.json
index e1508c65f..d3375c7d4 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,7 @@
73 "swagger-cli": "swagger-cli" 73 "swagger-cli": "swagger-cli"
74 }, 74 },
75 "dependencies": { 75 "dependencies": {
76 "@uploadx/core": "^4.4.0",
76 "apicache": "1.6.2", 77 "apicache": "1.6.2",
77 "async": "^3.0.1", 78 "async": "^3.0.1",
78 "async-lru": "^1.1.1", 79 "async-lru": "^1.1.1",
diff --git a/server.ts b/server.ts
index 2531080a3..97dffe756 100644
--- a/server.ts
+++ b/server.ts
@@ -116,6 +116,7 @@ import { YoutubeDlUpdateScheduler } from './server/lib/schedulers/youtube-dl-upd
116import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler' 116import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler'
117import { RemoveOldHistoryScheduler } from './server/lib/schedulers/remove-old-history-scheduler' 117import { RemoveOldHistoryScheduler } from './server/lib/schedulers/remove-old-history-scheduler'
118import { AutoFollowIndexInstances } from './server/lib/schedulers/auto-follow-index-instances' 118import { AutoFollowIndexInstances } from './server/lib/schedulers/auto-follow-index-instances'
119import { RemoveDanglingResumableUploadsScheduler } from './server/lib/schedulers/remove-dangling-resumable-uploads-scheduler'
119import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto' 120import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto'
120import { PeerTubeSocket } from './server/lib/peertube-socket' 121import { PeerTubeSocket } from './server/lib/peertube-socket'
121import { updateStreamingPlaylistsInfohashesIfNeeded } from './server/lib/hls' 122import { updateStreamingPlaylistsInfohashesIfNeeded } from './server/lib/hls'
@@ -280,6 +281,7 @@ async function startApplication () {
280 PluginsCheckScheduler.Instance.enable() 281 PluginsCheckScheduler.Instance.enable()
281 PeerTubeVersionCheckScheduler.Instance.enable() 282 PeerTubeVersionCheckScheduler.Instance.enable()
282 AutoFollowIndexInstances.Instance.enable() 283 AutoFollowIndexInstances.Instance.enable()
284 RemoveDanglingResumableUploadsScheduler.Instance.enable()
283 285
284 // Redis initialization 286 // Redis initialization
285 Redis.Instance.init() 287 Redis.Instance.init()
diff --git a/server/controllers/api/server/debug.ts b/server/controllers/api/server/debug.ts
index 7787186be..ff0d9ca3c 100644
--- a/server/controllers/api/server/debug.ts
+++ b/server/controllers/api/server/debug.ts
@@ -1,4 +1,6 @@
1import { InboxManager } from '@server/lib/activitypub/inbox-manager' 1import { InboxManager } from '@server/lib/activitypub/inbox-manager'
2import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler'
3import { SendDebugCommand } from '@shared/models'
2import * as express from 'express' 4import * as express from 'express'
3import { UserRight } from '../../../../shared/models/users' 5import { UserRight } from '../../../../shared/models/users'
4import { authenticate, ensureUserHasRight } from '../../../middlewares' 6import { authenticate, ensureUserHasRight } from '../../../middlewares'
@@ -11,6 +13,12 @@ debugRouter.get('/debug',
11 getDebug 13 getDebug
12) 14)
13 15
16debugRouter.post('/debug/run-command',
17 authenticate,
18 ensureUserHasRight(UserRight.MANAGE_DEBUG),
19 runCommand
20)
21
14// --------------------------------------------------------------------------- 22// ---------------------------------------------------------------------------
15 23
16export { 24export {
@@ -25,3 +33,13 @@ function getDebug (req: express.Request, res: express.Response) {
25 activityPubMessagesWaiting: InboxManager.Instance.getActivityPubMessagesWaiting() 33 activityPubMessagesWaiting: InboxManager.Instance.getActivityPubMessagesWaiting()
26 }) 34 })
27} 35}
36
37async function runCommand (req: express.Request, res: express.Response) {
38 const body: SendDebugCommand = req.body
39
40 if (body.command === 'remove-dandling-resumable-uploads') {
41 await RemoveDanglingResumableUploadsScheduler.Instance.execute()
42 }
43
44 return res.sendStatus(204)
45}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index fbdb0f776..c32626d30 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -2,6 +2,7 @@ import * as express from 'express'
2import { move } from 'fs-extra' 2import { move } from 'fs-extra'
3import { extname } from 'path' 3import { extname } from 'path'
4import toInt from 'validator/lib/toInt' 4import toInt from 'validator/lib/toInt'
5import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload'
5import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
6import { changeVideoChannelShare } from '@server/lib/activitypub/share' 7import { changeVideoChannelShare } from '@server/lib/activitypub/share'
7import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 8import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
@@ -10,8 +11,9 @@ import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnail
10import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 11import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
11import { getServerActor } from '@server/models/application/application' 12import { getServerActor } from '@server/models/application/application'
12import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 13import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
14import { uploadx } from '@uploadx/core'
13import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared' 15import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 16import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
15import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 17import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
16import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' 18import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils'
17import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils' 19import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils'
@@ -47,7 +49,9 @@ import {
47 setDefaultPagination, 49 setDefaultPagination,
48 setDefaultVideosSort, 50 setDefaultVideosSort,
49 videoFileMetadataGetValidator, 51 videoFileMetadataGetValidator,
50 videosAddValidator, 52 videosAddLegacyValidator,
53 videosAddResumableInitValidator,
54 videosAddResumableValidator,
51 videosCustomGetValidator, 55 videosCustomGetValidator,
52 videosGetValidator, 56 videosGetValidator,
53 videosRemoveValidator, 57 videosRemoveValidator,
@@ -69,6 +73,7 @@ import { watchingRouter } from './watching'
69const lTags = loggerTagsFactory('api', 'video') 73const lTags = loggerTagsFactory('api', 'video')
70const auditLogger = auditLoggerFactory('videos') 74const auditLogger = auditLoggerFactory('videos')
71const videosRouter = express.Router() 75const videosRouter = express.Router()
76const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
72 77
73const reqVideoFileAdd = createReqFiles( 78const reqVideoFileAdd = createReqFiles(
74 [ 'videofile', 'thumbnailfile', 'previewfile' ], 79 [ 'videofile', 'thumbnailfile', 'previewfile' ],
@@ -79,6 +84,16 @@ const reqVideoFileAdd = createReqFiles(
79 previewfile: CONFIG.STORAGE.TMP_DIR 84 previewfile: CONFIG.STORAGE.TMP_DIR
80 } 85 }
81) 86)
87
88const reqVideoFileAddResumable = createReqFiles(
89 [ 'thumbnailfile', 'previewfile' ],
90 MIMETYPES.IMAGE.MIMETYPE_EXT,
91 {
92 thumbnailfile: getResumableUploadPath(),
93 previewfile: getResumableUploadPath()
94 }
95)
96
82const reqVideoFileUpdate = createReqFiles( 97const reqVideoFileUpdate = createReqFiles(
83 [ 'thumbnailfile', 'previewfile' ], 98 [ 'thumbnailfile', 'previewfile' ],
84 MIMETYPES.IMAGE.MIMETYPE_EXT, 99 MIMETYPES.IMAGE.MIMETYPE_EXT,
@@ -111,18 +126,39 @@ videosRouter.get('/',
111 commonVideosFiltersValidator, 126 commonVideosFiltersValidator,
112 asyncMiddleware(listVideos) 127 asyncMiddleware(listVideos)
113) 128)
129
130videosRouter.post('/upload',
131 authenticate,
132 reqVideoFileAdd,
133 asyncMiddleware(videosAddLegacyValidator),
134 asyncRetryTransactionMiddleware(addVideoLegacy)
135)
136
137videosRouter.post('/upload-resumable',
138 authenticate,
139 reqVideoFileAddResumable,
140 asyncMiddleware(videosAddResumableInitValidator),
141 uploadxMiddleware
142)
143
144videosRouter.delete('/upload-resumable',
145 authenticate,
146 uploadxMiddleware
147)
148
149videosRouter.put('/upload-resumable',
150 authenticate,
151 uploadxMiddleware, // uploadx doesn't use call next() before the file upload completes
152 asyncMiddleware(videosAddResumableValidator),
153 asyncMiddleware(addVideoResumable)
154)
155
114videosRouter.put('/:id', 156videosRouter.put('/:id',
115 authenticate, 157 authenticate,
116 reqVideoFileUpdate, 158 reqVideoFileUpdate,
117 asyncMiddleware(videosUpdateValidator), 159 asyncMiddleware(videosUpdateValidator),
118 asyncRetryTransactionMiddleware(updateVideo) 160 asyncRetryTransactionMiddleware(updateVideo)
119) 161)
120videosRouter.post('/upload',
121 authenticate,
122 reqVideoFileAdd,
123 asyncMiddleware(videosAddValidator),
124 asyncRetryTransactionMiddleware(addVideo)
125)
126 162
127videosRouter.get('/:id/description', 163videosRouter.get('/:id/description',
128 asyncMiddleware(videosGetValidator), 164 asyncMiddleware(videosGetValidator),
@@ -157,23 +193,23 @@ export {
157 193
158// --------------------------------------------------------------------------- 194// ---------------------------------------------------------------------------
159 195
160function listVideoCategories (req: express.Request, res: express.Response) { 196function listVideoCategories (_req: express.Request, res: express.Response) {
161 res.json(VIDEO_CATEGORIES) 197 res.json(VIDEO_CATEGORIES)
162} 198}
163 199
164function listVideoLicences (req: express.Request, res: express.Response) { 200function listVideoLicences (_req: express.Request, res: express.Response) {
165 res.json(VIDEO_LICENCES) 201 res.json(VIDEO_LICENCES)
166} 202}
167 203
168function listVideoLanguages (req: express.Request, res: express.Response) { 204function listVideoLanguages (_req: express.Request, res: express.Response) {
169 res.json(VIDEO_LANGUAGES) 205 res.json(VIDEO_LANGUAGES)
170} 206}
171 207
172function listVideoPrivacies (req: express.Request, res: express.Response) { 208function listVideoPrivacies (_req: express.Request, res: express.Response) {
173 res.json(VIDEO_PRIVACIES) 209 res.json(VIDEO_PRIVACIES)
174} 210}
175 211
176async function addVideo (req: express.Request, res: express.Response) { 212async function addVideoLegacy (req: express.Request, res: express.Response) {
177 // Uploading the video could be long 213 // Uploading the video could be long
178 // Set timeout to 10 minutes, as Express's default is 2 minutes 214 // Set timeout to 10 minutes, as Express's default is 2 minutes
179 req.setTimeout(1000 * 60 * 10, () => { 215 req.setTimeout(1000 * 60 * 10, () => {
@@ -183,13 +219,42 @@ async function addVideo (req: express.Request, res: express.Response) {
183 219
184 const videoPhysicalFile = req.files['videofile'][0] 220 const videoPhysicalFile = req.files['videofile'][0]
185 const videoInfo: VideoCreate = req.body 221 const videoInfo: VideoCreate = req.body
222 const files = req.files
223
224 return addVideo({ res, videoPhysicalFile, videoInfo, files })
225}
226
227async function addVideoResumable (_req: express.Request, res: express.Response) {
228 const videoPhysicalFile = res.locals.videoFileResumable
229 const videoInfo = videoPhysicalFile.metadata
230 const files = { previewfile: videoInfo.previewfile }
231
232 // Don't need the meta file anymore
233 await deleteResumableUploadMetaFile(videoPhysicalFile.path)
234
235 return addVideo({ res, videoPhysicalFile, videoInfo, files })
236}
186 237
187 const videoData = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id) 238async function addVideo (options: {
188 videoData.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED 239 res: express.Response
189 videoData.duration = videoPhysicalFile['duration'] // duration was added by a previous middleware 240 videoPhysicalFile: express.VideoUploadFile
241 videoInfo: VideoCreate
242 files: express.UploadFiles
243}) {
244 const { res, videoPhysicalFile, videoInfo, files } = options
245 const videoChannel = res.locals.videoChannel
246 const user = res.locals.oauth.token.User
247
248 const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
249
250 videoData.state = CONFIG.TRANSCODING.ENABLED
251 ? VideoState.TO_TRANSCODE
252 : VideoState.PUBLISHED
253
254 videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
190 255
191 const video = new VideoModel(videoData) as MVideoFullLight 256 const video = new VideoModel(videoData) as MVideoFullLight
192 video.VideoChannel = res.locals.videoChannel 257 video.VideoChannel = videoChannel
193 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object 258 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
194 259
195 const videoFile = new VideoFileModel({ 260 const videoFile = new VideoFileModel({
@@ -217,7 +282,7 @@ async function addVideo (req: express.Request, res: express.Response) {
217 282
218 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ 283 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
219 video, 284 video,
220 files: req.files, 285 files,
221 fallback: type => generateVideoMiniature({ video, videoFile, type }) 286 fallback: type => generateVideoMiniature({ video, videoFile, type })
222 }) 287 })
223 288
@@ -253,7 +318,7 @@ async function addVideo (req: express.Request, res: express.Response) {
253 318
254 await autoBlacklistVideoIfNeeded({ 319 await autoBlacklistVideoIfNeeded({
255 video, 320 video,
256 user: res.locals.oauth.token.User, 321 user,
257 isRemote: false, 322 isRemote: false,
258 isNew: true, 323 isNew: true,
259 transaction: t 324 transaction: t
@@ -282,7 +347,7 @@ async function addVideo (req: express.Request, res: express.Response) {
282 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) })) 347 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
283 348
284 if (video.state === VideoState.TO_TRANSCODE) { 349 if (video.state === VideoState.TO_TRANSCODE) {
285 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, res.locals.oauth.token.User) 350 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
286 } 351 }
287 352
288 Hooks.runAction('action:api.video.uploaded', { video: videoCreated }) 353 Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index effdd98cb..fd3b45804 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -1,6 +1,7 @@
1import 'multer' 1import 'multer'
2import validator from 'validator' 2import { UploadFilesForCheck } from 'express'
3import { sep } from 'path' 3import { sep } from 'path'
4import validator from 'validator'
4 5
5function exists (value: any) { 6function exists (value: any) {
6 return value !== undefined && value !== null 7 return value !== undefined && value !== null
@@ -108,7 +109,7 @@ function isFileFieldValid (
108} 109}
109 110
110function isFileMimeTypeValid ( 111function isFileMimeTypeValid (
111 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], 112 files: UploadFilesForCheck,
112 mimeTypeRegex: string, 113 mimeTypeRegex: string,
113 field: string, 114 field: string,
114 optional = false 115 optional = false
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 87966798f..b33e088eb 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -1,4 +1,6 @@
1import { UploadFilesForCheck } from 'express'
1import { values } from 'lodash' 2import { values } from 'lodash'
3import * as magnetUtil from 'magnet-uri'
2import validator from 'validator' 4import validator from 'validator'
3import { VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared' 5import { VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared'
4import { 6import {
@@ -6,13 +8,12 @@ import {
6 MIMETYPES, 8 MIMETYPES,
7 VIDEO_CATEGORIES, 9 VIDEO_CATEGORIES,
8 VIDEO_LICENCES, 10 VIDEO_LICENCES,
11 VIDEO_LIVE,
9 VIDEO_PRIVACIES, 12 VIDEO_PRIVACIES,
10 VIDEO_RATE_TYPES, 13 VIDEO_RATE_TYPES,
11 VIDEO_STATES, 14 VIDEO_STATES
12 VIDEO_LIVE
13} from '../../initializers/constants' 15} from '../../initializers/constants'
14import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' 16import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc'
15import * as magnetUtil from 'magnet-uri'
16 17
17const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
18 19
@@ -81,7 +82,7 @@ function isVideoFileExtnameValid (value: string) {
81 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined) 82 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
82} 83}
83 84
84function isVideoFileMimeTypeValid (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 85function isVideoFileMimeTypeValid (files: UploadFilesForCheck) {
85 return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile') 86 return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile')
86} 87}
87 88
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index c0d3f8f32..ede22a3cc 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -2,7 +2,7 @@ import * as express from 'express'
2import * as multer from 'multer' 2import * as multer from 'multer'
3import { REMOTE_SCHEME } from '../initializers/constants' 3import { REMOTE_SCHEME } from '../initializers/constants'
4import { logger } from './logger' 4import { logger } from './logger'
5import { deleteFileAsync, generateRandomString } from './utils' 5import { deleteFileAndCatch, generateRandomString } from './utils'
6import { extname } from 'path' 6import { extname } from 'path'
7import { isArray } from './custom-validators/misc' 7import { isArray } from './custom-validators/misc'
8import { CONFIG } from '../initializers/config' 8import { CONFIG } from '../initializers/config'
@@ -36,15 +36,15 @@ function cleanUpReqFiles (req: { files: { [fieldname: string]: Express.Multer.Fi
36 if (!files) return 36 if (!files) return
37 37
38 if (isArray(files)) { 38 if (isArray(files)) {
39 (files as Express.Multer.File[]).forEach(f => deleteFileAsync(f.path)) 39 (files as Express.Multer.File[]).forEach(f => deleteFileAndCatch(f.path))
40 return 40 return
41 } 41 }
42 42
43 for (const key of Object.keys(files)) { 43 for (const key of Object.keys(files)) {
44 const file = files[key] 44 const file = files[key]
45 45
46 if (isArray(file)) file.forEach(f => deleteFileAsync(f.path)) 46 if (isArray(file)) file.forEach(f => deleteFileAndCatch(f.path))
47 else deleteFileAsync(file.path) 47 else deleteFileAndCatch(file.path)
48 } 48 }
49} 49}
50 50
diff --git a/server/helpers/upload.ts b/server/helpers/upload.ts
new file mode 100644
index 000000000..030a6b7d5
--- /dev/null
+++ b/server/helpers/upload.ts
@@ -0,0 +1,21 @@
1import { METAFILE_EXTNAME } from '@uploadx/core'
2import { remove } from 'fs-extra'
3import { join } from 'path'
4import { RESUMABLE_UPLOAD_DIRECTORY } from '../initializers/constants'
5
6function getResumableUploadPath (filename?: string) {
7 if (filename) return join(RESUMABLE_UPLOAD_DIRECTORY, filename)
8
9 return RESUMABLE_UPLOAD_DIRECTORY
10}
11
12function deleteResumableUploadMetaFile (filepath: string) {
13 return remove(filepath + METAFILE_EXTNAME)
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 getResumableUploadPath,
20 deleteResumableUploadMetaFile
21}
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts
index 0545e8996..6c95a43b6 100644
--- a/server/helpers/utils.ts
+++ b/server/helpers/utils.ts
@@ -6,7 +6,7 @@ import { CONFIG } from '../initializers/config'
6import { execPromise, execPromise2, randomBytesPromise, sha256 } from './core-utils' 6import { execPromise, execPromise2, randomBytesPromise, sha256 } from './core-utils'
7import { logger } from './logger' 7import { logger } from './logger'
8 8
9function deleteFileAsync (path: string) { 9function deleteFileAndCatch (path: string) {
10 remove(path) 10 remove(path)
11 .catch(err => logger.error('Cannot delete the file %s asynchronously.', path, { err })) 11 .catch(err => logger.error('Cannot delete the file %s asynchronously.', path, { err }))
12} 12}
@@ -83,7 +83,7 @@ function getUUIDFromFilename (filename: string) {
83// --------------------------------------------------------------------------- 83// ---------------------------------------------------------------------------
84 84
85export { 85export {
86 deleteFileAsync, 86 deleteFileAndCatch,
87 generateRandomString, 87 generateRandomString,
88 getFormattedObjects, 88 getFormattedObjects,
89 getSecureTorrentName, 89 getSecureTorrentName,
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index f807a1e58..6f388420e 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -208,7 +208,8 @@ const SCHEDULER_INTERVALS_MS = {
208 autoFollowIndexInstances: 60000 * 60 * 24, // 1 day 208 autoFollowIndexInstances: 60000 * 60 * 24, // 1 day
209 removeOldViews: 60000 * 60 * 24, // 1 day 209 removeOldViews: 60000 * 60 * 24, // 1 day
210 removeOldHistory: 60000 * 60 * 24, // 1 day 210 removeOldHistory: 60000 * 60 * 24, // 1 day
211 updateInboxStats: 1000 * 60// 1 minute 211 updateInboxStats: 1000 * 60, // 1 minute
212 removeDanglingResumableUploads: 60000 * 60 * 16 // 16 hours
212} 213}
213 214
214// --------------------------------------------------------------------------- 215// ---------------------------------------------------------------------------
@@ -285,6 +286,7 @@ const CONSTRAINTS_FIELDS = {
285 LIKES: { min: 0 }, 286 LIKES: { min: 0 },
286 DISLIKES: { min: 0 }, 287 DISLIKES: { min: 0 },
287 FILE_SIZE: { min: -1 }, 288 FILE_SIZE: { min: -1 },
289 PARTIAL_UPLOAD_SIZE: { max: 50 * 1024 * 1024 * 1024 }, // 50GB
288 URL: { min: 3, max: 2000 } // Length 290 URL: { min: 3, max: 2000 } // Length
289 }, 291 },
290 VIDEO_PLAYLISTS: { 292 VIDEO_PLAYLISTS: {
@@ -645,6 +647,7 @@ const LRU_CACHE = {
645 } 647 }
646} 648}
647 649
650const RESUMABLE_UPLOAD_DIRECTORY = join(CONFIG.STORAGE.TMP_DIR, 'resumable-uploads')
648const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') 651const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls')
649const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') 652const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
650 653
@@ -819,6 +822,7 @@ export {
819 PEERTUBE_VERSION, 822 PEERTUBE_VERSION,
820 LAZY_STATIC_PATHS, 823 LAZY_STATIC_PATHS,
821 SEARCH_INDEX, 824 SEARCH_INDEX,
825 RESUMABLE_UPLOAD_DIRECTORY,
822 HLS_REDUNDANCY_DIRECTORY, 826 HLS_REDUNDANCY_DIRECTORY,
823 P2P_MEDIA_LOADER_PEER_VERSION, 827 P2P_MEDIA_LOADER_PEER_VERSION,
824 ACTOR_IMAGES_SIZE, 828 ACTOR_IMAGES_SIZE,
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index cb58454cb..8dcff64e2 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -6,7 +6,7 @@ import { UserModel } from '../models/account/user'
6import { ApplicationModel } from '../models/application/application' 6import { ApplicationModel } from '../models/application/application'
7import { OAuthClientModel } from '../models/oauth/oauth-client' 7import { OAuthClientModel } from '../models/oauth/oauth-client'
8import { applicationExist, clientsExist, usersExist } from './checker-after-init' 8import { applicationExist, clientsExist, usersExist } from './checker-after-init'
9import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION } from './constants' 9import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION, RESUMABLE_UPLOAD_DIRECTORY } from './constants'
10import { sequelizeTypescript } from './database' 10import { sequelizeTypescript } from './database'
11import { ensureDir, remove } from 'fs-extra' 11import { ensureDir, remove } from 'fs-extra'
12import { CONFIG } from './config' 12import { CONFIG } from './config'
@@ -79,6 +79,9 @@ function createDirectoriesIfNotExist () {
79 // Playlist directories 79 // Playlist directories
80 tasks.push(ensureDir(HLS_STREAMING_PLAYLIST_DIRECTORY)) 80 tasks.push(ensureDir(HLS_STREAMING_PLAYLIST_DIRECTORY))
81 81
82 // Resumable upload directory
83 tasks.push(ensureDir(RESUMABLE_UPLOAD_DIRECTORY))
84
82 return Promise.all(tasks) 85 return Promise.all(tasks)
83} 86}
84 87
diff --git a/server/lib/moderation.ts b/server/lib/moderation.ts
index 5180b3299..925d64902 100644
--- a/server/lib/moderation.ts
+++ b/server/lib/moderation.ts
@@ -1,6 +1,8 @@
1import { VideoUploadFile } from 'express'
1import { PathLike } from 'fs-extra' 2import { PathLike } from 'fs-extra'
2import { Transaction } from 'sequelize/types' 3import { Transaction } from 'sequelize/types'
3import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger' 4import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger'
5import { afterCommitIfTransaction } from '@server/helpers/database-utils'
4import { logger } from '@server/helpers/logger' 6import { logger } from '@server/helpers/logger'
5import { AbuseModel } from '@server/models/abuse/abuse' 7import { AbuseModel } from '@server/models/abuse/abuse'
6import { VideoAbuseModel } from '@server/models/abuse/video-abuse' 8import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
@@ -28,7 +30,6 @@ import { VideoModel } from '../models/video/video'
28import { VideoCommentModel } from '../models/video/video-comment' 30import { VideoCommentModel } from '../models/video/video-comment'
29import { sendAbuse } from './activitypub/send/send-flag' 31import { sendAbuse } from './activitypub/send/send-flag'
30import { Notifier } from './notifier' 32import { Notifier } from './notifier'
31import { afterCommitIfTransaction } from '@server/helpers/database-utils'
32 33
33export type AcceptResult = { 34export type AcceptResult = {
34 accepted: boolean 35 accepted: boolean
@@ -38,7 +39,7 @@ export type AcceptResult = {
38// Can be filtered by plugins 39// Can be filtered by plugins
39function isLocalVideoAccepted (object: { 40function isLocalVideoAccepted (object: {
40 videoBody: VideoCreate 41 videoBody: VideoCreate
41 videoFile: Express.Multer.File & { duration?: number } 42 videoFile: VideoUploadFile
42 user: UserModel 43 user: UserModel
43}): AcceptResult { 44}): AcceptResult {
44 return { accepted: true } 45 return { accepted: true }
diff --git a/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts b/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
new file mode 100644
index 000000000..1acea7998
--- /dev/null
+++ b/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
@@ -0,0 +1,61 @@
1import * as bluebird from 'bluebird'
2import { readdir, remove, stat } from 'fs-extra'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { getResumableUploadPath } from '@server/helpers/upload'
5import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants'
6import { METAFILE_EXTNAME } from '@uploadx/core'
7import { AbstractScheduler } from './abstract-scheduler'
8
9const lTags = loggerTagsFactory('scheduler', 'resumable-upload', 'cleaner')
10
11export class RemoveDanglingResumableUploadsScheduler extends AbstractScheduler {
12
13 private static instance: AbstractScheduler
14 private lastExecutionTimeMs: number
15
16 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.removeDanglingResumableUploads
17
18 private constructor () {
19 super()
20
21 this.lastExecutionTimeMs = new Date().getTime()
22 }
23
24 protected async internalExecute () {
25 const path = getResumableUploadPath()
26 const files = await readdir(path)
27
28 const metafiles = files.filter(f => f.endsWith(METAFILE_EXTNAME))
29
30 if (metafiles.length === 0) return
31
32 logger.debug('Reading resumable video upload folder %s with %d files', path, metafiles.length, lTags())
33
34 try {
35 await bluebird.map(metafiles, metafile => {
36 return this.deleteIfOlderThan(metafile, this.lastExecutionTimeMs)
37 }, { concurrency: 5 })
38 } catch (error) {
39 logger.error('Failed to handle file during resumable video upload folder cleanup', { error, ...lTags() })
40 } finally {
41 this.lastExecutionTimeMs = new Date().getTime()
42 }
43 }
44
45 private async deleteIfOlderThan (metafile: string, olderThan: number) {
46 const metafilePath = getResumableUploadPath(metafile)
47 const statResult = await stat(metafilePath)
48
49 // Delete uploads that started since a long time
50 if (statResult.ctimeMs < olderThan) {
51 await remove(metafilePath)
52
53 const datafile = metafilePath.replace(new RegExp(`${METAFILE_EXTNAME}$`), '')
54 await remove(datafile)
55 }
56 }
57
58 static get Instance () {
59 return this.instance || (this.instance = new this())
60 }
61}
diff --git a/server/lib/video.ts b/server/lib/video.ts
index 9469b8178..21e4b7ff2 100644
--- a/server/lib/video.ts
+++ b/server/lib/video.ts
@@ -1,3 +1,4 @@
1import { UploadFiles } from 'express'
1import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
2import { DEFAULT_AUDIO_RESOLUTION, JOB_PRIORITY } from '@server/initializers/constants' 3import { DEFAULT_AUDIO_RESOLUTION, JOB_PRIORITY } from '@server/initializers/constants'
3import { sequelizeTypescript } from '@server/initializers/database' 4import { sequelizeTypescript } from '@server/initializers/database'
@@ -32,7 +33,7 @@ function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): Fil
32 33
33async function buildVideoThumbnailsFromReq (options: { 34async function buildVideoThumbnailsFromReq (options: {
34 video: MVideoThumbnail 35 video: MVideoThumbnail
35 files: { [fieldname: string]: Express.Multer.File[] } | Express.Multer.File[] 36 files: UploadFiles
36 fallback: (type: ThumbnailType) => Promise<MThumbnail> 37 fallback: (type: ThumbnailType) => Promise<MThumbnail>
37 automaticallyGenerated?: boolean 38 automaticallyGenerated?: boolean
38}) { 39}) {
diff --git a/server/middlewares/async.ts b/server/middlewares/async.ts
index 3d6e38809..0faa4fb8c 100644
--- a/server/middlewares/async.ts
+++ b/server/middlewares/async.ts
@@ -3,6 +3,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'
3import { ValidationChain } from 'express-validator' 3import { ValidationChain } from 'express-validator'
4import { ExpressPromiseHandler } from '@server/types/express' 4import { ExpressPromiseHandler } from '@server/types/express'
5import { retryTransactionWrapper } from '../helpers/database-utils' 5import { retryTransactionWrapper } from '../helpers/database-utils'
6import { HttpMethod, HttpStatusCode } from '@shared/core-utils'
6 7
7// Syntactic sugar to avoid try/catch in express controllers 8// Syntactic sugar to avoid try/catch in express controllers
8// Thanks: https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016 9// Thanks: https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index bb617d77c..d26bcd4a6 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -1,9 +1,10 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query, ValidationChain } from 'express-validator' 2import { body, header, param, query, ValidationChain } from 'express-validator'
3import { getResumableUploadPath } from '@server/helpers/upload'
3import { isAbleToUploadVideo } from '@server/lib/user' 4import { isAbleToUploadVideo } from '@server/lib/user'
4import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
5import { ExpressPromiseHandler } from '@server/types/express' 6import { ExpressPromiseHandler } from '@server/types/express'
6import { MVideoWithRights } from '@server/types/models' 7import { MUserAccountId, MVideoWithRights } from '@server/types/models'
7import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' 8import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
8import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 9import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
9import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' 10import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
@@ -47,6 +48,7 @@ import {
47 doesVideoExist, 48 doesVideoExist,
48 doesVideoFileOfVideoExist 49 doesVideoFileOfVideoExist
49} from '../../../helpers/middlewares' 50} from '../../../helpers/middlewares'
51import { deleteFileAndCatch } from '../../../helpers/utils'
50import { getVideoWithAttributes } from '../../../helpers/video' 52import { getVideoWithAttributes } from '../../../helpers/video'
51import { CONFIG } from '../../../initializers/config' 53import { CONFIG } from '../../../initializers/config'
52import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' 54import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
@@ -57,7 +59,7 @@ import { VideoModel } from '../../../models/video/video'
57import { authenticatePromiseIfNeeded } from '../../auth' 59import { authenticatePromiseIfNeeded } from '../../auth'
58import { areValidationErrors } from '../utils' 60import { areValidationErrors } from '../utils'
59 61
60const videosAddValidator = getCommonVideoEditAttributes().concat([ 62const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
61 body('videofile') 63 body('videofile')
62 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile')) 64 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
63 .withMessage('Should have a file'), 65 .withMessage('Should have a file'),
@@ -73,54 +75,117 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
73 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) 75 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
74 76
75 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 77 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
76 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
77 78
78 const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0] 79 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
79 const user = res.locals.oauth.token.User 80 const user = res.locals.oauth.token.User
80 81
81 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) 82 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
82
83 if (!isVideoFileMimeTypeValid(req.files)) {
84 res.status(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415)
85 .json({
86 error: 'This file is not supported. Please, make sure it is of the following type: ' +
87 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
88 })
89
90 return cleanUpReqFiles(req) 83 return cleanUpReqFiles(req)
91 } 84 }
92 85
93 if (!isVideoFileSizeValid(videoFile.size.toString())) { 86 try {
94 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413) 87 if (!videoFile.duration) await addDurationToVideo(videoFile)
95 .json({ 88 } catch (err) {
96 error: 'This file is too large.' 89 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
97 }) 90 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
91 .json({ error: 'Video file unreadable.' })
98 92
99 return cleanUpReqFiles(req) 93 return cleanUpReqFiles(req)
100 } 94 }
101 95
102 if (await isAbleToUploadVideo(user.id, videoFile.size) === false) { 96 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
103 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
104 .json({ error: 'The user video quota is exceeded with this video.' })
105 97
106 return cleanUpReqFiles(req) 98 return next()
107 } 99 }
100])
101
102/**
103 * Gets called after the last PUT request
104 */
105const videosAddResumableValidator = [
106 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
107 const user = res.locals.oauth.token.User
108
109 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
110 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename }
111
112 const cleanup = () => deleteFileAndCatch(file.path)
108 113
109 let duration: number 114 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
110 115
111 try { 116 try {
112 duration = await getDurationFromVideoFile(videoFile.path) 117 if (!file.duration) await addDurationToVideo(file)
113 } catch (err) { 118 } catch (err) {
114 logger.error('Invalid input file in videosAddValidator.', { err }) 119 logger.error('Invalid input file in videosAddResumableValidator.', { err })
115 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422) 120 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
116 .json({ error: 'Video file unreadable.' }) 121 .json({ error: 'Video file unreadable.' })
117 122
118 return cleanUpReqFiles(req) 123 return cleanup()
119 } 124 }
120 125
121 videoFile.duration = duration 126 if (!await isVideoAccepted(req, res, file)) return cleanup()
122 127
123 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req) 128 res.locals.videoFileResumable = file
129
130 return next()
131 }
132]
133
134/**
135 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
136 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
137 *
138 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
139 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
140 *
141 */
142const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
143 body('filename')
144 .isString()
145 .exists()
146 .withMessage('Should have a valid filename'),
147 body('name')
148 .trim()
149 .custom(isVideoNameValid)
150 .withMessage('Should have a valid name'),
151 body('channelId')
152 .customSanitizer(toIntOrNull)
153 .custom(isIdValid).withMessage('Should have correct video channel id'),
154
155 header('x-upload-content-length')
156 .isNumeric()
157 .exists()
158 .withMessage('Should specify the file length'),
159 header('x-upload-content-type')
160 .isString()
161 .exists()
162 .withMessage('Should specify the file mimetype'),
163
164 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
165 const videoFileMetadata = {
166 mimetype: req.headers['x-upload-content-type'] as string,
167 size: +req.headers['x-upload-content-length'],
168 originalname: req.body.name
169 }
170
171 const user = res.locals.oauth.token.User
172 const cleanup = () => cleanUpReqFiles(req)
173
174 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
175 parameters: req.body,
176 headers: req.headers,
177 files: req.files
178 })
179
180 if (areValidationErrors(req, res)) return cleanup()
181
182 const files = { videofile: [ videoFileMetadata ] }
183 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
184
185 // multer required unsetting the Content-Type, now we can set it for node-uploadx
186 req.headers['content-type'] = 'application/json; charset=utf-8'
187 // place previewfile in metadata so that uploadx saves it in .META
188 if (req.files['previewfile']) req.body.previewfile = req.files['previewfile']
124 189
125 return next() 190 return next()
126 } 191 }
@@ -478,7 +543,10 @@ const commonVideosFiltersValidator = [
478// --------------------------------------------------------------------------- 543// ---------------------------------------------------------------------------
479 544
480export { 545export {
481 videosAddValidator, 546 videosAddLegacyValidator,
547 videosAddResumableValidator,
548 videosAddResumableInitValidator,
549
482 videosUpdateValidator, 550 videosUpdateValidator,
483 videosGetValidator, 551 videosGetValidator,
484 videoFileMetadataGetValidator, 552 videoFileMetadataGetValidator,
@@ -515,7 +583,51 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response)
515 return false 583 return false
516} 584}
517 585
518async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) { 586async function commonVideoChecksPass (parameters: {
587 req: express.Request
588 res: express.Response
589 user: MUserAccountId
590 videoFileSize: number
591 files: express.UploadFilesForCheck
592}): Promise<boolean> {
593 const { req, res, user, videoFileSize, files } = parameters
594
595 if (areErrorsInScheduleUpdate(req, res)) return false
596
597 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
598
599 if (!isVideoFileMimeTypeValid(files)) {
600 res.status(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415)
601 .json({
602 error: 'This file is not supported. Please, make sure it is of the following type: ' +
603 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
604 })
605
606 return false
607 }
608
609 if (!isVideoFileSizeValid(videoFileSize.toString())) {
610 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
611 .json({ error: 'This file is too large. It exceeds the maximum file size authorized.' })
612
613 return false
614 }
615
616 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
617 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
618 .json({ error: 'The user video quota is exceeded with this video.' })
619
620 return false
621 }
622
623 return true
624}
625
626export async function isVideoAccepted (
627 req: express.Request,
628 res: express.Response,
629 videoFile: express.VideoUploadFile
630) {
519 // Check we accept this video 631 // Check we accept this video
520 const acceptParameters = { 632 const acceptParameters = {
521 videoBody: req.body, 633 videoBody: req.body,
@@ -538,3 +650,11 @@ async function isVideoAccepted (req: express.Request, res: express.Response, vid
538 650
539 return true 651 return true
540} 652}
653
654async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
655 const duration: number = await getDurationFromVideoFile(videoFile.path)
656
657 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
658
659 videoFile.duration = duration
660}
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index d0b0b9c21..143515838 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -13,6 +13,7 @@ import './plugins'
13import './redundancy' 13import './redundancy'
14import './search' 14import './search'
15import './services' 15import './services'
16import './upload-quota'
16import './user-notifications' 17import './user-notifications'
17import './user-subscriptions' 18import './user-subscriptions'
18import './users' 19import './users'
diff --git a/server/tests/api/check-params/upload-quota.ts b/server/tests/api/check-params/upload-quota.ts
new file mode 100644
index 000000000..d0fbec415
--- /dev/null
+++ b/server/tests/api/check-params/upload-quota.ts
@@ -0,0 +1,152 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import { expect } from 'chai'
5import { HttpStatusCode, randomInt } from '@shared/core-utils'
6import { getGoodVideoUrl, getMagnetURI, getMyVideoImports, importVideo } from '@shared/extra-utils/videos/video-imports'
7import { MyUser, VideoImport, VideoImportState, VideoPrivacy } from '@shared/models'
8import {
9 cleanupTests,
10 flushAndRunServer,
11 getMyUserInformation,
12 immutableAssign,
13 registerUser,
14 ServerInfo,
15 setAccessTokensToServers,
16 setDefaultVideoChannel,
17 updateUser,
18 uploadVideo,
19 userLogin,
20 waitJobs
21} from '../../../../shared/extra-utils'
22
23describe('Test upload quota', function () {
24 let server: ServerInfo
25 let rootId: number
26
27 // ---------------------------------------------------------------
28
29 before(async function () {
30 this.timeout(30000)
31
32 server = await flushAndRunServer(1)
33 await setAccessTokensToServers([ server ])
34 await setDefaultVideoChannel([ server ])
35
36 const res = await getMyUserInformation(server.url, server.accessToken)
37 rootId = (res.body as MyUser).id
38
39 await updateUser({
40 url: server.url,
41 userId: rootId,
42 accessToken: server.accessToken,
43 videoQuota: 42
44 })
45 })
46
47 describe('When having a video quota', function () {
48
49 it('Should fail with a registered user having too many videos with legacy upload', async function () {
50 this.timeout(30000)
51
52 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
53 await registerUser(server.url, user.username, user.password)
54 const userAccessToken = await userLogin(server, user)
55
56 const videoAttributes = { fixture: 'video_short2.webm' }
57 for (let i = 0; i < 5; i++) {
58 await uploadVideo(server.url, userAccessToken, videoAttributes)
59 }
60
61 await uploadVideo(server.url, userAccessToken, videoAttributes, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'legacy')
62 })
63
64 it('Should fail with a registered user having too many videos with resumable upload', async function () {
65 this.timeout(30000)
66
67 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
68 await registerUser(server.url, user.username, user.password)
69 const userAccessToken = await userLogin(server, user)
70
71 const videoAttributes = { fixture: 'video_short2.webm' }
72 for (let i = 0; i < 5; i++) {
73 await uploadVideo(server.url, userAccessToken, videoAttributes)
74 }
75
76 await uploadVideo(server.url, userAccessToken, videoAttributes, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'resumable')
77 })
78
79 it('Should fail to import with HTTP/Torrent/magnet', async function () {
80 this.timeout(120000)
81
82 const baseAttributes = {
83 channelId: server.videoChannel.id,
84 privacy: VideoPrivacy.PUBLIC
85 }
86 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { targetUrl: getGoodVideoUrl() }))
87 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { magnetUri: getMagnetURI() }))
88 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { torrentfile: 'video-720p.torrent' as any }))
89
90 await waitJobs([ server ])
91
92 const res = await getMyVideoImports(server.url, server.accessToken)
93
94 expect(res.body.total).to.equal(3)
95 const videoImports: VideoImport[] = res.body.data
96 expect(videoImports).to.have.lengthOf(3)
97
98 for (const videoImport of videoImports) {
99 expect(videoImport.state.id).to.equal(VideoImportState.FAILED)
100 expect(videoImport.error).not.to.be.undefined
101 expect(videoImport.error).to.contain('user video quota is exceeded')
102 }
103 })
104 })
105
106 describe('When having a daily video quota', function () {
107
108 it('Should fail with a user having too many videos daily', async function () {
109 await updateUser({
110 url: server.url,
111 userId: rootId,
112 accessToken: server.accessToken,
113 videoQuotaDaily: 42
114 })
115
116 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'legacy')
117 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'resumable')
118 })
119 })
120
121 describe('When having an absolute and daily video quota', function () {
122 it('Should fail if exceeding total quota', async function () {
123 await updateUser({
124 url: server.url,
125 userId: rootId,
126 accessToken: server.accessToken,
127 videoQuota: 42,
128 videoQuotaDaily: 1024 * 1024 * 1024
129 })
130
131 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'legacy')
132 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'resumable')
133 })
134
135 it('Should fail if exceeding daily quota', async function () {
136 await updateUser({
137 url: server.url,
138 userId: rootId,
139 accessToken: server.accessToken,
140 videoQuota: 1024 * 1024 * 1024,
141 videoQuotaDaily: 42
142 })
143
144 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'legacy')
145 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'resumable')
146 })
147 })
148
149 after(async function () {
150 await cleanupTests([ server ])
151 })
152})
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index 2b03fde2d..dcff0d52b 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -1,10 +1,10 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4import { expect } from 'chai'
5import { omit } from 'lodash' 4import { omit } from 'lodash'
6import { join } from 'path' 5import { join } from 'path'
7import { User, UserRole, VideoImport, VideoImportState } from '../../../../shared' 6import { User, UserRole } from '../../../../shared'
7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
8import { 8import {
9 addVideoChannel, 9 addVideoChannel,
10 blockUser, 10 blockUser,
@@ -29,7 +29,6 @@ import {
29 ServerInfo, 29 ServerInfo,
30 setAccessTokensToServers, 30 setAccessTokensToServers,
31 unblockUser, 31 unblockUser,
32 updateUser,
33 uploadVideo, 32 uploadVideo,
34 userLogin 33 userLogin
35} from '../../../../shared/extra-utils' 34} from '../../../../shared/extra-utils'
@@ -39,11 +38,7 @@ import {
39 checkBadSortPagination, 38 checkBadSortPagination,
40 checkBadStartPagination 39 checkBadStartPagination
41} from '../../../../shared/extra-utils/requests/check-api-params' 40} from '../../../../shared/extra-utils/requests/check-api-params'
42import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
43import { getGoodVideoUrl, getMagnetURI, getMyVideoImports, importVideo } from '../../../../shared/extra-utils/videos/video-imports'
44import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model' 41import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model'
45import { VideoPrivacy } from '../../../../shared/models/videos'
46import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
47 42
48describe('Test users API validators', function () { 43describe('Test users API validators', function () {
49 const path = '/api/v1/users/' 44 const path = '/api/v1/users/'
@@ -1093,102 +1088,6 @@ describe('Test users API validators', function () {
1093 }) 1088 })
1094 }) 1089 })
1095 1090
1096 describe('When having a video quota', function () {
1097 it('Should fail with a user having too many videos', async function () {
1098 await updateUser({
1099 url: server.url,
1100 userId: rootId,
1101 accessToken: server.accessToken,
1102 videoQuota: 42
1103 })
1104
1105 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1106 })
1107
1108 it('Should fail with a registered user having too many videos', async function () {
1109 this.timeout(30000)
1110
1111 const user = {
1112 username: 'user3',
1113 password: 'my super password'
1114 }
1115 userAccessToken = await userLogin(server, user)
1116
1117 const videoAttributes = { fixture: 'video_short2.webm' }
1118 await uploadVideo(server.url, userAccessToken, videoAttributes)
1119 await uploadVideo(server.url, userAccessToken, videoAttributes)
1120 await uploadVideo(server.url, userAccessToken, videoAttributes)
1121 await uploadVideo(server.url, userAccessToken, videoAttributes)
1122 await uploadVideo(server.url, userAccessToken, videoAttributes)
1123 await uploadVideo(server.url, userAccessToken, videoAttributes, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1124 })
1125
1126 it('Should fail to import with HTTP/Torrent/magnet', async function () {
1127 this.timeout(120000)
1128
1129 const baseAttributes = {
1130 channelId: 1,
1131 privacy: VideoPrivacy.PUBLIC
1132 }
1133 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { targetUrl: getGoodVideoUrl() }))
1134 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { magnetUri: getMagnetURI() }))
1135 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { torrentfile: 'video-720p.torrent' as any }))
1136
1137 await waitJobs([ server ])
1138
1139 const res = await getMyVideoImports(server.url, server.accessToken)
1140
1141 expect(res.body.total).to.equal(3)
1142 const videoImports: VideoImport[] = res.body.data
1143 expect(videoImports).to.have.lengthOf(3)
1144
1145 for (const videoImport of videoImports) {
1146 expect(videoImport.state.id).to.equal(VideoImportState.FAILED)
1147 expect(videoImport.error).not.to.be.undefined
1148 expect(videoImport.error).to.contain('user video quota is exceeded')
1149 }
1150 })
1151 })
1152
1153 describe('When having a daily video quota', function () {
1154 it('Should fail with a user having too many videos daily', async function () {
1155 await updateUser({
1156 url: server.url,
1157 userId: rootId,
1158 accessToken: server.accessToken,
1159 videoQuotaDaily: 42
1160 })
1161
1162 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1163 })
1164 })
1165
1166 describe('When having an absolute and daily video quota', function () {
1167 it('Should fail if exceeding total quota', async function () {
1168 await updateUser({
1169 url: server.url,
1170 userId: rootId,
1171 accessToken: server.accessToken,
1172 videoQuota: 42,
1173 videoQuotaDaily: 1024 * 1024 * 1024
1174 })
1175
1176 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1177 })
1178
1179 it('Should fail if exceeding daily quota', async function () {
1180 await updateUser({
1181 url: server.url,
1182 userId: rootId,
1183 accessToken: server.accessToken,
1184 videoQuota: 1024 * 1024 * 1024,
1185 videoQuotaDaily: 42
1186 })
1187
1188 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1189 })
1190 })
1191
1192 describe('When asking a password reset', function () { 1091 describe('When asking a password reset', function () {
1193 const path = '/api/v1/users/ask-reset-password' 1092 const path = '/api/v1/users/ask-reset-password'
1194 1093
diff --git a/server/tests/api/check-params/videos.ts b/server/tests/api/check-params/videos.ts
index 188d1835c..c970c4a15 100644
--- a/server/tests/api/check-params/videos.ts
+++ b/server/tests/api/check-params/videos.ts
@@ -1,11 +1,12 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha'
3import * as chai from 'chai' 4import * as chai from 'chai'
4import { omit } from 'lodash' 5import { omit } from 'lodash'
5import 'mocha'
6import { join } from 'path' 6import { join } from 'path'
7import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' 7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
8import { 8import {
9 checkUploadVideoParam,
9 cleanupTests, 10 cleanupTests,
10 createUser, 11 createUser,
11 flushAndRunServer, 12 flushAndRunServer,
@@ -18,17 +19,18 @@ import {
18 makePutBodyRequest, 19 makePutBodyRequest,
19 makeUploadRequest, 20 makeUploadRequest,
20 removeVideo, 21 removeVideo,
22 root,
21 ServerInfo, 23 ServerInfo,
22 setAccessTokensToServers, 24 setAccessTokensToServers,
23 userLogin, 25 userLogin
24 root
25} from '../../../../shared/extra-utils' 26} from '../../../../shared/extra-utils'
26import { 27import {
27 checkBadCountPagination, 28 checkBadCountPagination,
28 checkBadSortPagination, 29 checkBadSortPagination,
29 checkBadStartPagination 30 checkBadStartPagination
30} from '../../../../shared/extra-utils/requests/check-api-params' 31} from '../../../../shared/extra-utils/requests/check-api-params'
31import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 32import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum'
33import { randomInt } from '@shared/core-utils'
32 34
33const expect = chai.expect 35const expect = chai.expect
34 36
@@ -183,7 +185,7 @@ describe('Test videos API validator', function () {
183 describe('When adding a video', function () { 185 describe('When adding a video', function () {
184 let baseCorrectParams 186 let baseCorrectParams
185 const baseCorrectAttaches = { 187 const baseCorrectAttaches = {
186 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm') 188 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm')
187 } 189 }
188 190
189 before(function () { 191 before(function () {
@@ -206,256 +208,243 @@ describe('Test videos API validator', function () {
206 } 208 }
207 }) 209 })
208 210
209 it('Should fail with nothing', async function () { 211 function runSuite (mode: 'legacy' | 'resumable') {
210 const fields = {}
211 const attaches = {}
212 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
213 })
214 212
215 it('Should fail without name', async function () { 213 it('Should fail with nothing', async function () {
216 const fields = omit(baseCorrectParams, 'name') 214 const fields = {}
217 const attaches = baseCorrectAttaches 215 const attaches = {}
216 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
217 })
218 218
219 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 219 it('Should fail without name', async function () {
220 }) 220 const fields = omit(baseCorrectParams, 'name')
221 const attaches = baseCorrectAttaches
221 222
222 it('Should fail with a long name', async function () { 223 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
223 const fields = immutableAssign(baseCorrectParams, { name: 'super'.repeat(65) }) 224 })
224 const attaches = baseCorrectAttaches
225 225
226 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 226 it('Should fail with a long name', async function () {
227 }) 227 const fields = immutableAssign(baseCorrectParams, { name: 'super'.repeat(65) })
228 const attaches = baseCorrectAttaches
228 229
229 it('Should fail with a bad category', async function () { 230 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
230 const fields = immutableAssign(baseCorrectParams, { category: 125 }) 231 })
231 const attaches = baseCorrectAttaches
232 232
233 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 233 it('Should fail with a bad category', async function () {
234 }) 234 const fields = immutableAssign(baseCorrectParams, { category: 125 })
235 const attaches = baseCorrectAttaches
235 236
236 it('Should fail with a bad licence', async function () { 237 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
237 const fields = immutableAssign(baseCorrectParams, { licence: 125 }) 238 })
238 const attaches = baseCorrectAttaches
239 239
240 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 240 it('Should fail with a bad licence', async function () {
241 }) 241 const fields = immutableAssign(baseCorrectParams, { licence: 125 })
242 const attaches = baseCorrectAttaches
242 243
243 it('Should fail with a bad language', async function () { 244 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
244 const fields = immutableAssign(baseCorrectParams, { language: 'a'.repeat(15) }) 245 })
245 const attaches = baseCorrectAttaches
246 246
247 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 247 it('Should fail with a bad language', async function () {
248 }) 248 const fields = immutableAssign(baseCorrectParams, { language: 'a'.repeat(15) })
249 const attaches = baseCorrectAttaches
249 250
250 it('Should fail with a long description', async function () { 251 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
251 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(2500) }) 252 })
252 const attaches = baseCorrectAttaches
253 253
254 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 254 it('Should fail with a long description', async function () {
255 }) 255 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(2500) })
256 const attaches = baseCorrectAttaches
256 257
257 it('Should fail with a long support text', async function () { 258 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
258 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) }) 259 })
259 const attaches = baseCorrectAttaches
260 260
261 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 261 it('Should fail with a long support text', async function () {
262 }) 262 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) })
263 const attaches = baseCorrectAttaches
263 264
264 it('Should fail without a channel', async function () { 265 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
265 const fields = omit(baseCorrectParams, 'channelId') 266 })
266 const attaches = baseCorrectAttaches
267 267
268 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 268 it('Should fail without a channel', async function () {
269 }) 269 const fields = omit(baseCorrectParams, 'channelId')
270 const attaches = baseCorrectAttaches
270 271
271 it('Should fail with a bad channel', async function () { 272 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
272 const fields = immutableAssign(baseCorrectParams, { channelId: 545454 }) 273 })
273 const attaches = baseCorrectAttaches
274 274
275 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 275 it('Should fail with a bad channel', async function () {
276 }) 276 const fields = immutableAssign(baseCorrectParams, { channelId: 545454 })
277 const attaches = baseCorrectAttaches
277 278
278 it('Should fail with another user channel', async function () { 279 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
279 const user = { 280 })
280 username: 'fake',
281 password: 'fake_password'
282 }
283 await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
284 281
285 const accessTokenUser = await userLogin(server, user) 282 it('Should fail with another user channel', async function () {
286 const res = await getMyUserInformation(server.url, accessTokenUser) 283 const user = {
287 const customChannelId = res.body.videoChannels[0].id 284 username: 'fake' + randomInt(0, 1500),
285 password: 'fake_password'
286 }
287 await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
288 288
289 const fields = immutableAssign(baseCorrectParams, { channelId: customChannelId }) 289 const accessTokenUser = await userLogin(server, user)
290 const attaches = baseCorrectAttaches 290 const res = await getMyUserInformation(server.url, accessTokenUser)
291 const customChannelId = res.body.videoChannels[0].id
291 292
292 await makeUploadRequest({ url: server.url, path: path + '/upload', token: userAccessToken, fields, attaches }) 293 const fields = immutableAssign(baseCorrectParams, { channelId: customChannelId })
293 }) 294 const attaches = baseCorrectAttaches
294 295
295 it('Should fail with too many tags', async function () { 296 await checkUploadVideoParam(server.url, userAccessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
296 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] }) 297 })
297 const attaches = baseCorrectAttaches
298 298
299 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 299 it('Should fail with too many tags', async function () {
300 }) 300 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] })
301 const attaches = baseCorrectAttaches
301 302
302 it('Should fail with a tag length too low', async function () { 303 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
303 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 't' ] }) 304 })
304 const attaches = baseCorrectAttaches
305 305
306 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 306 it('Should fail with a tag length too low', async function () {
307 }) 307 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 't' ] })
308 const attaches = baseCorrectAttaches
308 309
309 it('Should fail with a tag length too big', async function () { 310 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
310 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] }) 311 })
311 const attaches = baseCorrectAttaches
312 312
313 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 313 it('Should fail with a tag length too big', async function () {
314 }) 314 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] })
315 const attaches = baseCorrectAttaches
315 316
316 it('Should fail with a bad schedule update (miss updateAt)', async function () { 317 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
317 const fields = immutableAssign(baseCorrectParams, { 'scheduleUpdate[privacy]': VideoPrivacy.PUBLIC }) 318 })
318 const attaches = baseCorrectAttaches
319 319
320 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 320 it('Should fail with a bad schedule update (miss updateAt)', async function () {
321 }) 321 const fields = immutableAssign(baseCorrectParams, { scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } })
322 const attaches = baseCorrectAttaches
322 323
323 it('Should fail with a bad schedule update (wrong updateAt)', async function () { 324 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
324 const fields = immutableAssign(baseCorrectParams, {
325 'scheduleUpdate[privacy]': VideoPrivacy.PUBLIC,
326 'scheduleUpdate[updateAt]': 'toto'
327 }) 325 })
328 const attaches = baseCorrectAttaches
329 326
330 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 327 it('Should fail with a bad schedule update (wrong updateAt)', async function () {
331 }) 328 const fields = immutableAssign(baseCorrectParams, {
329 scheduleUpdate: {
330 privacy: VideoPrivacy.PUBLIC,
331 updateAt: 'toto'
332 }
333 })
334 const attaches = baseCorrectAttaches
332 335
333 it('Should fail with a bad originally published at attribute', async function () { 336 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
334 const fields = immutableAssign(baseCorrectParams, { originallyPublishedAt: 'toto' }) 337 })
335 const attaches = baseCorrectAttaches
336 338
337 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 339 it('Should fail with a bad originally published at attribute', async function () {
338 }) 340 const fields = immutableAssign(baseCorrectParams, { originallyPublishedAt: 'toto' })
341 const attaches = baseCorrectAttaches
339 342
340 it('Should fail without an input file', async function () { 343 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
341 const fields = baseCorrectParams 344 })
342 const attaches = {}
343 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
344 })
345 345
346 it('Should fail with an incorrect input file', async function () { 346 it('Should fail without an input file', async function () {
347 const fields = baseCorrectParams 347 const fields = baseCorrectParams
348 let attaches = { 348 const attaches = {}
349 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short_fake.webm') 349 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
350 }
351 await makeUploadRequest({
352 url: server.url,
353 path: path + '/upload',
354 token: server.accessToken,
355 fields,
356 attaches,
357 statusCodeExpected: HttpStatusCode.UNPROCESSABLE_ENTITY_422
358 }) 350 })
359 351
360 attaches = { 352 it('Should fail with an incorrect input file', async function () {
361 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mkv') 353 const fields = baseCorrectParams
362 } 354 let attaches = { fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short_fake.webm') }
363 await makeUploadRequest({ 355
364 url: server.url, 356 await checkUploadVideoParam(
365 path: path + '/upload', 357 server.url,
366 token: server.accessToken, 358 server.accessToken,
367 fields, 359 { ...fields, ...attaches },
368 attaches, 360 HttpStatusCode.UNPROCESSABLE_ENTITY_422,
369 statusCodeExpected: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 361 mode
362 )
363
364 attaches = { fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mkv') }
365 await checkUploadVideoParam(
366 server.url,
367 server.accessToken,
368 { ...fields, ...attaches },
369 HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
370 mode
371 )
370 }) 372 })
371 })
372 373
373 it('Should fail with an incorrect thumbnail file', async function () { 374 it('Should fail with an incorrect thumbnail file', async function () {
374 const fields = baseCorrectParams 375 const fields = baseCorrectParams
375 const attaches = { 376 const attaches = {
376 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4'), 377 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4'),
377 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 378 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
378 } 379 }
379 380
380 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 381 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
381 }) 382 })
382 383
383 it('Should fail with a big thumbnail file', async function () { 384 it('Should fail with a big thumbnail file', async function () {
384 const fields = baseCorrectParams 385 const fields = baseCorrectParams
385 const attaches = { 386 const attaches = {
386 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'), 387 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'),
387 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 388 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
388 } 389 }
389 390
390 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 391 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
391 }) 392 })
392 393
393 it('Should fail with an incorrect preview file', async function () { 394 it('Should fail with an incorrect preview file', async function () {
394 const fields = baseCorrectParams 395 const fields = baseCorrectParams
395 const attaches = { 396 const attaches = {
396 previewfile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4'), 397 previewfile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4'),
397 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 398 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
398 } 399 }
399 400
400 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 401 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
401 }) 402 })
402 403
403 it('Should fail with a big preview file', async function () { 404 it('Should fail with a big preview file', async function () {
404 const fields = baseCorrectParams 405 const fields = baseCorrectParams
405 const attaches = { 406 const attaches = {
406 previewfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'), 407 previewfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'),
407 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 408 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
408 } 409 }
409 410
410 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 411 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
411 }) 412 })
412 413
413 it('Should succeed with the correct parameters', async function () { 414 it('Should succeed with the correct parameters', async function () {
414 this.timeout(10000) 415 this.timeout(10000)
415 416
416 const fields = baseCorrectParams 417 const fields = baseCorrectParams
417 418
418 { 419 {
419 const attaches = baseCorrectAttaches 420 const attaches = baseCorrectAttaches
420 await makeUploadRequest({ 421 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.OK_200, mode)
421 url: server.url, 422 }
422 path: path + '/upload',
423 token: server.accessToken,
424 fields,
425 attaches,
426 statusCodeExpected: HttpStatusCode.OK_200
427 })
428 }
429 423
430 { 424 {
431 const attaches = immutableAssign(baseCorrectAttaches, { 425 const attaches = immutableAssign(baseCorrectAttaches, {
432 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 426 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
433 }) 427 })
434 428
435 await makeUploadRequest({ 429 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.OK_200, mode)
436 url: server.url, 430 }
437 path: path + '/upload',
438 token: server.accessToken,
439 fields,
440 attaches,
441 statusCodeExpected: HttpStatusCode.OK_200
442 })
443 }
444 431
445 { 432 {
446 const attaches = immutableAssign(baseCorrectAttaches, { 433 const attaches = immutableAssign(baseCorrectAttaches, {
447 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.ogv') 434 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.ogv')
448 }) 435 })
449 436
450 await makeUploadRequest({ 437 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.OK_200, mode)
451 url: server.url, 438 }
452 path: path + '/upload', 439 })
453 token: server.accessToken, 440 }
454 fields, 441
455 attaches, 442 describe('Resumable upload', function () {
456 statusCodeExpected: HttpStatusCode.OK_200 443 runSuite('resumable')
457 }) 444 })
458 } 445
446 describe('Legacy upload', function () {
447 runSuite('legacy')
459 }) 448 })
460 }) 449 })
461 450
@@ -678,7 +667,7 @@ describe('Test videos API validator', function () {
678 }) 667 })
679 668
680 expect(res.body.data).to.be.an('array') 669 expect(res.body.data).to.be.an('array')
681 expect(res.body.data.length).to.equal(3) 670 expect(res.body.data.length).to.equal(6)
682 }) 671 })
683 672
684 it('Should fail without a correct uuid', async function () { 673 it('Should fail without a correct uuid', async function () {
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts
index fc8b447b7..5c07f8926 100644
--- a/server/tests/api/videos/index.ts
+++ b/server/tests/api/videos/index.ts
@@ -1,5 +1,6 @@
1import './audio-only' 1import './audio-only'
2import './multiple-servers' 2import './multiple-servers'
3import './resumable-upload'
3import './single-server' 4import './single-server'
4import './video-captions' 5import './video-captions'
5import './video-change-ownership' 6import './video-change-ownership'
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index 55e280e9f..41cd814e0 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -181,7 +181,7 @@ describe('Test multiple servers', function () {
181 thumbnailfile: 'thumbnail.jpg', 181 thumbnailfile: 'thumbnail.jpg',
182 previewfile: 'preview.jpg' 182 previewfile: 'preview.jpg'
183 } 183 }
184 await uploadVideo(servers[1].url, userAccessToken, videoAttributes) 184 await uploadVideo(servers[1].url, userAccessToken, videoAttributes, HttpStatusCode.OK_200, 'resumable')
185 185
186 // Transcoding 186 // Transcoding
187 await waitJobs(servers) 187 await waitJobs(servers)
diff --git a/server/tests/api/videos/resumable-upload.ts b/server/tests/api/videos/resumable-upload.ts
new file mode 100644
index 000000000..af9221c43
--- /dev/null
+++ b/server/tests/api/videos/resumable-upload.ts
@@ -0,0 +1,187 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { pathExists, readdir, stat } from 'fs-extra'
6import { join } from 'path'
7import { HttpStatusCode } from '@shared/core-utils'
8import {
9 buildAbsoluteFixturePath,
10 buildServerDirectory,
11 flushAndRunServer,
12 getMyUserInformation,
13 prepareResumableUpload,
14 sendDebugCommand,
15 sendResumableChunks,
16 ServerInfo,
17 setAccessTokensToServers,
18 setDefaultVideoChannel,
19 updateUser
20} from '@shared/extra-utils'
21import { MyUser, VideoPrivacy } from '@shared/models'
22
23const expect = chai.expect
24
25// Most classic resumable upload tests are done in other test suites
26
27describe('Test resumable upload', function () {
28 const defaultFixture = 'video_short.mp4'
29 let server: ServerInfo
30 let rootId: number
31
32 async function buildSize (fixture: string, size?: number) {
33 if (size !== undefined) return size
34
35 const baseFixture = buildAbsoluteFixturePath(fixture)
36 return (await stat(baseFixture)).size
37 }
38
39 async function prepareUpload (sizeArg?: number) {
40 const size = await buildSize(defaultFixture, sizeArg)
41
42 const attributes = {
43 name: 'video',
44 channelId: server.videoChannel.id,
45 privacy: VideoPrivacy.PUBLIC,
46 fixture: defaultFixture
47 }
48
49 const mimetype = 'video/mp4'
50
51 const res = await prepareResumableUpload({ url: server.url, token: server.accessToken, attributes, size, mimetype })
52
53 return res.header['location'].split('?')[1]
54 }
55
56 async function sendChunks (options: {
57 pathUploadId: string
58 size?: number
59 expectedStatus?: HttpStatusCode
60 contentLength?: number
61 contentRange?: string
62 contentRangeBuilder?: (start: number, chunk: any) => string
63 }) {
64 const { pathUploadId, expectedStatus, contentLength, contentRangeBuilder } = options
65
66 const size = await buildSize(defaultFixture, options.size)
67 const absoluteFilePath = buildAbsoluteFixturePath(defaultFixture)
68
69 return sendResumableChunks({
70 url: server.url,
71 token: server.accessToken,
72 pathUploadId,
73 videoFilePath: absoluteFilePath,
74 size,
75 contentLength,
76 contentRangeBuilder,
77 specialStatus: expectedStatus
78 })
79 }
80
81 async function checkFileSize (uploadIdArg: string, expectedSize: number | null) {
82 const uploadId = uploadIdArg.replace(/^upload_id=/, '')
83
84 const subPath = join('tmp', 'resumable-uploads', uploadId)
85 const filePath = buildServerDirectory(server, subPath)
86 const exists = await pathExists(filePath)
87
88 if (expectedSize === null) {
89 expect(exists).to.be.false
90 return
91 }
92
93 expect(exists).to.be.true
94
95 expect((await stat(filePath)).size).to.equal(expectedSize)
96 }
97
98 async function countResumableUploads () {
99 const subPath = join('tmp', 'resumable-uploads')
100 const filePath = buildServerDirectory(server, subPath)
101
102 const files = await readdir(filePath)
103 return files.length
104 }
105
106 before(async function () {
107 this.timeout(30000)
108
109 server = await flushAndRunServer(1)
110 await setAccessTokensToServers([ server ])
111 await setDefaultVideoChannel([ server ])
112
113 const res = await getMyUserInformation(server.url, server.accessToken)
114 rootId = (res.body as MyUser).id
115
116 await updateUser({
117 url: server.url,
118 userId: rootId,
119 accessToken: server.accessToken,
120 videoQuota: 10_000_000
121 })
122 })
123
124 describe('Directory cleaning', function () {
125
126 it('Should correctly delete files after an upload', async function () {
127 const uploadId = await prepareUpload()
128 await sendChunks({ pathUploadId: uploadId })
129
130 expect(await countResumableUploads()).to.equal(0)
131 })
132
133 it('Should not delete files after an unfinished upload', async function () {
134 await prepareUpload()
135
136 expect(await countResumableUploads()).to.equal(2)
137 })
138
139 it('Should not delete recent uploads', async function () {
140 await sendDebugCommand(server.url, server.accessToken, { command: 'remove-dandling-resumable-uploads' })
141
142 expect(await countResumableUploads()).to.equal(2)
143 })
144
145 it('Should delete old uploads', async function () {
146 await sendDebugCommand(server.url, server.accessToken, { command: 'remove-dandling-resumable-uploads' })
147
148 expect(await countResumableUploads()).to.equal(0)
149 })
150 })
151
152 describe('Resumable upload and chunks', function () {
153
154 it('Should accept the same amount of chunks', async function () {
155 const uploadId = await prepareUpload()
156 await sendChunks({ pathUploadId: uploadId })
157
158 await checkFileSize(uploadId, null)
159 })
160
161 it('Should not accept more chunks than expected', async function () {
162 const size = 100
163 const uploadId = await prepareUpload(size)
164
165 await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.CONFLICT_409 })
166 await checkFileSize(uploadId, 0)
167 })
168
169 it('Should not accept more chunks than expected with an invalid content length/content range', async function () {
170 const uploadId = await prepareUpload(1500)
171
172 await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.BAD_REQUEST_400, contentLength: 1000 })
173 await checkFileSize(uploadId, 0)
174 })
175
176 it('Should not accept more chunks than expected with an invalid content length', async function () {
177 const uploadId = await prepareUpload(500)
178
179 const size = 1000
180
181 const contentRangeBuilder = start => `bytes ${start}-${start + size - 1}/${size}`
182 await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.BAD_REQUEST_400, contentRangeBuilder, contentLength: size })
183 await checkFileSize(uploadId, 0)
184 })
185 })
186
187})
diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts
index a79648bf7..1058a1e9c 100644
--- a/server/tests/api/videos/single-server.ts
+++ b/server/tests/api/videos/single-server.ts
@@ -1,9 +1,9 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha'
3import * as chai from 'chai' 4import * as chai from 'chai'
4import { keyBy } from 'lodash' 5import { keyBy } from 'lodash'
5import 'mocha' 6
6import { VideoPrivacy } from '../../../../shared/models/videos'
7import { 7import {
8 checkVideoFilesWereRemoved, 8 checkVideoFilesWereRemoved,
9 cleanupTests, 9 cleanupTests,
@@ -28,430 +28,432 @@ import {
28 viewVideo, 28 viewVideo,
29 wait 29 wait
30} from '../../../../shared/extra-utils' 30} from '../../../../shared/extra-utils'
31import { VideoPrivacy } from '../../../../shared/models/videos'
32import { HttpStatusCode } from '@shared/core-utils'
31 33
32const expect = chai.expect 34const expect = chai.expect
33 35
34describe('Test a single server', function () { 36describe('Test a single server', function () {
35 let server: ServerInfo = null
36 let videoId = -1
37 let videoId2 = -1
38 let videoUUID = ''
39 let videosListBase: any[] = null
40
41 const getCheckAttributes = () => ({
42 name: 'my super name',
43 category: 2,
44 licence: 6,
45 language: 'zh',
46 nsfw: true,
47 description: 'my super description',
48 support: 'my super support text',
49 account: {
50 name: 'root',
51 host: 'localhost:' + server.port
52 },
53 isLocal: true,
54 duration: 5,
55 tags: [ 'tag1', 'tag2', 'tag3' ],
56 privacy: VideoPrivacy.PUBLIC,
57 commentsEnabled: true,
58 downloadEnabled: true,
59 channel: {
60 displayName: 'Main root channel',
61 name: 'root_channel',
62 description: '',
63 isLocal: true
64 },
65 fixture: 'video_short.webm',
66 files: [
67 {
68 resolution: 720,
69 size: 218910
70 }
71 ]
72 })
73
74 const updateCheckAttributes = () => ({
75 name: 'my super video updated',
76 category: 4,
77 licence: 2,
78 language: 'ar',
79 nsfw: false,
80 description: 'my super description updated',
81 support: 'my super support text updated',
82 account: {
83 name: 'root',
84 host: 'localhost:' + server.port
85 },
86 isLocal: true,
87 tags: [ 'tagup1', 'tagup2' ],
88 privacy: VideoPrivacy.PUBLIC,
89 duration: 5,
90 commentsEnabled: false,
91 downloadEnabled: false,
92 channel: {
93 name: 'root_channel',
94 displayName: 'Main root channel',
95 description: '',
96 isLocal: true
97 },
98 fixture: 'video_short3.webm',
99 files: [
100 {
101 resolution: 720,
102 size: 292677
103 }
104 ]
105 })
106
107 before(async function () {
108 this.timeout(30000)
109
110 server = await flushAndRunServer(1)
111
112 await setAccessTokensToServers([ server ])
113 })
114
115 it('Should list video categories', async function () {
116 const res = await getVideoCategories(server.url)
117
118 const categories = res.body
119 expect(Object.keys(categories)).to.have.length.above(10)
120
121 expect(categories[11]).to.equal('News & Politics')
122 })
123
124 it('Should list video licences', async function () {
125 const res = await getVideoLicences(server.url)
126
127 const licences = res.body
128 expect(Object.keys(licences)).to.have.length.above(5)
129
130 expect(licences[3]).to.equal('Attribution - No Derivatives')
131 })
132
133 it('Should list video languages', async function () {
134 const res = await getVideoLanguages(server.url)
135
136 const languages = res.body
137 expect(Object.keys(languages)).to.have.length.above(5)
138
139 expect(languages['ru']).to.equal('Russian')
140 })
141
142 it('Should list video privacies', async function () {
143 const res = await getVideoPrivacies(server.url)
144
145 const privacies = res.body
146 expect(Object.keys(privacies)).to.have.length.at.least(3)
147
148 expect(privacies[3]).to.equal('Private')
149 })
150
151 it('Should not have videos', async function () {
152 const res = await getVideosList(server.url)
153
154 expect(res.body.total).to.equal(0)
155 expect(res.body.data).to.be.an('array')
156 expect(res.body.data.length).to.equal(0)
157 })
158 37
159 it('Should upload the video', async function () { 38 function runSuite (mode: 'legacy' | 'resumable') {
160 this.timeout(10000) 39 let server: ServerInfo = null
40 let videoId = -1
41 let videoId2 = -1
42 let videoUUID = ''
43 let videosListBase: any[] = null
161 44
162 const videoAttributes = { 45 const getCheckAttributes = () => ({
163 name: 'my super name', 46 name: 'my super name',
164 category: 2, 47 category: 2,
165 nsfw: true,
166 licence: 6, 48 licence: 6,
167 tags: [ 'tag1', 'tag2', 'tag3' ] 49 language: 'zh',
168 } 50 nsfw: true,
169 const res = await uploadVideo(server.url, server.accessToken, videoAttributes) 51 description: 'my super description',
170 expect(res.body.video).to.not.be.undefined 52 support: 'my super support text',
171 expect(res.body.video.id).to.equal(1) 53 account: {
172 expect(res.body.video.uuid).to.have.length.above(5) 54 name: 'root',
173 55 host: 'localhost:' + server.port
174 videoId = res.body.video.id 56 },
175 videoUUID = res.body.video.uuid 57 isLocal: true,
176 }) 58 duration: 5,
177 59 tags: [ 'tag1', 'tag2', 'tag3' ],
178 it('Should get and seed the uploaded video', async function () { 60 privacy: VideoPrivacy.PUBLIC,
179 this.timeout(5000) 61 commentsEnabled: true,
180 62 downloadEnabled: true,
181 const res = await getVideosList(server.url) 63 channel: {
182 64 displayName: 'Main root channel',
183 expect(res.body.total).to.equal(1) 65 name: 'root_channel',
184 expect(res.body.data).to.be.an('array') 66 description: '',
185 expect(res.body.data.length).to.equal(1) 67 isLocal: true
186 68 },
187 const video = res.body.data[0] 69 fixture: 'video_short.webm',
188 await completeVideoCheck(server.url, video, getCheckAttributes()) 70 files: [
189 }) 71 {
72 resolution: 720,
73 size: 218910
74 }
75 ]
76 })
77
78 const updateCheckAttributes = () => ({
79 name: 'my super video updated',
80 category: 4,
81 licence: 2,
82 language: 'ar',
83 nsfw: false,
84 description: 'my super description updated',
85 support: 'my super support text updated',
86 account: {
87 name: 'root',
88 host: 'localhost:' + server.port
89 },
90 isLocal: true,
91 tags: [ 'tagup1', 'tagup2' ],
92 privacy: VideoPrivacy.PUBLIC,
93 duration: 5,
94 commentsEnabled: false,
95 downloadEnabled: false,
96 channel: {
97 name: 'root_channel',
98 displayName: 'Main root channel',
99 description: '',
100 isLocal: true
101 },
102 fixture: 'video_short3.webm',
103 files: [
104 {
105 resolution: 720,
106 size: 292677
107 }
108 ]
109 })
190 110
191 it('Should get the video by UUID', async function () { 111 before(async function () {
192 this.timeout(5000) 112 this.timeout(30000)
193 113
194 const res = await getVideo(server.url, videoUUID) 114 server = await flushAndRunServer(1)
195 115
196 const video = res.body 116 await setAccessTokensToServers([ server ])
197 await completeVideoCheck(server.url, video, getCheckAttributes()) 117 })
198 })
199 118
200 it('Should have the views updated', async function () { 119 it('Should list video categories', async function () {
201 this.timeout(20000) 120 const res = await getVideoCategories(server.url)
202 121
203 await viewVideo(server.url, videoId) 122 const categories = res.body
204 await viewVideo(server.url, videoId) 123 expect(Object.keys(categories)).to.have.length.above(10)
205 await viewVideo(server.url, videoId)
206 124
207 await wait(1500) 125 expect(categories[11]).to.equal('News & Politics')
126 })
208 127
209 await viewVideo(server.url, videoId) 128 it('Should list video licences', async function () {
210 await viewVideo(server.url, videoId) 129 const res = await getVideoLicences(server.url)
211 130
212 await wait(1500) 131 const licences = res.body
132 expect(Object.keys(licences)).to.have.length.above(5)
213 133
214 await viewVideo(server.url, videoId) 134 expect(licences[3]).to.equal('Attribution - No Derivatives')
215 await viewVideo(server.url, videoId) 135 })
216 136
217 // Wait the repeatable job 137 it('Should list video languages', async function () {
218 await wait(8000) 138 const res = await getVideoLanguages(server.url)
219 139
220 const res = await getVideo(server.url, videoId) 140 const languages = res.body
141 expect(Object.keys(languages)).to.have.length.above(5)
221 142
222 const video = res.body 143 expect(languages['ru']).to.equal('Russian')
223 expect(video.views).to.equal(3) 144 })
224 })
225 145
226 it('Should remove the video', async function () { 146 it('Should list video privacies', async function () {
227 await removeVideo(server.url, server.accessToken, videoId) 147 const res = await getVideoPrivacies(server.url)
228 148
229 await checkVideoFilesWereRemoved(videoUUID, 1) 149 const privacies = res.body
230 }) 150 expect(Object.keys(privacies)).to.have.length.at.least(3)
231 151
232 it('Should not have videos', async function () { 152 expect(privacies[3]).to.equal('Private')
233 const res = await getVideosList(server.url) 153 })
234 154
235 expect(res.body.total).to.equal(0) 155 it('Should not have videos', async function () {
236 expect(res.body.data).to.be.an('array') 156 const res = await getVideosList(server.url)
237 expect(res.body.data).to.have.lengthOf(0)
238 })
239 157
240 it('Should upload 6 videos', async function () { 158 expect(res.body.total).to.equal(0)
241 this.timeout(25000) 159 expect(res.body.data).to.be.an('array')
160 expect(res.body.data.length).to.equal(0)
161 })
242 162
243 const videos = new Set([ 163 it('Should upload the video', async function () {
244 'video_short.mp4', 'video_short.ogv', 'video_short.webm', 164 this.timeout(10000)
245 'video_short1.webm', 'video_short2.webm', 'video_short3.webm'
246 ])
247 165
248 for (const video of videos) {
249 const videoAttributes = { 166 const videoAttributes = {
250 name: video + ' name', 167 name: 'my super name',
251 description: video + ' description',
252 category: 2, 168 category: 2,
253 licence: 1,
254 language: 'en',
255 nsfw: true, 169 nsfw: true,
256 tags: [ 'tag1', 'tag2', 'tag3' ], 170 licence: 6,
257 fixture: video 171 tags: [ 'tag1', 'tag2', 'tag3' ]
258 } 172 }
173 const res = await uploadVideo(server.url, server.accessToken, videoAttributes, HttpStatusCode.OK_200, mode)
174 expect(res.body.video).to.not.be.undefined
175 expect(res.body.video.id).to.equal(1)
176 expect(res.body.video.uuid).to.have.length.above(5)
259 177
260 await uploadVideo(server.url, server.accessToken, videoAttributes) 178 videoId = res.body.video.id
261 } 179 videoUUID = res.body.video.uuid
262 }) 180 })
263 181
264 it('Should have the correct durations', async function () { 182 it('Should get and seed the uploaded video', async function () {
265 const res = await getVideosList(server.url) 183 this.timeout(5000)
266
267 expect(res.body.total).to.equal(6)
268 const videos = res.body.data
269 expect(videos).to.be.an('array')
270 expect(videos).to.have.lengthOf(6)
271
272 const videosByName = keyBy<{ duration: number }>(videos, 'name')
273 expect(videosByName['video_short.mp4 name'].duration).to.equal(5)
274 expect(videosByName['video_short.ogv name'].duration).to.equal(5)
275 expect(videosByName['video_short.webm name'].duration).to.equal(5)
276 expect(videosByName['video_short1.webm name'].duration).to.equal(10)
277 expect(videosByName['video_short2.webm name'].duration).to.equal(5)
278 expect(videosByName['video_short3.webm name'].duration).to.equal(5)
279 })
280 184
281 it('Should have the correct thumbnails', async function () { 185 const res = await getVideosList(server.url)
282 const res = await getVideosList(server.url)
283 186
284 const videos = res.body.data 187 expect(res.body.total).to.equal(1)
285 // For the next test 188 expect(res.body.data).to.be.an('array')
286 videosListBase = videos 189 expect(res.body.data.length).to.equal(1)
287 190
288 for (const video of videos) { 191 const video = res.body.data[0]
289 const videoName = video.name.replace(' name', '') 192 await completeVideoCheck(server.url, video, getCheckAttributes())
290 await testImage(server.url, videoName, video.thumbnailPath) 193 })
291 }
292 })
293 194
294 it('Should list only the two first videos', async function () { 195 it('Should get the video by UUID', async function () {
295 const res = await getVideosListPagination(server.url, 0, 2, 'name') 196 this.timeout(5000)
296 197
297 const videos = res.body.data 198 const res = await getVideo(server.url, videoUUID)
298 expect(res.body.total).to.equal(6)
299 expect(videos.length).to.equal(2)
300 expect(videos[0].name).to.equal(videosListBase[0].name)
301 expect(videos[1].name).to.equal(videosListBase[1].name)
302 })
303 199
304 it('Should list only the next three videos', async function () { 200 const video = res.body
305 const res = await getVideosListPagination(server.url, 2, 3, 'name') 201 await completeVideoCheck(server.url, video, getCheckAttributes())
202 })
306 203
307 const videos = res.body.data 204 it('Should have the views updated', async function () {
308 expect(res.body.total).to.equal(6) 205 this.timeout(20000)
309 expect(videos.length).to.equal(3)
310 expect(videos[0].name).to.equal(videosListBase[2].name)
311 expect(videos[1].name).to.equal(videosListBase[3].name)
312 expect(videos[2].name).to.equal(videosListBase[4].name)
313 })
314 206
315 it('Should list the last video', async function () { 207 await viewVideo(server.url, videoId)
316 const res = await getVideosListPagination(server.url, 5, 6, 'name') 208 await viewVideo(server.url, videoId)
209 await viewVideo(server.url, videoId)
317 210
318 const videos = res.body.data 211 await wait(1500)
319 expect(res.body.total).to.equal(6)
320 expect(videos.length).to.equal(1)
321 expect(videos[0].name).to.equal(videosListBase[5].name)
322 })
323 212
324 it('Should not have the total field', async function () { 213 await viewVideo(server.url, videoId)
325 const res = await getVideosListPagination(server.url, 5, 6, 'name', true) 214 await viewVideo(server.url, videoId)
326 215
327 const videos = res.body.data 216 await wait(1500)
328 expect(res.body.total).to.not.exist
329 expect(videos.length).to.equal(1)
330 expect(videos[0].name).to.equal(videosListBase[5].name)
331 })
332 217
333 it('Should list and sort by name in descending order', async function () { 218 await viewVideo(server.url, videoId)
334 const res = await getVideosListSort(server.url, '-name') 219 await viewVideo(server.url, videoId)
335
336 const videos = res.body.data
337 expect(res.body.total).to.equal(6)
338 expect(videos.length).to.equal(6)
339 expect(videos[0].name).to.equal('video_short.webm name')
340 expect(videos[1].name).to.equal('video_short.ogv name')
341 expect(videos[2].name).to.equal('video_short.mp4 name')
342 expect(videos[3].name).to.equal('video_short3.webm name')
343 expect(videos[4].name).to.equal('video_short2.webm name')
344 expect(videos[5].name).to.equal('video_short1.webm name')
345
346 videoId = videos[3].uuid
347 videoId2 = videos[5].uuid
348 })
349 220
350 it('Should list and sort by trending in descending order', async function () { 221 // Wait the repeatable job
351 const res = await getVideosListPagination(server.url, 0, 2, '-trending') 222 await wait(8000)
352 223
353 const videos = res.body.data 224 const res = await getVideo(server.url, videoId)
354 expect(res.body.total).to.equal(6)
355 expect(videos.length).to.equal(2)
356 })
357 225
358 it('Should list and sort by hotness in descending order', async function () { 226 const video = res.body
359 const res = await getVideosListPagination(server.url, 0, 2, '-hot') 227 expect(video.views).to.equal(3)
228 })
360 229
361 const videos = res.body.data 230 it('Should remove the video', async function () {
362 expect(res.body.total).to.equal(6) 231 await removeVideo(server.url, server.accessToken, videoId)
363 expect(videos.length).to.equal(2)
364 })
365 232
366 it('Should list and sort by best in descending order', async function () { 233 await checkVideoFilesWereRemoved(videoUUID, 1)
367 const res = await getVideosListPagination(server.url, 0, 2, '-best') 234 })
368 235
369 const videos = res.body.data 236 it('Should not have videos', async function () {
370 expect(res.body.total).to.equal(6) 237 const res = await getVideosList(server.url)
371 expect(videos.length).to.equal(2)
372 })
373 238
374 it('Should update a video', async function () { 239 expect(res.body.total).to.equal(0)
375 const attributes = { 240 expect(res.body.data).to.be.an('array')
376 name: 'my super video updated', 241 expect(res.body.data).to.have.lengthOf(0)
377 category: 4, 242 })
378 licence: 2,
379 language: 'ar',
380 nsfw: false,
381 description: 'my super description updated',
382 commentsEnabled: false,
383 downloadEnabled: false,
384 tags: [ 'tagup1', 'tagup2' ]
385 }
386 await updateVideo(server.url, server.accessToken, videoId, attributes)
387 })
388 243
389 it('Should filter by tags and category', async function () { 244 it('Should upload 6 videos', async function () {
390 const res1 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 4 ] }) 245 this.timeout(25000)
391 expect(res1.body.total).to.equal(1)
392 expect(res1.body.data[0].name).to.equal('my super video updated')
393 246
394 const res2 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 3 ] }) 247 const videos = new Set([
395 expect(res2.body.total).to.equal(0) 248 'video_short.mp4', 'video_short.ogv', 'video_short.webm',
396 }) 249 'video_short1.webm', 'video_short2.webm', 'video_short3.webm'
250 ])
397 251
398 it('Should have the video updated', async function () { 252 for (const video of videos) {
399 this.timeout(60000) 253 const videoAttributes = {
254 name: video + ' name',
255 description: video + ' description',
256 category: 2,
257 licence: 1,
258 language: 'en',
259 nsfw: true,
260 tags: [ 'tag1', 'tag2', 'tag3' ],
261 fixture: video
262 }
400 263
401 const res = await getVideo(server.url, videoId) 264 await uploadVideo(server.url, server.accessToken, videoAttributes, HttpStatusCode.OK_200, mode)
402 const video = res.body 265 }
266 })
267
268 it('Should have the correct durations', async function () {
269 const res = await getVideosList(server.url)
270
271 expect(res.body.total).to.equal(6)
272 const videos = res.body.data
273 expect(videos).to.be.an('array')
274 expect(videos).to.have.lengthOf(6)
275
276 const videosByName = keyBy<{ duration: number }>(videos, 'name')
277 expect(videosByName['video_short.mp4 name'].duration).to.equal(5)
278 expect(videosByName['video_short.ogv name'].duration).to.equal(5)
279 expect(videosByName['video_short.webm name'].duration).to.equal(5)
280 expect(videosByName['video_short1.webm name'].duration).to.equal(10)
281 expect(videosByName['video_short2.webm name'].duration).to.equal(5)
282 expect(videosByName['video_short3.webm name'].duration).to.equal(5)
283 })
284
285 it('Should have the correct thumbnails', async function () {
286 const res = await getVideosList(server.url)
287
288 const videos = res.body.data
289 // For the next test
290 videosListBase = videos
291
292 for (const video of videos) {
293 const videoName = video.name.replace(' name', '')
294 await testImage(server.url, videoName, video.thumbnailPath)
295 }
296 })
297
298 it('Should list only the two first videos', async function () {
299 const res = await getVideosListPagination(server.url, 0, 2, 'name')
300
301 const videos = res.body.data
302 expect(res.body.total).to.equal(6)
303 expect(videos.length).to.equal(2)
304 expect(videos[0].name).to.equal(videosListBase[0].name)
305 expect(videos[1].name).to.equal(videosListBase[1].name)
306 })
307
308 it('Should list only the next three videos', async function () {
309 const res = await getVideosListPagination(server.url, 2, 3, 'name')
310
311 const videos = res.body.data
312 expect(res.body.total).to.equal(6)
313 expect(videos.length).to.equal(3)
314 expect(videos[0].name).to.equal(videosListBase[2].name)
315 expect(videos[1].name).to.equal(videosListBase[3].name)
316 expect(videos[2].name).to.equal(videosListBase[4].name)
317 })
318
319 it('Should list the last video', async function () {
320 const res = await getVideosListPagination(server.url, 5, 6, 'name')
321
322 const videos = res.body.data
323 expect(res.body.total).to.equal(6)
324 expect(videos.length).to.equal(1)
325 expect(videos[0].name).to.equal(videosListBase[5].name)
326 })
327
328 it('Should not have the total field', async function () {
329 const res = await getVideosListPagination(server.url, 5, 6, 'name', true)
330
331 const videos = res.body.data
332 expect(res.body.total).to.not.exist
333 expect(videos.length).to.equal(1)
334 expect(videos[0].name).to.equal(videosListBase[5].name)
335 })
336
337 it('Should list and sort by name in descending order', async function () {
338 const res = await getVideosListSort(server.url, '-name')
339
340 const videos = res.body.data
341 expect(res.body.total).to.equal(6)
342 expect(videos.length).to.equal(6)
343 expect(videos[0].name).to.equal('video_short.webm name')
344 expect(videos[1].name).to.equal('video_short.ogv name')
345 expect(videos[2].name).to.equal('video_short.mp4 name')
346 expect(videos[3].name).to.equal('video_short3.webm name')
347 expect(videos[4].name).to.equal('video_short2.webm name')
348 expect(videos[5].name).to.equal('video_short1.webm name')
349
350 videoId = videos[3].uuid
351 videoId2 = videos[5].uuid
352 })
353
354 it('Should list and sort by trending in descending order', async function () {
355 const res = await getVideosListPagination(server.url, 0, 2, '-trending')
356
357 const videos = res.body.data
358 expect(res.body.total).to.equal(6)
359 expect(videos.length).to.equal(2)
360 })
361
362 it('Should list and sort by hotness in descending order', async function () {
363 const res = await getVideosListPagination(server.url, 0, 2, '-hot')
364
365 const videos = res.body.data
366 expect(res.body.total).to.equal(6)
367 expect(videos.length).to.equal(2)
368 })
369
370 it('Should list and sort by best in descending order', async function () {
371 const res = await getVideosListPagination(server.url, 0, 2, '-best')
372
373 const videos = res.body.data
374 expect(res.body.total).to.equal(6)
375 expect(videos.length).to.equal(2)
376 })
377
378 it('Should update a video', async function () {
379 const attributes = {
380 name: 'my super video updated',
381 category: 4,
382 licence: 2,
383 language: 'ar',
384 nsfw: false,
385 description: 'my super description updated',
386 commentsEnabled: false,
387 downloadEnabled: false,
388 tags: [ 'tagup1', 'tagup2' ]
389 }
390 await updateVideo(server.url, server.accessToken, videoId, attributes)
391 })
403 392
404 await completeVideoCheck(server.url, video, updateCheckAttributes()) 393 it('Should filter by tags and category', async function () {
405 }) 394 const res1 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 4 ] })
395 expect(res1.body.total).to.equal(1)
396 expect(res1.body.data[0].name).to.equal('my super video updated')
406 397
407 it('Should update only the tags of a video', async function () { 398 const res2 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 3 ] })
408 const attributes = { 399 expect(res2.body.total).to.equal(0)
409 tags: [ 'supertag', 'tag1', 'tag2' ] 400 })
410 }
411 await updateVideo(server.url, server.accessToken, videoId, attributes)
412 401
413 const res = await getVideo(server.url, videoId) 402 it('Should have the video updated', async function () {
414 const video = res.body 403 this.timeout(60000)
415 404
416 await completeVideoCheck(server.url, video, Object.assign(updateCheckAttributes(), attributes)) 405 const res = await getVideo(server.url, videoId)
417 }) 406 const video = res.body
418 407
419 it('Should update only the description of a video', async function () { 408 await completeVideoCheck(server.url, video, updateCheckAttributes())
420 const attributes = { 409 })
421 description: 'hello everybody'
422 }
423 await updateVideo(server.url, server.accessToken, videoId, attributes)
424 410
425 const res = await getVideo(server.url, videoId) 411 it('Should update only the tags of a video', async function () {
426 const video = res.body 412 const attributes = {
413 tags: [ 'supertag', 'tag1', 'tag2' ]
414 }
415 await updateVideo(server.url, server.accessToken, videoId, attributes)
427 416
428 const expectedAttributes = Object.assign(updateCheckAttributes(), { tags: [ 'supertag', 'tag1', 'tag2' ] }, attributes) 417 const res = await getVideo(server.url, videoId)
429 await completeVideoCheck(server.url, video, expectedAttributes) 418 const video = res.body
430 })
431 419
432 it('Should like a video', async function () { 420 await completeVideoCheck(server.url, video, Object.assign(updateCheckAttributes(), attributes))
433 await rateVideo(server.url, server.accessToken, videoId, 'like') 421 })
434 422
435 const res = await getVideo(server.url, videoId) 423 it('Should update only the description of a video', async function () {
436 const video = res.body 424 const attributes = {
425 description: 'hello everybody'
426 }
427 await updateVideo(server.url, server.accessToken, videoId, attributes)
437 428
438 expect(video.likes).to.equal(1) 429 const res = await getVideo(server.url, videoId)
439 expect(video.dislikes).to.equal(0) 430 const video = res.body
440 })
441 431
442 it('Should dislike the same video', async function () { 432 const expectedAttributes = Object.assign(updateCheckAttributes(), { tags: [ 'supertag', 'tag1', 'tag2' ] }, attributes)
443 await rateVideo(server.url, server.accessToken, videoId, 'dislike') 433 await completeVideoCheck(server.url, video, expectedAttributes)
434 })
444 435
445 const res = await getVideo(server.url, videoId) 436 it('Should like a video', async function () {
446 const video = res.body 437 await rateVideo(server.url, server.accessToken, videoId, 'like')
447 438
448 expect(video.likes).to.equal(0) 439 const res = await getVideo(server.url, videoId)
449 expect(video.dislikes).to.equal(1) 440 const video = res.body
450 })
451 441
452 it('Should sort by originallyPublishedAt', async function () { 442 expect(video.likes).to.equal(1)
453 { 443 expect(video.dislikes).to.equal(0)
444 })
454 445
446 it('Should dislike the same video', async function () {
447 await rateVideo(server.url, server.accessToken, videoId, 'dislike')
448
449 const res = await getVideo(server.url, videoId)
450 const video = res.body
451
452 expect(video.likes).to.equal(0)
453 expect(video.dislikes).to.equal(1)
454 })
455
456 it('Should sort by originallyPublishedAt', async function () {
455 { 457 {
456 const now = new Date() 458 const now = new Date()
457 const attributes = { originallyPublishedAt: now.toISOString() } 459 const attributes = { originallyPublishedAt: now.toISOString() }
@@ -483,10 +485,18 @@ describe('Test a single server', function () {
483 expect(names[4]).to.equal('video_short.ogv name') 485 expect(names[4]).to.equal('video_short.ogv name')
484 expect(names[5]).to.equal('video_short.mp4 name') 486 expect(names[5]).to.equal('video_short.mp4 name')
485 } 487 }
486 } 488 })
489
490 after(async function () {
491 await cleanupTests([ server ])
492 })
493 }
494
495 describe('Legacy upload', function () {
496 runSuite('legacy')
487 }) 497 })
488 498
489 after(async function () { 499 describe('Resumable upload', function () {
490 await cleanupTests([ server ]) 500 runSuite('resumable')
491 }) 501 })
492}) 502})
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts
index 1c99f26df..ea5ffd239 100644
--- a/server/tests/api/videos/video-transcoder.ts
+++ b/server/tests/api/videos/video-transcoder.ts
@@ -361,106 +361,117 @@ describe('Test video transcoding', function () {
361 361
362 describe('Audio upload', function () { 362 describe('Audio upload', function () {
363 363
364 before(async function () { 364 function runSuite (mode: 'legacy' | 'resumable') {
365 await updateCustomSubConfig(servers[1].url, servers[1].accessToken, { 365
366 transcoding: { 366 before(async function () {
367 hls: { enabled: true }, 367 await updateCustomSubConfig(servers[1].url, servers[1].accessToken, {
368 webtorrent: { enabled: true }, 368 transcoding: {
369 resolutions: { 369 hls: { enabled: true },
370 '0p': false, 370 webtorrent: { enabled: true },
371 '240p': false, 371 resolutions: {
372 '360p': false, 372 '0p': false,
373 '480p': false, 373 '240p': false,
374 '720p': false, 374 '360p': false,
375 '1080p': false, 375 '480p': false,
376 '1440p': false, 376 '720p': false,
377 '2160p': false 377 '1080p': false,
378 '1440p': false,
379 '2160p': false
380 }
378 } 381 }
379 } 382 })
380 }) 383 })
381 })
382
383 it('Should merge an audio file with the preview file', async function () {
384 this.timeout(60_000)
385
386 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
387 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg)
388 384
389 await waitJobs(servers) 385 it('Should merge an audio file with the preview file', async function () {
386 this.timeout(60_000)
390 387
391 for (const server of servers) { 388 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
392 const res = await getVideosList(server.url) 389 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg, HttpStatusCode.OK_200, mode)
393 390
394 const video = res.body.data.find(v => v.name === 'audio_with_preview') 391 await waitJobs(servers)
395 const res2 = await getVideo(server.url, video.id)
396 const videoDetails: VideoDetails = res2.body
397 392
398 expect(videoDetails.files).to.have.lengthOf(1) 393 for (const server of servers) {
394 const res = await getVideosList(server.url)
399 395
400 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: HttpStatusCode.OK_200 }) 396 const video = res.body.data.find(v => v.name === 'audio_with_preview')
401 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: HttpStatusCode.OK_200 }) 397 const res2 = await getVideo(server.url, video.id)
398 const videoDetails: VideoDetails = res2.body
402 399
403 const magnetUri = videoDetails.files[0].magnetUri 400 expect(videoDetails.files).to.have.lengthOf(1)
404 expect(magnetUri).to.contain('.mp4')
405 }
406 })
407 401
408 it('Should upload an audio file and choose a default background image', async function () { 402 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: HttpStatusCode.OK_200 })
409 this.timeout(60_000) 403 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: HttpStatusCode.OK_200 })
410 404
411 const videoAttributesArg = { name: 'audio_without_preview', fixture: 'sample.ogg' } 405 const magnetUri = videoDetails.files[0].magnetUri
412 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg) 406 expect(magnetUri).to.contain('.mp4')
407 }
408 })
413 409
414 await waitJobs(servers) 410 it('Should upload an audio file and choose a default background image', async function () {
411 this.timeout(60_000)
415 412
416 for (const server of servers) { 413 const videoAttributesArg = { name: 'audio_without_preview', fixture: 'sample.ogg' }
417 const res = await getVideosList(server.url) 414 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg, HttpStatusCode.OK_200, mode)
418 415
419 const video = res.body.data.find(v => v.name === 'audio_without_preview') 416 await waitJobs(servers)
420 const res2 = await getVideo(server.url, video.id)
421 const videoDetails = res2.body
422 417
423 expect(videoDetails.files).to.have.lengthOf(1) 418 for (const server of servers) {
419 const res = await getVideosList(server.url)
424 420
425 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: HttpStatusCode.OK_200 }) 421 const video = res.body.data.find(v => v.name === 'audio_without_preview')
426 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: HttpStatusCode.OK_200 }) 422 const res2 = await getVideo(server.url, video.id)
423 const videoDetails = res2.body
427 424
428 const magnetUri = videoDetails.files[0].magnetUri 425 expect(videoDetails.files).to.have.lengthOf(1)
429 expect(magnetUri).to.contain('.mp4')
430 }
431 })
432 426
433 it('Should upload an audio file and create an audio version only', async function () { 427 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: HttpStatusCode.OK_200 })
434 this.timeout(60_000) 428 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: HttpStatusCode.OK_200 })
435 429
436 await updateCustomSubConfig(servers[1].url, servers[1].accessToken, { 430 const magnetUri = videoDetails.files[0].magnetUri
437 transcoding: { 431 expect(magnetUri).to.contain('.mp4')
438 hls: { enabled: true },
439 webtorrent: { enabled: true },
440 resolutions: {
441 '0p': true,
442 '240p': false,
443 '360p': false
444 }
445 } 432 }
446 }) 433 })
447 434
448 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' } 435 it('Should upload an audio file and create an audio version only', async function () {
449 const resVideo = await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg) 436 this.timeout(60_000)
437
438 await updateCustomSubConfig(servers[1].url, servers[1].accessToken, {
439 transcoding: {
440 hls: { enabled: true },
441 webtorrent: { enabled: true },
442 resolutions: {
443 '0p': true,
444 '240p': false,
445 '360p': false
446 }
447 }
448 })
450 449
451 await waitJobs(servers) 450 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
451 const resVideo = await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg, HttpStatusCode.OK_200, mode)
452 452
453 for (const server of servers) { 453 await waitJobs(servers)
454 const res2 = await getVideo(server.url, resVideo.body.video.id) 454
455 const videoDetails: VideoDetails = res2.body 455 for (const server of servers) {
456 const res2 = await getVideo(server.url, resVideo.body.video.id)
457 const videoDetails: VideoDetails = res2.body
456 458
457 for (const files of [ videoDetails.files, videoDetails.streamingPlaylists[0].files ]) { 459 for (const files of [ videoDetails.files, videoDetails.streamingPlaylists[0].files ]) {
458 expect(files).to.have.lengthOf(2) 460 expect(files).to.have.lengthOf(2)
459 expect(files.find(f => f.resolution.id === 0)).to.not.be.undefined 461 expect(files.find(f => f.resolution.id === 0)).to.not.be.undefined
462 }
460 } 463 }
461 }
462 464
463 await updateConfigForTranscoding(servers[1]) 465 await updateConfigForTranscoding(servers[1])
466 })
467 }
468
469 describe('Legacy upload', function () {
470 runSuite('legacy')
471 })
472
473 describe('Resumable upload', function () {
474 runSuite('resumable')
464 }) 475 })
465 }) 476 })
466 477
diff --git a/server/typings/express/index.d.ts b/server/typings/express/index.d.ts
index cf3e7ae34..55b6e0039 100644
--- a/server/typings/express/index.d.ts
+++ b/server/typings/express/index.d.ts
@@ -19,6 +19,9 @@ import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server'
19import { MVideoImportDefault } from '@server/types/models/video/video-import' 19import { MVideoImportDefault } from '@server/types/models/video/video-import'
20import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element' 20import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element'
21import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate' 21import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate'
22import { HttpMethod } from '@shared/core-utils/miscs/http-methods'
23import { VideoCreate } from '@shared/models'
24import { File as UploadXFile, Metadata } from '@uploadx/core'
22import { RegisteredPlugin } from '../../lib/plugins/plugin-manager' 25import { RegisteredPlugin } from '../../lib/plugins/plugin-manager'
23import { 26import {
24 MAccountDefault, 27 MAccountDefault,
@@ -37,86 +40,125 @@ import {
37 MVideoThumbnail, 40 MVideoThumbnail,
38 MVideoWithRights 41 MVideoWithRights
39} from '../../types/models' 42} from '../../types/models'
40
41declare module 'express' { 43declare module 'express' {
42 export interface Request { 44 export interface Request {
43 query: any 45 query: any
46 method: HttpMethod
44 } 47 }
45 interface Response { 48
46 locals: PeerTubeLocals 49 // Upload using multer or uploadx middleware
50 export type MulterOrUploadXFile = UploadXFile | Express.Multer.File
51
52 export type UploadFiles = {
53 [fieldname: string]: MulterOrUploadXFile[]
54 } | MulterOrUploadXFile[]
55
56 // Partial object used by some functions to check the file mimetype/extension
57 export type UploadFileForCheck = {
58 originalname: string
59 mimetype: string
47 } 60 }
48}
49 61
50interface PeerTubeLocals { 62 export type UploadFilesForCheck = {
51 videoAll?: MVideoFullLight 63 [fieldname: string]: UploadFileForCheck[]
52 onlyImmutableVideo?: MVideoImmutable 64 } | UploadFileForCheck[]
53 onlyVideo?: MVideoThumbnail
54 onlyVideoWithRights?: MVideoWithRights
55 videoId?: MVideoIdThumbnail
56 65
57 videoLive?: MVideoLive 66 // Upload file with a duration added by our middleware
67 export type VideoUploadFile = Pick<Express.Multer.File, 'path' | 'filename' | 'size'> & {
68 duration: number
69 }
58 70
59 videoShare?: MVideoShareActor 71 // Extends Metadata property of UploadX object
72 export type UploadXFileMetadata = Metadata & VideoCreate & {
73 previewfile: Express.Multer.File[]
74 thumbnailfile: Express.Multer.File[]
75 }
60 76
61 videoFile?: MVideoFile 77 // Our custom UploadXFile object using our custom metadata
78 export type CustomUploadXFile <T extends Metadata> = UploadXFile & { metadata: T }
62 79
63 videoImport?: MVideoImportDefault 80 export type EnhancedUploadXFile = CustomUploadXFile<UploadXFileMetadata> & {
81 duration: number
82 path: string
83 filename: string
84 }
64 85
65 videoBlacklist?: MVideoBlacklist 86 // Extends locals property from Response
87 interface Response {
88 locals: {
89 videoAll?: MVideoFullLight
90 onlyImmutableVideo?: MVideoImmutable
91 onlyVideo?: MVideoThumbnail
92 onlyVideoWithRights?: MVideoWithRights
93 videoId?: MVideoIdThumbnail
66 94
67 videoCaption?: MVideoCaptionVideo 95 videoLive?: MVideoLive
68 96
69 abuse?: MAbuseReporter 97 videoShare?: MVideoShareActor
70 abuseMessage?: MAbuseMessage
71 98
72 videoStreamingPlaylist?: MStreamingPlaylist 99 videoFile?: MVideoFile
73 100
74 videoChannel?: MChannelBannerAccountDefault 101 videoFileResumable?: EnhancedUploadXFile
75 102
76 videoPlaylistFull?: MVideoPlaylistFull 103 videoImport?: MVideoImportDefault
77 videoPlaylistSummary?: MVideoPlaylistFullSummary
78 104
79 videoPlaylistElement?: MVideoPlaylistElement 105 videoBlacklist?: MVideoBlacklist
80 videoPlaylistElementAP?: MVideoPlaylistElementVideoUrlPlaylistPrivacy
81 106
82 accountVideoRate?: MAccountVideoRateAccountVideo 107 videoCaption?: MVideoCaptionVideo
83 108
84 videoCommentFull?: MCommentOwnerVideoReply 109 abuse?: MAbuseReporter
85 videoCommentThread?: MComment 110 abuseMessage?: MAbuseMessage
86 111
87 follow?: MActorFollowActorsDefault 112 videoStreamingPlaylist?: MStreamingPlaylist
88 subscription?: MActorFollowActorsDefaultSubscription
89 113
90 nextOwner?: MAccountDefault 114 videoChannel?: MChannelBannerAccountDefault
91 videoChangeOwnership?: MVideoChangeOwnershipFull
92 115
93 account?: MAccountDefault 116 videoPlaylistFull?: MVideoPlaylistFull
117 videoPlaylistSummary?: MVideoPlaylistFullSummary
94 118
95 actorUrl?: MActorUrl 119 videoPlaylistElement?: MVideoPlaylistElement
96 actorFull?: MActorFull 120 videoPlaylistElementAP?: MVideoPlaylistElementVideoUrlPlaylistPrivacy
97 121
98 user?: MUserDefault 122 accountVideoRate?: MAccountVideoRateAccountVideo
99 123
100 server?: MServer 124 videoCommentFull?: MCommentOwnerVideoReply
125 videoCommentThread?: MComment
101 126
102 videoRedundancy?: MVideoRedundancyVideo 127 follow?: MActorFollowActorsDefault
128 subscription?: MActorFollowActorsDefaultSubscription
103 129
104 accountBlock?: MAccountBlocklist 130 nextOwner?: MAccountDefault
105 serverBlock?: MServerBlocklist 131 videoChangeOwnership?: MVideoChangeOwnershipFull
106 132
107 oauth?: { 133 account?: MAccountDefault
108 token: MOAuthTokenUser
109 }
110 134
111 signature?: { 135 actorUrl?: MActorUrl
112 actor: MActorAccountChannelId 136 actorFull?: MActorFull
113 } 137
138 user?: MUserDefault
139
140 server?: MServer
141
142 videoRedundancy?: MVideoRedundancyVideo
114 143
115 authenticated?: boolean 144 accountBlock?: MAccountBlocklist
145 serverBlock?: MServerBlocklist
116 146
117 registeredPlugin?: RegisteredPlugin 147 oauth?: {
148 token: MOAuthTokenUser
149 }
118 150
119 externalAuth?: RegisterServerAuthExternalOptions 151 signature?: {
152 actor: MActorAccountChannelId
153 }
120 154
121 plugin?: MPlugin 155 authenticated?: boolean
156
157 registeredPlugin?: RegisteredPlugin
158
159 externalAuth?: RegisterServerAuthExternalOptions
160
161 plugin?: MPlugin
162 }
163 }
122} 164}
diff --git a/shared/core-utils/miscs/http-methods.ts b/shared/core-utils/miscs/http-methods.ts
new file mode 100644
index 000000000..1cfa458b9
--- /dev/null
+++ b/shared/core-utils/miscs/http-methods.ts
@@ -0,0 +1,21 @@
1/** HTTP request method to indicate the desired action to be performed for a given resource. */
2export enum HttpMethod {
3 /** The CONNECT method establishes a tunnel to the server identified by the target resource. */
4 CONNECT = 'CONNECT',
5 /** The DELETE method deletes the specified resource. */
6 DELETE = 'DELETE',
7 /** The GET method requests a representation of the specified resource. Requests using GET should only retrieve data. */
8 GET = 'GET',
9 /** The HEAD method asks for a response identical to that of a GET request, but without the response body. */
10 HEAD = 'HEAD',
11 /** The OPTIONS method is used to describe the communication options for the target resource. */
12 OPTIONS = 'OPTIONS',
13 /** The PATCH method is used to apply partial modifications to a resource. */
14 PATCH = 'PATCH',
15 /** The POST method is used to submit an entity to the specified resource */
16 POST = 'POST',
17 /** The PUT method replaces all current representations of the target resource with the request payload. */
18 PUT = 'PUT',
19 /** The TRACE method performs a message loop-back test along the path to the target resource. */
20 TRACE = 'TRACE'
21}
diff --git a/shared/core-utils/miscs/index.ts b/shared/core-utils/miscs/index.ts
index 898fd4791..251df1de2 100644
--- a/shared/core-utils/miscs/index.ts
+++ b/shared/core-utils/miscs/index.ts
@@ -2,3 +2,4 @@ export * from './date'
2export * from './miscs' 2export * from './miscs'
3export * from './types' 3export * from './types'
4export * from './http-error-codes' 4export * from './http-error-codes'
5export * from './http-methods'
diff --git a/shared/extra-utils/server/debug.ts b/shared/extra-utils/server/debug.ts
index 5cf80a5fb..f196812b7 100644
--- a/shared/extra-utils/server/debug.ts
+++ b/shared/extra-utils/server/debug.ts
@@ -1,5 +1,6 @@
1import { makeGetRequest } from '../requests/requests' 1import { makeGetRequest, makePostBodyRequest } from '../requests/requests'
2import { HttpStatusCode } from '../../core-utils/miscs/http-error-codes' 2import { HttpStatusCode } from '../../core-utils/miscs/http-error-codes'
3import { SendDebugCommand } from '@shared/models'
3 4
4function getDebug (url: string, token: string) { 5function getDebug (url: string, token: string) {
5 const path = '/api/v1/server/debug' 6 const path = '/api/v1/server/debug'
@@ -12,8 +13,21 @@ function getDebug (url: string, token: string) {
12 }) 13 })
13} 14}
14 15
16function sendDebugCommand (url: string, token: string, body: SendDebugCommand) {
17 const path = '/api/v1/server/debug/run-command'
18
19 return makePostBodyRequest({
20 url,
21 path,
22 token,
23 fields: body,
24 statusCodeExpected: HttpStatusCode.NO_CONTENT_204
25 })
26}
27
15// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
16 29
17export { 30export {
18 getDebug 31 getDebug,
32 sendDebugCommand
19} 33}
diff --git a/shared/extra-utils/server/servers.ts b/shared/extra-utils/server/servers.ts
index 779a3cc36..479f08e12 100644
--- a/shared/extra-utils/server/servers.ts
+++ b/shared/extra-utils/server/servers.ts
@@ -274,7 +274,7 @@ async function reRunServer (server: ServerInfo, configOverride?: any) {
274} 274}
275 275
276async function checkTmpIsEmpty (server: ServerInfo) { 276async function checkTmpIsEmpty (server: ServerInfo) {
277 await checkDirectoryIsEmpty(server, 'tmp', [ 'plugins-global.css', 'hls' ]) 277 await checkDirectoryIsEmpty(server, 'tmp', [ 'plugins-global.css', 'hls', 'resumable-uploads' ])
278 278
279 if (await pathExists(join('test' + server.internalServerNumber, 'tmp', 'hls'))) { 279 if (await pathExists(join('test' + server.internalServerNumber, 'tmp', 'hls'))) {
280 await checkDirectoryIsEmpty(server, 'tmp/hls') 280 await checkDirectoryIsEmpty(server, 'tmp/hls')
diff --git a/shared/extra-utils/videos/video-channels.ts b/shared/extra-utils/videos/video-channels.ts
index d0dfb5856..0aab93e52 100644
--- a/shared/extra-utils/videos/video-channels.ts
+++ b/shared/extra-utils/videos/video-channels.ts
@@ -5,7 +5,7 @@ import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-up
5import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model' 5import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model'
6import { makeDeleteRequest, makeGetRequest, updateImageRequest } from '../requests/requests' 6import { makeDeleteRequest, makeGetRequest, updateImageRequest } from '../requests/requests'
7import { ServerInfo } from '../server/servers' 7import { ServerInfo } from '../server/servers'
8import { User } from '../../models/users/user.model' 8import { MyUser, User } from '../../models/users/user.model'
9import { getMyUserInformation } from '../users/users' 9import { getMyUserInformation } from '../users/users'
10import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 10import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
11 11
@@ -170,6 +170,12 @@ function setDefaultVideoChannel (servers: ServerInfo[]) {
170 return Promise.all(tasks) 170 return Promise.all(tasks)
171} 171}
172 172
173async function getDefaultVideoChannel (url: string, token: string) {
174 const res = await getMyUserInformation(url, token)
175
176 return (res.body as MyUser).videoChannels[0].id
177}
178
173// --------------------------------------------------------------------------- 179// ---------------------------------------------------------------------------
174 180
175export { 181export {
@@ -181,5 +187,6 @@ export {
181 deleteVideoChannel, 187 deleteVideoChannel,
182 getVideoChannel, 188 getVideoChannel,
183 setDefaultVideoChannel, 189 setDefaultVideoChannel,
184 deleteVideoChannelImage 190 deleteVideoChannelImage,
191 getDefaultVideoChannel
185} 192}
diff --git a/shared/extra-utils/videos/videos.ts b/shared/extra-utils/videos/videos.ts
index a0143b0ef..e88256ac0 100644
--- a/shared/extra-utils/videos/videos.ts
+++ b/shared/extra-utils/videos/videos.ts
@@ -1,7 +1,8 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { pathExists, readdir, readFile } from 'fs-extra' 4import { createReadStream, pathExists, readdir, readFile, stat } from 'fs-extra'
5import got, { Response as GotResponse } from 'got/dist/source'
5import * as parseTorrent from 'parse-torrent' 6import * as parseTorrent from 'parse-torrent'
6import { extname, join } from 'path' 7import { extname, join } from 'path'
7import * as request from 'supertest' 8import * as request from 'supertest'
@@ -42,6 +43,7 @@ type VideoAttributes = {
42 channelId?: number 43 channelId?: number
43 privacy?: VideoPrivacy 44 privacy?: VideoPrivacy
44 fixture?: string 45 fixture?: string
46 support?: string
45 thumbnailfile?: string 47 thumbnailfile?: string
46 previewfile?: string 48 previewfile?: string
47 scheduleUpdate?: { 49 scheduleUpdate?: {
@@ -364,8 +366,13 @@ async function checkVideoFilesWereRemoved (
364 } 366 }
365} 367}
366 368
367async function uploadVideo (url: string, accessToken: string, videoAttributesArg: VideoAttributes, specialStatus = HttpStatusCode.OK_200) { 369async function uploadVideo (
368 const path = '/api/v1/videos/upload' 370 url: string,
371 accessToken: string,
372 videoAttributesArg: VideoAttributes,
373 specialStatus = HttpStatusCode.OK_200,
374 mode: 'legacy' | 'resumable' = 'legacy'
375) {
369 let defaultChannelId = '1' 376 let defaultChannelId = '1'
370 377
371 try { 378 try {
@@ -391,74 +398,170 @@ async function uploadVideo (url: string, accessToken: string, videoAttributesArg
391 fixture: 'video_short.webm' 398 fixture: 'video_short.webm'
392 }, videoAttributesArg) 399 }, videoAttributesArg)
393 400
401 const res = mode === 'legacy'
402 ? await buildLegacyUpload(url, accessToken, attributes, specialStatus)
403 : await buildResumeUpload(url, accessToken, attributes, specialStatus)
404
405 // Wait torrent generation
406 if (specialStatus === HttpStatusCode.OK_200) {
407 let video: VideoDetails
408 do {
409 const resVideo = await getVideoWithToken(url, accessToken, res.body.video.uuid)
410 video = resVideo.body
411
412 await wait(50)
413 } while (!video.files[0].torrentUrl)
414 }
415
416 return res
417}
418
419function checkUploadVideoParam (
420 url: string,
421 token: string,
422 attributes: Partial<VideoAttributes>,
423 specialStatus = HttpStatusCode.OK_200,
424 mode: 'legacy' | 'resumable' = 'legacy'
425) {
426 return mode === 'legacy'
427 ? buildLegacyUpload(url, token, attributes, specialStatus)
428 : buildResumeUpload(url, token, attributes, specialStatus)
429}
430
431async function buildLegacyUpload (url: string, token: string, attributes: VideoAttributes, specialStatus = HttpStatusCode.OK_200) {
432 const path = '/api/v1/videos/upload'
394 const req = request(url) 433 const req = request(url)
395 .post(path) 434 .post(path)
396 .set('Accept', 'application/json') 435 .set('Accept', 'application/json')
397 .set('Authorization', 'Bearer ' + accessToken) 436 .set('Authorization', 'Bearer ' + token)
398 .field('name', attributes.name)
399 .field('nsfw', JSON.stringify(attributes.nsfw))
400 .field('commentsEnabled', JSON.stringify(attributes.commentsEnabled))
401 .field('downloadEnabled', JSON.stringify(attributes.downloadEnabled))
402 .field('waitTranscoding', JSON.stringify(attributes.waitTranscoding))
403 .field('privacy', attributes.privacy.toString())
404 .field('channelId', attributes.channelId)
405
406 if (attributes.support !== undefined) {
407 req.field('support', attributes.support)
408 }
409 437
410 if (attributes.description !== undefined) { 438 buildUploadReq(req, attributes)
411 req.field('description', attributes.description)
412 }
413 if (attributes.language !== undefined) {
414 req.field('language', attributes.language.toString())
415 }
416 if (attributes.category !== undefined) {
417 req.field('category', attributes.category.toString())
418 }
419 if (attributes.licence !== undefined) {
420 req.field('licence', attributes.licence.toString())
421 }
422 439
423 const tags = attributes.tags || [] 440 if (attributes.fixture !== undefined) {
424 for (let i = 0; i < tags.length; i++) { 441 req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture))
425 req.field('tags[' + i + ']', attributes.tags[i])
426 } 442 }
427 443
428 if (attributes.thumbnailfile !== undefined) { 444 return req.expect(specialStatus)
429 req.attach('thumbnailfile', buildAbsoluteFixturePath(attributes.thumbnailfile)) 445}
430 }
431 if (attributes.previewfile !== undefined) {
432 req.attach('previewfile', buildAbsoluteFixturePath(attributes.previewfile))
433 }
434 446
435 if (attributes.scheduleUpdate) { 447async function buildResumeUpload (url: string, token: string, attributes: VideoAttributes, specialStatus = HttpStatusCode.OK_200) {
436 req.field('scheduleUpdate[updateAt]', attributes.scheduleUpdate.updateAt) 448 let size = 0
449 let videoFilePath: string
450 let mimetype = 'video/mp4'
437 451
438 if (attributes.scheduleUpdate.privacy) { 452 if (attributes.fixture) {
439 req.field('scheduleUpdate[privacy]', attributes.scheduleUpdate.privacy) 453 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
454 size = (await stat(videoFilePath)).size
455
456 if (videoFilePath.endsWith('.mkv')) {
457 mimetype = 'video/x-matroska'
458 } else if (videoFilePath.endsWith('.webm')) {
459 mimetype = 'video/webm'
440 } 460 }
441 } 461 }
442 462
443 if (attributes.originallyPublishedAt !== undefined) { 463 const initializeSessionRes = await prepareResumableUpload({ url, token, attributes, size, mimetype })
444 req.field('originallyPublishedAt', attributes.originallyPublishedAt) 464 const initStatus = initializeSessionRes.status
465
466 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
467 const locationHeader = initializeSessionRes.header['location']
468 expect(locationHeader).to.not.be.undefined
469
470 const pathUploadId = locationHeader.split('?')[1]
471
472 return sendResumableChunks({ url, token, pathUploadId, videoFilePath, size, specialStatus })
445 } 473 }
446 474
447 const res = await req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture)) 475 const expectedInitStatus = specialStatus === HttpStatusCode.OK_200
448 .expect(specialStatus) 476 ? HttpStatusCode.CREATED_201
477 : specialStatus
449 478
450 // Wait torrent generation 479 expect(initStatus).to.equal(expectedInitStatus)
451 if (specialStatus === HttpStatusCode.OK_200) {
452 let video: VideoDetails
453 do {
454 const resVideo = await getVideoWithToken(url, accessToken, res.body.video.uuid)
455 video = resVideo.body
456 480
457 await wait(50) 481 return initializeSessionRes
458 } while (!video.files[0].torrentUrl) 482}
483
484async function prepareResumableUpload (options: {
485 url: string
486 token: string
487 attributes: VideoAttributes
488 size: number
489 mimetype: string
490}) {
491 const { url, token, attributes, size, mimetype } = options
492
493 const path = '/api/v1/videos/upload-resumable'
494
495 const req = request(url)
496 .post(path)
497 .set('Authorization', 'Bearer ' + token)
498 .set('X-Upload-Content-Type', mimetype)
499 .set('X-Upload-Content-Length', size.toString())
500
501 buildUploadReq(req, attributes)
502
503 if (attributes.fixture) {
504 req.field('filename', attributes.fixture)
459 } 505 }
460 506
461 return res 507 return req
508}
509
510function sendResumableChunks (options: {
511 url: string
512 token: string
513 pathUploadId: string
514 videoFilePath: string
515 size: number
516 specialStatus?: HttpStatusCode
517 contentLength?: number
518 contentRangeBuilder?: (start: number, chunk: any) => string
519}) {
520 const { url, token, pathUploadId, videoFilePath, size, specialStatus, contentLength, contentRangeBuilder } = options
521
522 const expectedStatus = specialStatus || HttpStatusCode.OK_200
523
524 const path = '/api/v1/videos/upload-resumable'
525 let start = 0
526
527 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
528 return new Promise<GotResponse>((resolve, reject) => {
529 readable.on('data', async function onData (chunk) {
530 readable.pause()
531
532 const headers = {
533 'Authorization': 'Bearer ' + token,
534 'Content-Type': 'application/octet-stream',
535 'Content-Range': contentRangeBuilder
536 ? contentRangeBuilder(start, chunk)
537 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
538 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
539 }
540
541 const res = await got({
542 url,
543 method: 'put',
544 headers,
545 path: path + '?' + pathUploadId,
546 body: chunk,
547 responseType: 'json',
548 throwHttpErrors: false
549 })
550
551 start += chunk.length
552
553 if (res.statusCode === expectedStatus) {
554 return resolve(res)
555 }
556
557 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
558 readable.off('data', onData)
559 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
560 }
561
562 readable.resume()
563 })
564 })
462} 565}
463 566
464function updateVideo ( 567function updateVideo (
@@ -749,11 +852,13 @@ export {
749 getVideoWithToken, 852 getVideoWithToken,
750 getVideosList, 853 getVideosList,
751 removeAllVideos, 854 removeAllVideos,
855 checkUploadVideoParam,
752 getVideosListPagination, 856 getVideosListPagination,
753 getVideosListSort, 857 getVideosListSort,
754 removeVideo, 858 removeVideo,
755 getVideosListWithToken, 859 getVideosListWithToken,
756 uploadVideo, 860 uploadVideo,
861 sendResumableChunks,
757 getVideosWithFilters, 862 getVideosWithFilters,
758 uploadRandomVideoOnServers, 863 uploadRandomVideoOnServers,
759 updateVideo, 864 updateVideo,
@@ -767,5 +872,50 @@ export {
767 getMyVideosWithFilter, 872 getMyVideosWithFilter,
768 uploadVideoAndGetId, 873 uploadVideoAndGetId,
769 getLocalIdByUUID, 874 getLocalIdByUUID,
770 getVideoIdFromUUID 875 getVideoIdFromUUID,
876 prepareResumableUpload
877}
878
879// ---------------------------------------------------------------------------
880
881function buildUploadReq (req: request.Test, attributes: VideoAttributes) {
882
883 for (const key of [ 'name', 'support', 'channelId', 'description', 'originallyPublishedAt' ]) {
884 if (attributes[key] !== undefined) {
885 req.field(key, attributes[key])
886 }
887 }
888
889 for (const key of [ 'nsfw', 'commentsEnabled', 'downloadEnabled', 'waitTranscoding' ]) {
890 if (attributes[key] !== undefined) {
891 req.field(key, JSON.stringify(attributes[key]))
892 }
893 }
894
895 for (const key of [ 'language', 'privacy', 'category', 'licence' ]) {
896 if (attributes[key] !== undefined) {
897 req.field(key, attributes[key].toString())
898 }
899 }
900
901 const tags = attributes.tags || []
902 for (let i = 0; i < tags.length; i++) {
903 req.field('tags[' + i + ']', attributes.tags[i])
904 }
905
906 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
907 if (attributes[key] !== undefined) {
908 req.attach(key, buildAbsoluteFixturePath(attributes[key]))
909 }
910 }
911
912 if (attributes.scheduleUpdate) {
913 if (attributes.scheduleUpdate.updateAt) {
914 req.field('scheduleUpdate[updateAt]', attributes.scheduleUpdate.updateAt)
915 }
916
917 if (attributes.scheduleUpdate.privacy) {
918 req.field('scheduleUpdate[privacy]', attributes.scheduleUpdate.privacy)
919 }
920 }
771} 921}
diff --git a/shared/models/server/debug.model.ts b/shared/models/server/debug.model.ts
index 61cba6518..7ceff9137 100644
--- a/shared/models/server/debug.model.ts
+++ b/shared/models/server/debug.model.ts
@@ -1,3 +1,7 @@
1export interface Debug { 1export interface Debug {
2 ip: string 2 ip: string
3} 3}
4
5export interface SendDebugCommand {
6 command: 'remove-dandling-resumable-uploads'
7}
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 90e30545f..050ab82f8 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -125,7 +125,7 @@ tags:
125 Redundancy is part of the inter-server solidarity that PeerTube fosters. 125 Redundancy is part of the inter-server solidarity that PeerTube fosters.
126 Manage the list of instances you wish to help by seeding their videos according 126 Manage the list of instances you wish to help by seeding their videos according
127 to the policy of video selection of your choice. Note that you have a similar functionality 127 to the policy of video selection of your choice. Note that you have a similar functionality
128 to mirror individual videos, see `Video Mirroring`. 128 to mirror individual videos, see [video mirroring](#tag/Video-Mirroring).
129 externalDocs: 129 externalDocs:
130 url: https://docs.joinpeertube.org/admin-following-instances?id=instances-redundancy 130 url: https://docs.joinpeertube.org/admin-following-instances?id=instances-redundancy
131 - name: Plugins 131 - name: Plugins
@@ -139,6 +139,50 @@ tags:
139 - name: Video 139 - name: Video
140 description: | 140 description: |
141 Operations dealing with listing, uploading, fetching or modifying videos. 141 Operations dealing with listing, uploading, fetching or modifying videos.
142 - name: Video Upload
143 description: |
144 Operations dealing with adding video or audio. PeerTube supports two upload modes, and three import modes.
145
146 ### Upload
147
148 - [_legacy_](#tag/Video-Upload/paths/~1videos~1upload/post), where the video file is sent in a single request
149 - [_resumable_](#tag/Video-Upload/paths/~1videos~1upload-resumable/post), where the video file is sent in chunks
150
151 You can upload videos more reliably by using the resumable variant. Its protocol lets
152 you resume an upload operation after a network interruption or other transmission failure,
153 saving time and bandwidth in the event of network failures.
154
155 Favor using resumable uploads in any of the following cases:
156 - You are transferring large files
157 - The likelihood of a network interruption is high
158 - Uploads are originating from a device with a low-bandwidth or unstable Internet connection,
159 such as a mobile device
160
161 ### Import
162
163 - _URL_-based: where the URL points to any service supported by [youtube-dl](https://ytdl-org.github.io/youtube-dl/)
164 - _magnet_-based: where the URI resolves to a BitTorrent ressource containing a single supported video file
165 - _torrent_-based: where the metainfo file resolves to a BitTorrent ressource containing a single supported video file
166
167 The import function is practical when the desired video/audio is available online. It makes PeerTube
168 download it for you, saving you as much bandwidth and avoiding any instability or limitation your network might have.
169 - name: Video Captions
170 description: Operations dealing with listing, adding and removing closed captions of a video.
171 - name: Video Channels
172 description: Operations dealing with the creation, modification and listing of videos within a channel.
173 - name: Video Comments
174 description: >
175 Operations dealing with comments to a video. Comments are organized in threads: adding a
176 comment in response to the video starts a thread, adding a reply to a comment adds it to
177 its root comment thread.
178 - name: Video Blocks
179 description: Operations dealing with blocking videos (removing them from view and preventing interactions).
180 - name: Video Rates
181 description: Like/dislike a video.
182 - name: Video Playlists
183 description: Operations dealing with playlists of videos. Playlists are bound to users and/or channels.
184 - name: Feeds
185 description: Server syndication feeds
142 - name: Search 186 - name: Search
143 description: | 187 description: |
144 The search helps to find _videos_ or _channels_ from within the instance and beyond. 188 The search helps to find _videos_ or _channels_ from within the instance and beyond.
@@ -148,27 +192,11 @@ tags:
148 192
149 Administrators can also enable the use of a remote search system, indexing 193 Administrators can also enable the use of a remote search system, indexing
150 videos and channels not could be not federated by the instance. 194 videos and channels not could be not federated by the instance.
151 - name: Video Comments 195 - name: Video Mirroring
152 description: > 196 description: |
153 Operations dealing with comments to a video. Comments are organized in 197 PeerTube instances can mirror videos from one another, and help distribute some videos.
154 threads. 198
155 - name: Video Playlists 199 For importing videos as your own, refer to [video imports](#tag/Video-Upload/paths/~1videos~1imports/post).
156 description: >
157 Operations dealing with playlists of videos. Playlists are bound to users
158 and/or channels.
159 - name: Video Channels
160 description: >
161 Operations dealing with the creation, modification and listing of videos within a channel.
162 - name: Video Blocks
163 description: >
164 Operations dealing with blocking videos (removing them from view and
165 preventing interactions).
166 - name: Video Rates
167 description: >
168 Like/dislike a video.
169 - name: Feeds
170 description: >
171 Server syndication feeds
172x-tagGroups: 200x-tagGroups:
173 - name: Accounts 201 - name: Accounts
174 tags: 202 tags:
@@ -181,6 +209,7 @@ x-tagGroups:
181 - name: Videos 209 - name: Videos
182 tags: 210 tags:
183 - Video 211 - Video
212 - Video Upload
184 - Video Captions 213 - Video Captions
185 - Video Channels 214 - Video Channels
186 - Video Comments 215 - Video Comments
@@ -1347,10 +1376,12 @@ paths:
1347 /videos/upload: 1376 /videos/upload:
1348 post: 1377 post:
1349 summary: Upload a video 1378 summary: Upload a video
1379 description: Uses a single request to upload a video.
1350 security: 1380 security:
1351 - OAuth2: [] 1381 - OAuth2: []
1352 tags: 1382 tags:
1353 - Video 1383 - Video
1384 - Video Upload
1354 responses: 1385 responses:
1355 '200': 1386 '200':
1356 description: successful operation 1387 description: successful operation
@@ -1380,80 +1411,7 @@ paths:
1380 content: 1411 content:
1381 multipart/form-data: 1412 multipart/form-data:
1382 schema: 1413 schema:
1383 type: object 1414 $ref: '#/components/schemas/VideoUploadRequestLegacy'
1384 properties:
1385 videofile:
1386 description: Video file
1387 type: string
1388 format: binary
1389 channelId:
1390 description: Channel id that will contain this video
1391 type: integer
1392 thumbnailfile:
1393 description: Video thumbnail file
1394 type: string
1395 format: binary
1396 previewfile:
1397 description: Video preview file
1398 type: string
1399 format: binary
1400 privacy:
1401 $ref: '#/components/schemas/VideoPrivacySet'
1402 category:
1403 description: Video category
1404 type: integer
1405 example: 4
1406 licence:
1407 description: Video licence
1408 type: integer
1409 example: 2
1410 language:
1411 description: Video language
1412 type: string
1413 description:
1414 description: Video description
1415 type: string
1416 waitTranscoding:
1417 description: Whether or not we wait transcoding before publish the video
1418 type: boolean
1419 support:
1420 description: A text tell the audience how to support the video creator
1421 example: Please support my work on <insert crowdfunding plateform>! <3
1422 type: string
1423 nsfw:
1424 description: Whether or not this video contains sensitive content
1425 type: boolean
1426 name:
1427 description: Video name
1428 type: string
1429 minLength: 3
1430 maxLength: 120
1431 tags:
1432 description: Video tags (maximum 5 tags each between 2 and 30 characters)
1433 type: array
1434 minItems: 1
1435 maxItems: 5
1436 uniqueItems: true
1437 items:
1438 type: string
1439 minLength: 2
1440 maxLength: 30
1441 commentsEnabled:
1442 description: Enable or disable comments for this video
1443 type: boolean
1444 downloadEnabled:
1445 description: Enable or disable downloading for this video
1446 type: boolean
1447 originallyPublishedAt:
1448 description: Date when the content was originally published
1449 type: string
1450 format: date-time
1451 scheduleUpdate:
1452 $ref: '#/components/schemas/VideoScheduledUpdate'
1453 required:
1454 - videofile
1455 - channelId
1456 - name
1457 encoding: 1415 encoding:
1458 videofile: 1416 videofile:
1459 contentType: video/mp4, video/webm, video/ogg, video/avi, video/quicktime, video/x-msvideo, video/x-flv, video/x-matroska, application/octet-stream 1417 contentType: video/mp4, video/webm, video/ogg, video/avi, video/quicktime, video/x-msvideo, video/x-flv, video/x-matroska, application/octet-stream
@@ -1490,6 +1448,164 @@ paths:
1490 --form videofile=@"$FILE_PATH" \ 1448 --form videofile=@"$FILE_PATH" \
1491 --form channelId=$CHANNEL_ID \ 1449 --form channelId=$CHANNEL_ID \
1492 --form name="$NAME" 1450 --form name="$NAME"
1451 /videos/upload-resumable:
1452 post:
1453 summary: Initialize the resumable upload of a video
1454 description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the upload of a video
1455 security:
1456 - OAuth2: []
1457 tags:
1458 - Video
1459 - Video Upload
1460 parameters:
1461 - name: X-Upload-Content-Length
1462 in: header
1463 schema:
1464 type: number
1465 example: 2469036
1466 required: true
1467 description: Number of bytes that will be uploaded in subsequent requests. Set this value to the size of the file you are uploading.
1468 - name: X-Upload-Content-Type
1469 in: header
1470 schema:
1471 type: string
1472 format: mimetype
1473 example: video/mp4
1474 required: true
1475 description: MIME type of the file that you are uploading. Depending on your instance settings, acceptable values might vary.
1476 requestBody:
1477 content:
1478 application/json:
1479 schema:
1480 $ref: '#/components/schemas/VideoUploadRequestResumable'
1481 responses:
1482 '200':
1483 description: file already exists, send a [`resume`](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) request instead
1484 '201':
1485 description: created
1486 headers:
1487 Location:
1488 schema:
1489 type: string
1490 format: url
1491 example: /api/v1/videos/upload-resumable?upload_id=471e97554f21dec3b8bb5d4602939c51
1492 Content-Length:
1493 schema:
1494 type: number
1495 example: 0
1496 '400':
1497 description: invalid file field, schedule date or parameter
1498 '413':
1499 description: video file too large, due to quota, absolute max file size or concurrent partial upload limit
1500 '415':
1501 description: video type unsupported
1502 put:
1503 summary: Send chunk for the resumable upload of a video
1504 description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to continue, pause or resume the upload of a video
1505 security:
1506 - OAuth2: []
1507 tags:
1508 - Video
1509 - Video Upload
1510 parameters:
1511 - name: upload_id
1512 in: path
1513 required: true
1514 description: |
1515 Created session id to proceed with. If you didn't send chunks in the last 12 hours, it is
1516 not valid anymore and you need to initialize a new upload.
1517 schema:
1518 type: string
1519 - name: Content-Range
1520 in: header
1521 schema:
1522 type: string
1523 example: bytes 0-262143/2469036
1524 required: true
1525 description: |
1526 Specifies the bytes in the file that the request is uploading.
1527
1528 For example, a value of `bytes 0-262143/1000000` shows that the request is sending the first
1529 262144 bytes (256 x 1024) in a 2,469,036 byte file.
1530 - name: Content-Length
1531 in: header
1532 schema:
1533 type: number
1534 example: 262144
1535 required: true
1536 description: |
1537 Size of the chunk that the request is sending.
1538
1539 The chunk size __must be a multiple of 256 KB__, and unlike [Google Resumable](https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol)
1540 doesn't mandate for chunks to have the same size throughout the upload sequence.
1541
1542 Remember that larger chunks are more efficient. PeerTube's web client uses chunks varying from
1543 1048576 bytes (~1MB) and increases or reduces size depending on connection health.
1544 requestBody:
1545 content:
1546 application/octet-stream:
1547 schema:
1548 type: string
1549 format: binary
1550 responses:
1551 '200':
1552 description: last chunk received
1553 headers:
1554 Content-Length:
1555 schema:
1556 type: number
1557 content:
1558 application/json:
1559 schema:
1560 $ref: '#/components/schemas/VideoUploadResponse'
1561 '308':
1562 description: resume incomplete
1563 headers:
1564 Range:
1565 schema:
1566 type: string
1567 example: bytes=0-262143
1568 Content-Length:
1569 schema:
1570 type: number
1571 example: 0
1572 '403':
1573 description: video didn't pass upload filter
1574 '413':
1575 description: video file too large, due to quota or max body size limit set by the reverse-proxy
1576 '422':
1577 description: video unreadable
1578 delete:
1579 summary: Cancel the resumable upload of a video, deleting any data uploaded so far
1580 description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to cancel the upload of a video
1581 security:
1582 - OAuth2: []
1583 tags:
1584 - Video
1585 - Video Upload
1586 parameters:
1587 - name: upload_id
1588 in: path
1589 required: true
1590 description: |
1591 Created session id to proceed with. If you didn't send chunks in the last 12 hours, it is
1592 not valid anymore and the upload session has already been deleted with its data ;-)
1593 schema:
1594 type: string
1595 - name: Content-Length
1596 in: header
1597 required: true
1598 schema:
1599 type: number
1600 example: 0
1601 responses:
1602 '204':
1603 description: upload cancelled
1604 headers:
1605 Content-Length:
1606 schema:
1607 type: number
1608 example: 0
1493 /videos/imports: 1609 /videos/imports:
1494 post: 1610 post:
1495 summary: Import a video 1611 summary: Import a video
@@ -1498,6 +1614,7 @@ paths:
1498 - OAuth2: [] 1614 - OAuth2: []
1499 tags: 1615 tags:
1500 - Video 1616 - Video
1617 - Video Upload
1501 requestBody: 1618 requestBody:
1502 content: 1619 content:
1503 multipart/form-data: 1620 multipart/form-data:
@@ -1688,7 +1805,7 @@ paths:
1688 1805
1689 /videos/live/{id}: 1806 /videos/live/{id}:
1690 get: 1807 get:
1691 summary: Get a live information 1808 summary: Get information about a live
1692 security: 1809 security:
1693 - OAuth2: [] 1810 - OAuth2: []
1694 tags: 1811 tags:
@@ -1704,7 +1821,7 @@ paths:
1704 schema: 1821 schema:
1705 $ref: '#/components/schemas/LiveVideoResponse' 1822 $ref: '#/components/schemas/LiveVideoResponse'
1706 put: 1823 put:
1707 summary: Update a live information 1824 summary: Update information about a live
1708 security: 1825 security:
1709 - OAuth2: [] 1826 - OAuth2: []
1710 tags: 1827 tags:
@@ -3940,6 +4057,7 @@ components:
3940 oneOf: 4057 oneOf:
3941 - type: string 4058 - type: string
3942 - type: array 4059 - type: array
4060 maxItems: 5
3943 items: 4061 items:
3944 type: string 4062 type: string
3945 style: form 4063 style: form
@@ -4636,7 +4754,7 @@ components:
4636 message: 4754 message:
4637 type: string 4755 type: string
4638 minLength: 2 4756 minLength: 2
4639 maxLength: 3000 4757 maxLength: 3000
4640 byModerator: 4758 byModerator:
4641 type: boolean 4759 type: boolean
4642 createdAt: 4760 createdAt:
@@ -5229,6 +5347,7 @@ components:
5229 PredefinedAbuseReasons: 5347 PredefinedAbuseReasons:
5230 description: Reason categories that help triage reports 5348 description: Reason categories that help triage reports
5231 type: array 5349 type: array
5350 maxItems: 8
5232 items: 5351 items:
5233 type: string 5352 type: string
5234 enum: 5353 enum:
@@ -5298,6 +5417,103 @@ components:
5298 id: 5417 id:
5299 type: integer 5418 type: integer
5300 example: 37 5419 example: 37
5420 VideoUploadRequestCommon:
5421 properties:
5422 name:
5423 description: Video name
5424 type: string
5425 channelId:
5426 description: Channel id that will contain this video
5427 type: integer
5428 privacy:
5429 $ref: '#/components/schemas/VideoPrivacySet'
5430 category:
5431 description: Video category
5432 type: integer
5433 example: 4
5434 licence:
5435 description: Video licence
5436 type: integer
5437 example: 2
5438 language:
5439 description: Video language
5440 type: string
5441 description:
5442 description: Video description
5443 type: string
5444 waitTranscoding:
5445 description: Whether or not we wait transcoding before publish the video
5446 type: boolean
5447 support:
5448 description: A text tell the audience how to support the video creator
5449 example: Please support my work on <insert crowdfunding plateform>! <3
5450 type: string
5451 nsfw:
5452 description: Whether or not this video contains sensitive content
5453 type: boolean
5454 tags:
5455 description: Video tags (maximum 5 tags each between 2 and 30 characters)
5456 type: array
5457 minItems: 1
5458 maxItems: 5
5459 uniqueItems: true
5460 items:
5461 type: string
5462 minLength: 2
5463 maxLength: 30
5464 commentsEnabled:
5465 description: Enable or disable comments for this video
5466 type: boolean
5467 downloadEnabled:
5468 description: Enable or disable downloading for this video
5469 type: boolean
5470 originallyPublishedAt:
5471 description: Date when the content was originally published
5472 type: string
5473 format: date-time
5474 scheduleUpdate:
5475 $ref: '#/components/schemas/VideoScheduledUpdate'
5476 thumbnailfile:
5477 description: Video thumbnail file
5478 type: string
5479 format: binary
5480 previewfile:
5481 description: Video preview file
5482 type: string
5483 format: binary
5484 required:
5485 - channelId
5486 - name
5487 VideoUploadRequestLegacy:
5488 allOf:
5489 - $ref: '#/components/schemas/VideoUploadRequestCommon'
5490 - type: object
5491 required:
5492 - videofile
5493 properties:
5494 videofile:
5495 description: Video file
5496 type: string
5497 format: binary
5498 VideoUploadRequestResumable:
5499 allOf:
5500 - $ref: '#/components/schemas/VideoUploadRequestCommon'
5501 - type: object
5502 required:
5503 - filename
5504 properties:
5505 filename:
5506 description: Video filename including extension
5507 type: string
5508 format: filename
5509 thumbnailfile:
5510 description: Video thumbnail file
5511 type: string
5512 format: binary
5513 previewfile:
5514 description: Video preview file
5515 type: string
5516 format: binary
5301 VideoUploadResponse: 5517 VideoUploadResponse:
5302 properties: 5518 properties:
5303 video: 5519 video:
diff --git a/support/nginx/peertube b/support/nginx/peertube
index 00ce1d0dc..385acac24 100644
--- a/support/nginx/peertube
+++ b/support/nginx/peertube
@@ -78,6 +78,13 @@ server {
78 try_files /dev/null @api; 78 try_files /dev/null @api;
79 } 79 }
80 80
81 location = /api/v1/videos/upload-resumable {
82 client_max_body_size 0;
83 proxy_request_buffering off;
84
85 try_files /dev/null @api;
86 }
87
81 location = /api/v1/videos/upload { 88 location = /api/v1/videos/upload {
82 limit_except POST HEAD { deny all; } 89 limit_except POST HEAD { deny all; }
83 90
diff --git a/yarn.lock b/yarn.lock
index b61589fa5..adfb8c912 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1061,6 +1061,15 @@
1061 resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" 1061 resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44"
1062 integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== 1062 integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==
1063 1063
1064"@uploadx/core@^4.4.0":
1065 version "4.4.0"
1066 resolved "https://registry.yarnpkg.com/@uploadx/core/-/core-4.4.0.tgz#27ea2b0d28125e81a6bdd65637dc5c7829306cc7"
1067 integrity sha512-dU0oDURYR5RvuAzf63EL9e/fCY4OOQKOs237UTbZDulbRbiyxwEZR+IpRYYr3hKRjjij03EF/Y5j54VGkebAKg==
1068 dependencies:
1069 bytes "^3.1.0"
1070 debug "^4.3.1"
1071 multiparty "^4.2.2"
1072
1064abbrev@1: 1073abbrev@1:
1065 version "1.1.1" 1074 version "1.1.1"
1066 resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" 1075 resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@@ -1794,7 +1803,7 @@ busboy@^0.2.11:
1794 dicer "0.2.5" 1803 dicer "0.2.5"
1795 readable-stream "1.1.x" 1804 readable-stream "1.1.x"
1796 1805
1797bytes@3.1.0, bytes@^3.0.0: 1806bytes@3.1.0, bytes@^3.0.0, bytes@^3.1.0:
1798 version "3.1.0" 1807 version "3.1.0"
1799 resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" 1808 resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
1800 integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== 1809 integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
@@ -4098,6 +4107,17 @@ http-errors@~1.7.2:
4098 statuses ">= 1.5.0 < 2" 4107 statuses ">= 1.5.0 < 2"
4099 toidentifier "1.0.0" 4108 toidentifier "1.0.0"
4100 4109
4110http-errors@~1.8.0:
4111 version "1.8.0"
4112 resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507"
4113 integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==
4114 dependencies:
4115 depd "~1.1.2"
4116 inherits "2.0.4"
4117 setprototypeof "1.2.0"
4118 statuses ">= 1.5.0 < 2"
4119 toidentifier "1.0.0"
4120
4101"http-node@github:feross/http-node#webtorrent": 4121"http-node@github:feross/http-node#webtorrent":
4102 version "1.2.0" 4122 version "1.2.0"
4103 resolved "https://codeload.github.com/feross/http-node/tar.gz/342ef8624495343ffd050bd0808b3750cf0e3974" 4123 resolved "https://codeload.github.com/feross/http-node/tar.gz/342ef8624495343ffd050bd0808b3750cf0e3974"
@@ -5567,6 +5587,15 @@ multimatch@^5.0.0:
5567 arrify "^2.0.1" 5587 arrify "^2.0.1"
5568 minimatch "^3.0.4" 5588 minimatch "^3.0.4"
5569 5589
5590multiparty@^4.2.2:
5591 version "4.2.2"
5592 resolved "https://registry.yarnpkg.com/multiparty/-/multiparty-4.2.2.tgz#bee5fb5737247628d39dab4979ffd6d57bf60ef6"
5593 integrity sha512-NtZLjlvsjcoGrzojtwQwn/Tm90aWJ6XXtPppYF4WmOk/6ncdwMMKggFY2NlRRN9yiCEIVxpOfPWahVEG2HAG8Q==
5594 dependencies:
5595 http-errors "~1.8.0"
5596 safe-buffer "5.2.1"
5597 uid-safe "2.1.5"
5598
5570multistream@^4.0.1, multistream@^4.1.0: 5599multistream@^4.0.1, multistream@^4.1.0:
5571 version "4.1.0" 5600 version "4.1.0"
5572 resolved "https://registry.yarnpkg.com/multistream/-/multistream-4.1.0.tgz#7bf00dfd119556fbc153cff3de4c6d477909f5a8" 5601 resolved "https://registry.yarnpkg.com/multistream/-/multistream-4.1.0.tgz#7bf00dfd119556fbc153cff3de4c6d477909f5a8"
@@ -6656,6 +6685,11 @@ random-access-storage@^1.1.1:
6656 dependencies: 6685 dependencies:
6657 inherits "^2.0.3" 6686 inherits "^2.0.3"
6658 6687
6688random-bytes@~1.0.0:
6689 version "1.0.0"
6690 resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b"
6691 integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=
6692
6659random-iterate@^1.0.1: 6693random-iterate@^1.0.1:
6660 version "1.0.1" 6694 version "1.0.1"
6661 resolved "https://registry.yarnpkg.com/random-iterate/-/random-iterate-1.0.1.tgz#f7d97d92dee6665ec5f6da08c7f963cad4b2ac99" 6695 resolved "https://registry.yarnpkg.com/random-iterate/-/random-iterate-1.0.1.tgz#f7d97d92dee6665ec5f6da08c7f963cad4b2ac99"
@@ -7040,7 +7074,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
7040 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" 7074 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
7041 integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== 7075 integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
7042 7076
7043safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.2.0: 7077safe-buffer@5.2.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.2.0:
7044 version "5.2.1" 7078 version "5.2.1"
7045 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 7079 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
7046 integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 7080 integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -7186,6 +7220,11 @@ setprototypeof@1.1.1:
7186 resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" 7220 resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
7187 integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== 7221 integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
7188 7222
7223setprototypeof@1.2.0:
7224 version "1.2.0"
7225 resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
7226 integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
7227
7189shebang-command@^1.2.0: 7228shebang-command@^1.2.0:
7190 version "1.2.0" 7229 version "1.2.0"
7191 resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" 7230 resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@@ -8139,6 +8178,13 @@ uc.micro@^1.0.1, uc.micro@^1.0.5:
8139 resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" 8178 resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
8140 integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== 8179 integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
8141 8180
8181uid-safe@2.1.5:
8182 version "2.1.5"
8183 resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a"
8184 integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==
8185 dependencies:
8186 random-bytes "~1.0.0"
8187
8142uint64be@^2.0.2: 8188uint64be@^2.0.2:
8143 version "2.0.2" 8189 version "2.0.2"
8144 resolved "https://registry.yarnpkg.com/uint64be/-/uint64be-2.0.2.tgz#ef4a179752fe8f9ddaa29544ecfc13490031e8e5" 8190 resolved "https://registry.yarnpkg.com/uint64be/-/uint64be-2.0.2.tgz#ef4a179752fe8f9ddaa29544ecfc13490031e8e5"