diff options
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' | |||
2 | import { HttpErrorResponse } from '@angular/common/http' | 2 | import { HttpErrorResponse } from '@angular/common/http' |
3 | import { AfterViewChecked, Component, OnInit } from '@angular/core' | 3 | import { AfterViewChecked, Component, OnInit } from '@angular/core' |
4 | import { AuthService, Notifier, User, UserService } from '@app/core' | 4 | import { AuthService, Notifier, User, UserService } from '@app/core' |
5 | import { uploadErrorHandler } from '@app/helpers' | 5 | import { 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' | |||
3 | import { Component, OnDestroy, OnInit } from '@angular/core' | 3 | import { Component, OnDestroy, OnInit } from '@angular/core' |
4 | import { ActivatedRoute, Router } from '@angular/router' | 4 | import { ActivatedRoute, Router } from '@angular/router' |
5 | import { AuthService, Notifier, ServerService } from '@app/core' | 5 | import { AuthService, Notifier, ServerService } from '@app/core' |
6 | import { uploadErrorHandler } from '@app/helpers' | 6 | import { genericUploadErrorHandler } from '@app/helpers' |
7 | import { | 7 | import { |
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 @@ | |||
1 | import { objectToFormData } from '@app/helpers' | ||
2 | import { 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 | */ | ||
15 | export 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 @@ | |||
1 | import { Subscription } from 'rxjs' | ||
2 | import { HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http' | ||
3 | import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' |
4 | import { Router } from '@angular/router' | 2 | import { Router } from '@angular/router' |
3 | import { UploadxOptions, UploadState, UploadxService } from 'ngx-uploadx' | ||
4 | import { UploaderXFormData } from './uploaderx-form-data' | ||
5 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService, UserService } from '@app/core' | 5 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService, UserService } from '@app/core' |
6 | import { scrollToTop, uploadErrorHandler } from '@app/helpers' | 6 | import { scrollToTop, genericUploadErrorHandler } from '@app/helpers' |
7 | import { FormValidatorService } from '@app/shared/shared-forms' | 7 | import { FormValidatorService } from '@app/shared/shared-forms' |
8 | import { BytesPipe, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 8 | import { BytesPipe, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' |
9 | import { LoadingBarService } from '@ngx-loading-bar/core' | 9 | import { LoadingBarService } from '@ngx-loading-bar/core' |
10 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' | 10 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' |
11 | import { VideoPrivacy } from '@shared/models' | 11 | import { VideoPrivacy } from '@shared/models' |
12 | import { VideoSend } from './video-send' | 12 | import { VideoSend } from './video-send' |
13 | import { 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 | }) |
23 | export class VideoUploadComponent extends VideoSend implements OnInit, AfterViewInit, OnDestroy, CanComponentDeactivate { | 24 | export 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})` |
289 | video 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 ( | ||
309 | video 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 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { CanDeactivateGuard } from '@app/core' | 2 | import { CanDeactivateGuard } from '@app/core' |
3 | import { UploadxModule } from 'ngx-uploadx' | ||
3 | import { VideoEditModule } from './shared/video-edit.module' | 4 | import { VideoEditModule } from './shared/video-edit.module' |
4 | import { DragDropDirective } from './video-add-components/drag-drop.directive' | 5 | import { DragDropDirective } from './video-add-components/drag-drop.directive' |
5 | import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' | 6 | import { 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 | ||
176 | function uploadErrorHandler (parameters: { | 176 | function 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 | ||
7796 | ngx-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 | |||
7796 | nice-try@^1.0.4: | 7803 | nice-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", |
@@ -116,6 +116,7 @@ import { YoutubeDlUpdateScheduler } from './server/lib/schedulers/youtube-dl-upd | |||
116 | import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler' | 116 | import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler' |
117 | import { RemoveOldHistoryScheduler } from './server/lib/schedulers/remove-old-history-scheduler' | 117 | import { RemoveOldHistoryScheduler } from './server/lib/schedulers/remove-old-history-scheduler' |
118 | import { AutoFollowIndexInstances } from './server/lib/schedulers/auto-follow-index-instances' | 118 | import { AutoFollowIndexInstances } from './server/lib/schedulers/auto-follow-index-instances' |
119 | import { RemoveDanglingResumableUploadsScheduler } from './server/lib/schedulers/remove-dangling-resumable-uploads-scheduler' | ||
119 | import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto' | 120 | import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto' |
120 | import { PeerTubeSocket } from './server/lib/peertube-socket' | 121 | import { PeerTubeSocket } from './server/lib/peertube-socket' |
121 | import { updateStreamingPlaylistsInfohashesIfNeeded } from './server/lib/hls' | 122 | import { 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 @@ | |||
1 | import { InboxManager } from '@server/lib/activitypub/inbox-manager' | 1 | import { InboxManager } from '@server/lib/activitypub/inbox-manager' |
2 | import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler' | ||
3 | import { SendDebugCommand } from '@shared/models' | ||
2 | import * as express from 'express' | 4 | import * as express from 'express' |
3 | import { UserRight } from '../../../../shared/models/users' | 5 | import { UserRight } from '../../../../shared/models/users' |
4 | import { authenticate, ensureUserHasRight } from '../../../middlewares' | 6 | import { authenticate, ensureUserHasRight } from '../../../middlewares' |
@@ -11,6 +13,12 @@ debugRouter.get('/debug', | |||
11 | getDebug | 13 | getDebug |
12 | ) | 14 | ) |
13 | 15 | ||
16 | debugRouter.post('/debug/run-command', | ||
17 | authenticate, | ||
18 | ensureUserHasRight(UserRight.MANAGE_DEBUG), | ||
19 | runCommand | ||
20 | ) | ||
21 | |||
14 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
15 | 23 | ||
16 | export { | 24 | export { |
@@ -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 | |||
37 | async 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' | |||
2 | import { move } from 'fs-extra' | 2 | import { move } from 'fs-extra' |
3 | import { extname } from 'path' | 3 | import { extname } from 'path' |
4 | import toInt from 'validator/lib/toInt' | 4 | import toInt from 'validator/lib/toInt' |
5 | import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload' | ||
5 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | 6 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' |
6 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' | 7 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' |
7 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | 8 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' |
@@ -10,8 +11,9 @@ import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnail | |||
10 | import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' | 11 | import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' |
11 | import { getServerActor } from '@server/models/application/application' | 12 | import { getServerActor } from '@server/models/application/application' |
12 | import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | 13 | import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' |
14 | import { uploadx } from '@uploadx/core' | ||
13 | import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared' | 15 | import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared' |
14 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | 16 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs' |
15 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | 17 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' |
16 | import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' | 18 | import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' |
17 | import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils' | 19 | import { 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' | |||
69 | const lTags = loggerTagsFactory('api', 'video') | 73 | const lTags = loggerTagsFactory('api', 'video') |
70 | const auditLogger = auditLoggerFactory('videos') | 74 | const auditLogger = auditLoggerFactory('videos') |
71 | const videosRouter = express.Router() | 75 | const videosRouter = express.Router() |
76 | const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() }) | ||
72 | 77 | ||
73 | const reqVideoFileAdd = createReqFiles( | 78 | const 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 | |||
88 | const reqVideoFileAddResumable = createReqFiles( | ||
89 | [ 'thumbnailfile', 'previewfile' ], | ||
90 | MIMETYPES.IMAGE.MIMETYPE_EXT, | ||
91 | { | ||
92 | thumbnailfile: getResumableUploadPath(), | ||
93 | previewfile: getResumableUploadPath() | ||
94 | } | ||
95 | ) | ||
96 | |||
82 | const reqVideoFileUpdate = createReqFiles( | 97 | const 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 | |||
130 | videosRouter.post('/upload', | ||
131 | authenticate, | ||
132 | reqVideoFileAdd, | ||
133 | asyncMiddleware(videosAddLegacyValidator), | ||
134 | asyncRetryTransactionMiddleware(addVideoLegacy) | ||
135 | ) | ||
136 | |||
137 | videosRouter.post('/upload-resumable', | ||
138 | authenticate, | ||
139 | reqVideoFileAddResumable, | ||
140 | asyncMiddleware(videosAddResumableInitValidator), | ||
141 | uploadxMiddleware | ||
142 | ) | ||
143 | |||
144 | videosRouter.delete('/upload-resumable', | ||
145 | authenticate, | ||
146 | uploadxMiddleware | ||
147 | ) | ||
148 | |||
149 | videosRouter.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 | |||
114 | videosRouter.put('/:id', | 156 | videosRouter.put('/:id', |
115 | authenticate, | 157 | authenticate, |
116 | reqVideoFileUpdate, | 158 | reqVideoFileUpdate, |
117 | asyncMiddleware(videosUpdateValidator), | 159 | asyncMiddleware(videosUpdateValidator), |
118 | asyncRetryTransactionMiddleware(updateVideo) | 160 | asyncRetryTransactionMiddleware(updateVideo) |
119 | ) | 161 | ) |
120 | videosRouter.post('/upload', | ||
121 | authenticate, | ||
122 | reqVideoFileAdd, | ||
123 | asyncMiddleware(videosAddValidator), | ||
124 | asyncRetryTransactionMiddleware(addVideo) | ||
125 | ) | ||
126 | 162 | ||
127 | videosRouter.get('/:id/description', | 163 | videosRouter.get('/:id/description', |
128 | asyncMiddleware(videosGetValidator), | 164 | asyncMiddleware(videosGetValidator), |
@@ -157,23 +193,23 @@ export { | |||
157 | 193 | ||
158 | // --------------------------------------------------------------------------- | 194 | // --------------------------------------------------------------------------- |
159 | 195 | ||
160 | function listVideoCategories (req: express.Request, res: express.Response) { | 196 | function listVideoCategories (_req: express.Request, res: express.Response) { |
161 | res.json(VIDEO_CATEGORIES) | 197 | res.json(VIDEO_CATEGORIES) |
162 | } | 198 | } |
163 | 199 | ||
164 | function listVideoLicences (req: express.Request, res: express.Response) { | 200 | function listVideoLicences (_req: express.Request, res: express.Response) { |
165 | res.json(VIDEO_LICENCES) | 201 | res.json(VIDEO_LICENCES) |
166 | } | 202 | } |
167 | 203 | ||
168 | function listVideoLanguages (req: express.Request, res: express.Response) { | 204 | function listVideoLanguages (_req: express.Request, res: express.Response) { |
169 | res.json(VIDEO_LANGUAGES) | 205 | res.json(VIDEO_LANGUAGES) |
170 | } | 206 | } |
171 | 207 | ||
172 | function listVideoPrivacies (req: express.Request, res: express.Response) { | 208 | function listVideoPrivacies (_req: express.Request, res: express.Response) { |
173 | res.json(VIDEO_PRIVACIES) | 209 | res.json(VIDEO_PRIVACIES) |
174 | } | 210 | } |
175 | 211 | ||
176 | async function addVideo (req: express.Request, res: express.Response) { | 212 | async 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 | |||
227 | async 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) | 238 | async 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 @@ | |||
1 | import 'multer' | 1 | import 'multer' |
2 | import validator from 'validator' | 2 | import { UploadFilesForCheck } from 'express' |
3 | import { sep } from 'path' | 3 | import { sep } from 'path' |
4 | import validator from 'validator' | ||
4 | 5 | ||
5 | function exists (value: any) { | 6 | function 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 | ||
110 | function isFileMimeTypeValid ( | 111 | function 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 @@ | |||
1 | import { UploadFilesForCheck } from 'express' | ||
1 | import { values } from 'lodash' | 2 | import { values } from 'lodash' |
3 | import * as magnetUtil from 'magnet-uri' | ||
2 | import validator from 'validator' | 4 | import validator from 'validator' |
3 | import { VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared' | 5 | import { VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared' |
4 | import { | 6 | import { |
@@ -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' |
14 | import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' | 16 | import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' |
15 | import * as magnetUtil from 'magnet-uri' | ||
16 | 17 | ||
17 | const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS | 18 | const 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 | ||
84 | function isVideoFileMimeTypeValid (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { | 85 | function 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' | |||
2 | import * as multer from 'multer' | 2 | import * as multer from 'multer' |
3 | import { REMOTE_SCHEME } from '../initializers/constants' | 3 | import { REMOTE_SCHEME } from '../initializers/constants' |
4 | import { logger } from './logger' | 4 | import { logger } from './logger' |
5 | import { deleteFileAsync, generateRandomString } from './utils' | 5 | import { deleteFileAndCatch, generateRandomString } from './utils' |
6 | import { extname } from 'path' | 6 | import { extname } from 'path' |
7 | import { isArray } from './custom-validators/misc' | 7 | import { isArray } from './custom-validators/misc' |
8 | import { CONFIG } from '../initializers/config' | 8 | import { 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 @@ | |||
1 | import { METAFILE_EXTNAME } from '@uploadx/core' | ||
2 | import { remove } from 'fs-extra' | ||
3 | import { join } from 'path' | ||
4 | import { RESUMABLE_UPLOAD_DIRECTORY } from '../initializers/constants' | ||
5 | |||
6 | function getResumableUploadPath (filename?: string) { | ||
7 | if (filename) return join(RESUMABLE_UPLOAD_DIRECTORY, filename) | ||
8 | |||
9 | return RESUMABLE_UPLOAD_DIRECTORY | ||
10 | } | ||
11 | |||
12 | function deleteResumableUploadMetaFile (filepath: string) { | ||
13 | return remove(filepath + METAFILE_EXTNAME) | ||
14 | } | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | |||
18 | export { | ||
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' | |||
6 | import { execPromise, execPromise2, randomBytesPromise, sha256 } from './core-utils' | 6 | import { execPromise, execPromise2, randomBytesPromise, sha256 } from './core-utils' |
7 | import { logger } from './logger' | 7 | import { logger } from './logger' |
8 | 8 | ||
9 | function deleteFileAsync (path: string) { | 9 | function 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 | ||
85 | export { | 85 | export { |
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 | ||
650 | const RESUMABLE_UPLOAD_DIRECTORY = join(CONFIG.STORAGE.TMP_DIR, 'resumable-uploads') | ||
648 | const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') | 651 | const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') |
649 | const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') | 652 | const 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' | |||
6 | import { ApplicationModel } from '../models/application/application' | 6 | import { ApplicationModel } from '../models/application/application' |
7 | import { OAuthClientModel } from '../models/oauth/oauth-client' | 7 | import { OAuthClientModel } from '../models/oauth/oauth-client' |
8 | import { applicationExist, clientsExist, usersExist } from './checker-after-init' | 8 | import { applicationExist, clientsExist, usersExist } from './checker-after-init' |
9 | import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION } from './constants' | 9 | import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION, RESUMABLE_UPLOAD_DIRECTORY } from './constants' |
10 | import { sequelizeTypescript } from './database' | 10 | import { sequelizeTypescript } from './database' |
11 | import { ensureDir, remove } from 'fs-extra' | 11 | import { ensureDir, remove } from 'fs-extra' |
12 | import { CONFIG } from './config' | 12 | import { 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 @@ | |||
1 | import { VideoUploadFile } from 'express' | ||
1 | import { PathLike } from 'fs-extra' | 2 | import { PathLike } from 'fs-extra' |
2 | import { Transaction } from 'sequelize/types' | 3 | import { Transaction } from 'sequelize/types' |
3 | import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger' | 4 | import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger' |
5 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | ||
4 | import { logger } from '@server/helpers/logger' | 6 | import { logger } from '@server/helpers/logger' |
5 | import { AbuseModel } from '@server/models/abuse/abuse' | 7 | import { AbuseModel } from '@server/models/abuse/abuse' |
6 | import { VideoAbuseModel } from '@server/models/abuse/video-abuse' | 8 | import { VideoAbuseModel } from '@server/models/abuse/video-abuse' |
@@ -28,7 +30,6 @@ import { VideoModel } from '../models/video/video' | |||
28 | import { VideoCommentModel } from '../models/video/video-comment' | 30 | import { VideoCommentModel } from '../models/video/video-comment' |
29 | import { sendAbuse } from './activitypub/send/send-flag' | 31 | import { sendAbuse } from './activitypub/send/send-flag' |
30 | import { Notifier } from './notifier' | 32 | import { Notifier } from './notifier' |
31 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | ||
32 | 33 | ||
33 | export type AcceptResult = { | 34 | export 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 |
39 | function isLocalVideoAccepted (object: { | 40 | function 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 @@ | |||
1 | import * as bluebird from 'bluebird' | ||
2 | import { readdir, remove, stat } from 'fs-extra' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
4 | import { getResumableUploadPath } from '@server/helpers/upload' | ||
5 | import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants' | ||
6 | import { METAFILE_EXTNAME } from '@uploadx/core' | ||
7 | import { AbstractScheduler } from './abstract-scheduler' | ||
8 | |||
9 | const lTags = loggerTagsFactory('scheduler', 'resumable-upload', 'cleaner') | ||
10 | |||
11 | export 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 @@ | |||
1 | import { UploadFiles } from 'express' | ||
1 | import { Transaction } from 'sequelize/types' | 2 | import { Transaction } from 'sequelize/types' |
2 | import { DEFAULT_AUDIO_RESOLUTION, JOB_PRIORITY } from '@server/initializers/constants' | 3 | import { DEFAULT_AUDIO_RESOLUTION, JOB_PRIORITY } from '@server/initializers/constants' |
3 | import { sequelizeTypescript } from '@server/initializers/database' | 4 | import { sequelizeTypescript } from '@server/initializers/database' |
@@ -32,7 +33,7 @@ function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): Fil | |||
32 | 33 | ||
33 | async function buildVideoThumbnailsFromReq (options: { | 34 | async 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' | |||
3 | import { ValidationChain } from 'express-validator' | 3 | import { ValidationChain } from 'express-validator' |
4 | import { ExpressPromiseHandler } from '@server/types/express' | 4 | import { ExpressPromiseHandler } from '@server/types/express' |
5 | import { retryTransactionWrapper } from '../helpers/database-utils' | 5 | import { retryTransactionWrapper } from '../helpers/database-utils' |
6 | import { 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body, param, query, ValidationChain } from 'express-validator' | 2 | import { body, header, param, query, ValidationChain } from 'express-validator' |
3 | import { getResumableUploadPath } from '@server/helpers/upload' | ||
3 | import { isAbleToUploadVideo } from '@server/lib/user' | 4 | import { isAbleToUploadVideo } from '@server/lib/user' |
4 | import { getServerActor } from '@server/models/application/application' | 5 | import { getServerActor } from '@server/models/application/application' |
5 | import { ExpressPromiseHandler } from '@server/types/express' | 6 | import { ExpressPromiseHandler } from '@server/types/express' |
6 | import { MVideoWithRights } from '@server/types/models' | 7 | import { MUserAccountId, MVideoWithRights } from '@server/types/models' |
7 | import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' | 8 | import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' |
8 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | 9 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' |
9 | import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' | 10 | import { 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' |
51 | import { deleteFileAndCatch } from '../../../helpers/utils' | ||
50 | import { getVideoWithAttributes } from '../../../helpers/video' | 52 | import { getVideoWithAttributes } from '../../../helpers/video' |
51 | import { CONFIG } from '../../../initializers/config' | 53 | import { CONFIG } from '../../../initializers/config' |
52 | import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' | 54 | import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' |
@@ -57,7 +59,7 @@ import { VideoModel } from '../../../models/video/video' | |||
57 | import { authenticatePromiseIfNeeded } from '../../auth' | 59 | import { authenticatePromiseIfNeeded } from '../../auth' |
58 | import { areValidationErrors } from '../utils' | 60 | import { areValidationErrors } from '../utils' |
59 | 61 | ||
60 | const videosAddValidator = getCommonVideoEditAttributes().concat([ | 62 | const 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 | */ | ||
105 | const 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 | */ | ||
142 | const 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 | ||
480 | export { | 545 | export { |
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 | ||
518 | async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) { | 586 | async 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 | |||
626 | export 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 | |||
654 | async 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' | |||
13 | import './redundancy' | 13 | import './redundancy' |
14 | import './search' | 14 | import './search' |
15 | import './services' | 15 | import './services' |
16 | import './upload-quota' | ||
16 | import './user-notifications' | 17 | import './user-notifications' |
17 | import './user-subscriptions' | 18 | import './user-subscriptions' |
18 | import './users' | 19 | import './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 | |||
3 | import 'mocha' | ||
4 | import { expect } from 'chai' | ||
5 | import { HttpStatusCode, randomInt } from '@shared/core-utils' | ||
6 | import { getGoodVideoUrl, getMagnetURI, getMyVideoImports, importVideo } from '@shared/extra-utils/videos/video-imports' | ||
7 | import { MyUser, VideoImport, VideoImportState, VideoPrivacy } from '@shared/models' | ||
8 | import { | ||
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 | |||
23 | describe('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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import { expect } from 'chai' | ||
5 | import { omit } from 'lodash' | 4 | import { omit } from 'lodash' |
6 | import { join } from 'path' | 5 | import { join } from 'path' |
7 | import { User, UserRole, VideoImport, VideoImportState } from '../../../../shared' | 6 | import { User, UserRole } from '../../../../shared' |
7 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | ||
8 | import { | 8 | import { |
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' |
42 | import { waitJobs } from '../../../../shared/extra-utils/server/jobs' | ||
43 | import { getGoodVideoUrl, getMagnetURI, getMyVideoImports, importVideo } from '../../../../shared/extra-utils/videos/video-imports' | ||
44 | import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model' | 41 | import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model' |
45 | import { VideoPrivacy } from '../../../../shared/models/videos' | ||
46 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | ||
47 | 42 | ||
48 | describe('Test users API validators', function () { | 43 | describe('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 | ||
3 | import 'mocha' | ||
3 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
4 | import { omit } from 'lodash' | 5 | import { omit } from 'lodash' |
5 | import 'mocha' | ||
6 | import { join } from 'path' | 6 | import { join } from 'path' |
7 | import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' | 7 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' |
8 | import { | 8 | import { |
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' |
26 | import { | 27 | import { |
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' |
31 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | 32 | import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' |
33 | import { randomInt } from '@shared/core-utils' | ||
32 | 34 | ||
33 | const expect = chai.expect | 35 | const 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 @@ | |||
1 | import './audio-only' | 1 | import './audio-only' |
2 | import './multiple-servers' | 2 | import './multiple-servers' |
3 | import './resumable-upload' | ||
3 | import './single-server' | 4 | import './single-server' |
4 | import './video-captions' | 5 | import './video-captions' |
5 | import './video-change-ownership' | 6 | import './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 | |||
3 | import 'mocha' | ||
4 | import * as chai from 'chai' | ||
5 | import { pathExists, readdir, stat } from 'fs-extra' | ||
6 | import { join } from 'path' | ||
7 | import { HttpStatusCode } from '@shared/core-utils' | ||
8 | import { | ||
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' | ||
21 | import { MyUser, VideoPrivacy } from '@shared/models' | ||
22 | |||
23 | const expect = chai.expect | ||
24 | |||
25 | // Most classic resumable upload tests are done in other test suites | ||
26 | |||
27 | describe('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 | ||
3 | import 'mocha' | ||
3 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
4 | import { keyBy } from 'lodash' | 5 | import { keyBy } from 'lodash' |
5 | import 'mocha' | 6 | |
6 | import { VideoPrivacy } from '../../../../shared/models/videos' | ||
7 | import { | 7 | import { |
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' |
31 | import { VideoPrivacy } from '../../../../shared/models/videos' | ||
32 | import { HttpStatusCode } from '@shared/core-utils' | ||
31 | 33 | ||
32 | const expect = chai.expect | 34 | const expect = chai.expect |
33 | 35 | ||
34 | describe('Test a single server', function () { | 36 | describe('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' | |||
19 | import { MVideoImportDefault } from '@server/types/models/video/video-import' | 19 | import { MVideoImportDefault } from '@server/types/models/video/video-import' |
20 | import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element' | 20 | import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element' |
21 | import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate' | 21 | import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate' |
22 | import { HttpMethod } from '@shared/core-utils/miscs/http-methods' | ||
23 | import { VideoCreate } from '@shared/models' | ||
24 | import { File as UploadXFile, Metadata } from '@uploadx/core' | ||
22 | import { RegisteredPlugin } from '../../lib/plugins/plugin-manager' | 25 | import { RegisteredPlugin } from '../../lib/plugins/plugin-manager' |
23 | import { | 26 | import { |
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 | |||
41 | declare module 'express' { | 43 | declare 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 | ||
50 | interface 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. */ | ||
2 | export 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' | |||
2 | export * from './miscs' | 2 | export * from './miscs' |
3 | export * from './types' | 3 | export * from './types' |
4 | export * from './http-error-codes' | 4 | export * from './http-error-codes' |
5 | export * 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 @@ | |||
1 | import { makeGetRequest } from '../requests/requests' | 1 | import { makeGetRequest, makePostBodyRequest } from '../requests/requests' |
2 | import { HttpStatusCode } from '../../core-utils/miscs/http-error-codes' | 2 | import { HttpStatusCode } from '../../core-utils/miscs/http-error-codes' |
3 | import { SendDebugCommand } from '@shared/models' | ||
3 | 4 | ||
4 | function getDebug (url: string, token: string) { | 5 | function 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 | ||
16 | function 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 | ||
17 | export { | 30 | export { |
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 | ||
276 | async function checkTmpIsEmpty (server: ServerInfo) { | 276 | async 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 | |||
5 | import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model' | 5 | import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model' |
6 | import { makeDeleteRequest, makeGetRequest, updateImageRequest } from '../requests/requests' | 6 | import { makeDeleteRequest, makeGetRequest, updateImageRequest } from '../requests/requests' |
7 | import { ServerInfo } from '../server/servers' | 7 | import { ServerInfo } from '../server/servers' |
8 | import { User } from '../../models/users/user.model' | 8 | import { MyUser, User } from '../../models/users/user.model' |
9 | import { getMyUserInformation } from '../users/users' | 9 | import { getMyUserInformation } from '../users/users' |
10 | import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' | 10 | import { 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 | ||
173 | async 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 | ||
175 | export { | 181 | export { |
@@ -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 | ||
3 | import { expect } from 'chai' | 3 | import { expect } from 'chai' |
4 | import { pathExists, readdir, readFile } from 'fs-extra' | 4 | import { createReadStream, pathExists, readdir, readFile, stat } from 'fs-extra' |
5 | import got, { Response as GotResponse } from 'got/dist/source' | ||
5 | import * as parseTorrent from 'parse-torrent' | 6 | import * as parseTorrent from 'parse-torrent' |
6 | import { extname, join } from 'path' | 7 | import { extname, join } from 'path' |
7 | import * as request from 'supertest' | 8 | import * 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 | ||
367 | async function uploadVideo (url: string, accessToken: string, videoAttributesArg: VideoAttributes, specialStatus = HttpStatusCode.OK_200) { | 369 | async 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 | |||
419 | function 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 | |||
431 | async 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) { | 447 | async 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 | |||
484 | async 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 | |||
510 | function 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 | ||
464 | function updateVideo ( | 567 | function 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 | |||
881 | function 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 @@ | |||
1 | export interface Debug { | 1 | export interface Debug { |
2 | ip: string | 2 | ip: string |
3 | } | 3 | } |
4 | |||
5 | export 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 | ||
172 | x-tagGroups: | 200 | x-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 | ||
@@ -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 | |||
1064 | abbrev@1: | 1073 | abbrev@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 | ||
1797 | bytes@3.1.0, bytes@^3.0.0: | 1806 | bytes@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 | ||
4110 | http-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 | ||
5590 | multiparty@^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 | |||
5570 | multistream@^4.0.1, multistream@^4.1.0: | 5599 | multistream@^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 | ||
6688 | random-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 | |||
6659 | random-iterate@^1.0.1: | 6693 | random-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 | ||
7043 | safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.2.0: | 7077 | safe-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 | ||
7223 | setprototypeof@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 | |||
7189 | shebang-command@^1.2.0: | 7228 | shebang-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 | ||
8181 | uid-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 | |||
8142 | uint64be@^2.0.2: | 8188 | uint64be@^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" |