aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorkontrollanten <6680299+kontrollanten@users.noreply.github.com>2021-05-10 11:13:41 +0200
committerGitHub <noreply@github.com>2021-05-10 11:13:41 +0200
commitf6d6e7f861189a4446f406efb775a29688764b48 (patch)
treec3dda9958c3f189d4c39e8743c738d8c1fef4c2d /server
parentd29ced1a8582d99b776f664475a157adcf555d98 (diff)
downloadPeerTube-f6d6e7f861189a4446f406efb775a29688764b48.tar.gz
PeerTube-f6d6e7f861189a4446f406efb775a29688764b48.tar.zst
PeerTube-f6d6e7f861189a4446f406efb775a29688764b48.zip
Resumable video uploads (#3933)
* WIP: resumable video uploads relates to #324 * fix review comments * video upload: error handling * fix audio upload * fixes after self review * Update server/controllers/api/videos/index.ts Co-authored-by: Rigel Kent <par@rigelk.eu> * Update server/middlewares/validators/videos/videos.ts Co-authored-by: Rigel Kent <par@rigelk.eu> * Update server/controllers/api/videos/index.ts Co-authored-by: Rigel Kent <par@rigelk.eu> * update after code review * refactor upload route - restore multipart upload route - move resumable to dedicated upload-resumable route - move checks to middleware - do not leak internal fs structure in response * fix yarn.lock upon rebase * factorize addVideo for reuse in both endpoints * add resumable upload API to openapi spec * add initial test and test helper for resumable upload * typings for videoAddResumable middleware * avoid including aws and google packages via node-uploadx, by only including uploadx/core * rename ex-isAudioBg to more explicit name mentioning it is a preview file for audio * add video-upload-tmp-folder-cleaner job * stronger typing of video upload middleware * reduce dependency to @uploadx/core * add audio upload test * refactor resumable uploads cleanup from job to scheduler * refactor resumable uploads scheduler to compare to last execution time * make resumable upload validator to always cleanup on failure * move legacy upload request building outside of uploadVideo test helper * filter upload-resumable middlewares down to POST, PUT, DELETE also begin to type metadata * merge add duration functions * stronger typings and documentation for uploadx behaviour, move init validator up * refactor(client/video-edit): options > uploadxOptions * refactor(client/video-edit): remove obsolete else * scheduler/remove-dangling-resum: rename tag * refactor(server/video): add UploadVideoFiles type * refactor(mw/validators): restructure eslint disable * refactor(mw/validators/videos): rename import * refactor(client/vid-upload): rename html elem id * refactor(sched/remove-dangl): move fn to method * refactor(mw/async): add method typing * refactor(mw/vali/video): double quote > single * refactor(server/upload-resum): express use > all * proper http methud enum server/middlewares/async.ts * properly type http methods * factorize common video upload validation steps * add check for maximum partially uploaded file size * fix audioBg use * fix extname(filename) in addVideo * document parameters for uploadx's resumable protocol * clear META files in scheduler * last audio refactor before cramming preview in the initial POST form data * refactor as mulitpart/form-data initial post request this allows preview/thumbnail uploads alongside the initial request, and cleans up the upload form * Add more tests for resumable uploads * Refactor remove dangling resumable uploads * Prepare changelog * Add more resumable upload tests * Remove user quota check for resumable uploads * Fix upload error handler * Update nginx template for upload-resumable * Cleanup comment * Remove unused express methods * Prefer to use got instead of raw http * Don't retry on error 500 Co-authored-by: Rigel Kent <par@rigelk.eu> Co-authored-by: Rigel Kent <sendmemail@rigelk.eu> Co-authored-by: Chocobozzz <me@florianbigard.com>
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/server/debug.ts18
-rw-r--r--server/controllers/api/videos/index.ts105
-rw-r--r--server/helpers/custom-validators/misc.ts5
-rw-r--r--server/helpers/custom-validators/videos.ts9
-rw-r--r--server/helpers/express-utils.ts8
-rw-r--r--server/helpers/upload.ts21
-rw-r--r--server/helpers/utils.ts4
-rw-r--r--server/initializers/constants.ts6
-rw-r--r--server/initializers/installer.ts5
-rw-r--r--server/lib/moderation.ts5
-rw-r--r--server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts61
-rw-r--r--server/lib/video.ts3
-rw-r--r--server/middlewares/async.ts1
-rw-r--r--server/middlewares/validators/videos/videos.ts184
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/upload-quota.ts152
-rw-r--r--server/tests/api/check-params/users.ts105
-rw-r--r--server/tests/api/check-params/videos.ts393
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/multiple-servers.ts2
-rw-r--r--server/tests/api/videos/resumable-upload.ts187
-rw-r--r--server/tests/api/videos/single-server.ts724
-rw-r--r--server/tests/api/videos/video-transcoder.ts159
-rw-r--r--server/typings/express/index.d.ts140
24 files changed, 1444 insertions, 855 deletions
diff --git a/server/controllers/api/server/debug.ts b/server/controllers/api/server/debug.ts
index 7787186be..ff0d9ca3c 100644
--- a/server/controllers/api/server/debug.ts
+++ b/server/controllers/api/server/debug.ts
@@ -1,4 +1,6 @@
1import { InboxManager } from '@server/lib/activitypub/inbox-manager' 1import { InboxManager } from '@server/lib/activitypub/inbox-manager'
2import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler'
3import { SendDebugCommand } from '@shared/models'
2import * as express from 'express' 4import * as express from 'express'
3import { UserRight } from '../../../../shared/models/users' 5import { UserRight } from '../../../../shared/models/users'
4import { authenticate, ensureUserHasRight } from '../../../middlewares' 6import { authenticate, ensureUserHasRight } from '../../../middlewares'
@@ -11,6 +13,12 @@ debugRouter.get('/debug',
11 getDebug 13 getDebug
12) 14)
13 15
16debugRouter.post('/debug/run-command',
17 authenticate,
18 ensureUserHasRight(UserRight.MANAGE_DEBUG),
19 runCommand
20)
21
14// --------------------------------------------------------------------------- 22// ---------------------------------------------------------------------------
15 23
16export { 24export {
@@ -25,3 +33,13 @@ function getDebug (req: express.Request, res: express.Response) {
25 activityPubMessagesWaiting: InboxManager.Instance.getActivityPubMessagesWaiting() 33 activityPubMessagesWaiting: InboxManager.Instance.getActivityPubMessagesWaiting()
26 }) 34 })
27} 35}
36
37async function runCommand (req: express.Request, res: express.Response) {
38 const body: SendDebugCommand = req.body
39
40 if (body.command === 'remove-dandling-resumable-uploads') {
41 await RemoveDanglingResumableUploadsScheduler.Instance.execute()
42 }
43
44 return res.sendStatus(204)
45}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index fbdb0f776..c32626d30 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -2,6 +2,7 @@ import * as express from 'express'
2import { move } from 'fs-extra' 2import { move } from 'fs-extra'
3import { extname } from 'path' 3import { extname } from 'path'
4import toInt from 'validator/lib/toInt' 4import toInt from 'validator/lib/toInt'
5import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload'
5import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
6import { changeVideoChannelShare } from '@server/lib/activitypub/share' 7import { changeVideoChannelShare } from '@server/lib/activitypub/share'
7import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 8import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
@@ -10,8 +11,9 @@ import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnail
10import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 11import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
11import { getServerActor } from '@server/models/application/application' 12import { getServerActor } from '@server/models/application/application'
12import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 13import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
14import { uploadx } from '@uploadx/core'
13import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared' 15import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 16import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
15import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 17import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
16import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' 18import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils'
17import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils' 19import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils'
@@ -47,7 +49,9 @@ import {
47 setDefaultPagination, 49 setDefaultPagination,
48 setDefaultVideosSort, 50 setDefaultVideosSort,
49 videoFileMetadataGetValidator, 51 videoFileMetadataGetValidator,
50 videosAddValidator, 52 videosAddLegacyValidator,
53 videosAddResumableInitValidator,
54 videosAddResumableValidator,
51 videosCustomGetValidator, 55 videosCustomGetValidator,
52 videosGetValidator, 56 videosGetValidator,
53 videosRemoveValidator, 57 videosRemoveValidator,
@@ -69,6 +73,7 @@ import { watchingRouter } from './watching'
69const lTags = loggerTagsFactory('api', 'video') 73const lTags = loggerTagsFactory('api', 'video')
70const auditLogger = auditLoggerFactory('videos') 74const auditLogger = auditLoggerFactory('videos')
71const videosRouter = express.Router() 75const videosRouter = express.Router()
76const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
72 77
73const reqVideoFileAdd = createReqFiles( 78const reqVideoFileAdd = createReqFiles(
74 [ 'videofile', 'thumbnailfile', 'previewfile' ], 79 [ 'videofile', 'thumbnailfile', 'previewfile' ],
@@ -79,6 +84,16 @@ const reqVideoFileAdd = createReqFiles(
79 previewfile: CONFIG.STORAGE.TMP_DIR 84 previewfile: CONFIG.STORAGE.TMP_DIR
80 } 85 }
81) 86)
87
88const reqVideoFileAddResumable = createReqFiles(
89 [ 'thumbnailfile', 'previewfile' ],
90 MIMETYPES.IMAGE.MIMETYPE_EXT,
91 {
92 thumbnailfile: getResumableUploadPath(),
93 previewfile: getResumableUploadPath()
94 }
95)
96
82const reqVideoFileUpdate = createReqFiles( 97const reqVideoFileUpdate = createReqFiles(
83 [ 'thumbnailfile', 'previewfile' ], 98 [ 'thumbnailfile', 'previewfile' ],
84 MIMETYPES.IMAGE.MIMETYPE_EXT, 99 MIMETYPES.IMAGE.MIMETYPE_EXT,
@@ -111,18 +126,39 @@ videosRouter.get('/',
111 commonVideosFiltersValidator, 126 commonVideosFiltersValidator,
112 asyncMiddleware(listVideos) 127 asyncMiddleware(listVideos)
113) 128)
129
130videosRouter.post('/upload',
131 authenticate,
132 reqVideoFileAdd,
133 asyncMiddleware(videosAddLegacyValidator),
134 asyncRetryTransactionMiddleware(addVideoLegacy)
135)
136
137videosRouter.post('/upload-resumable',
138 authenticate,
139 reqVideoFileAddResumable,
140 asyncMiddleware(videosAddResumableInitValidator),
141 uploadxMiddleware
142)
143
144videosRouter.delete('/upload-resumable',
145 authenticate,
146 uploadxMiddleware
147)
148
149videosRouter.put('/upload-resumable',
150 authenticate,
151 uploadxMiddleware, // uploadx doesn't use call next() before the file upload completes
152 asyncMiddleware(videosAddResumableValidator),
153 asyncMiddleware(addVideoResumable)
154)
155
114videosRouter.put('/:id', 156videosRouter.put('/:id',
115 authenticate, 157 authenticate,
116 reqVideoFileUpdate, 158 reqVideoFileUpdate,
117 asyncMiddleware(videosUpdateValidator), 159 asyncMiddleware(videosUpdateValidator),
118 asyncRetryTransactionMiddleware(updateVideo) 160 asyncRetryTransactionMiddleware(updateVideo)
119) 161)
120videosRouter.post('/upload',
121 authenticate,
122 reqVideoFileAdd,
123 asyncMiddleware(videosAddValidator),
124 asyncRetryTransactionMiddleware(addVideo)
125)
126 162
127videosRouter.get('/:id/description', 163videosRouter.get('/:id/description',
128 asyncMiddleware(videosGetValidator), 164 asyncMiddleware(videosGetValidator),
@@ -157,23 +193,23 @@ export {
157 193
158// --------------------------------------------------------------------------- 194// ---------------------------------------------------------------------------
159 195
160function listVideoCategories (req: express.Request, res: express.Response) { 196function listVideoCategories (_req: express.Request, res: express.Response) {
161 res.json(VIDEO_CATEGORIES) 197 res.json(VIDEO_CATEGORIES)
162} 198}
163 199
164function listVideoLicences (req: express.Request, res: express.Response) { 200function listVideoLicences (_req: express.Request, res: express.Response) {
165 res.json(VIDEO_LICENCES) 201 res.json(VIDEO_LICENCES)
166} 202}
167 203
168function listVideoLanguages (req: express.Request, res: express.Response) { 204function listVideoLanguages (_req: express.Request, res: express.Response) {
169 res.json(VIDEO_LANGUAGES) 205 res.json(VIDEO_LANGUAGES)
170} 206}
171 207
172function listVideoPrivacies (req: express.Request, res: express.Response) { 208function listVideoPrivacies (_req: express.Request, res: express.Response) {
173 res.json(VIDEO_PRIVACIES) 209 res.json(VIDEO_PRIVACIES)
174} 210}
175 211
176async function addVideo (req: express.Request, res: express.Response) { 212async function addVideoLegacy (req: express.Request, res: express.Response) {
177 // Uploading the video could be long 213 // Uploading the video could be long
178 // Set timeout to 10 minutes, as Express's default is 2 minutes 214 // Set timeout to 10 minutes, as Express's default is 2 minutes
179 req.setTimeout(1000 * 60 * 10, () => { 215 req.setTimeout(1000 * 60 * 10, () => {
@@ -183,13 +219,42 @@ async function addVideo (req: express.Request, res: express.Response) {
183 219
184 const videoPhysicalFile = req.files['videofile'][0] 220 const videoPhysicalFile = req.files['videofile'][0]
185 const videoInfo: VideoCreate = req.body 221 const videoInfo: VideoCreate = req.body
222 const files = req.files
223
224 return addVideo({ res, videoPhysicalFile, videoInfo, files })
225}
226
227async function addVideoResumable (_req: express.Request, res: express.Response) {
228 const videoPhysicalFile = res.locals.videoFileResumable
229 const videoInfo = videoPhysicalFile.metadata
230 const files = { previewfile: videoInfo.previewfile }
231
232 // Don't need the meta file anymore
233 await deleteResumableUploadMetaFile(videoPhysicalFile.path)
234
235 return addVideo({ res, videoPhysicalFile, videoInfo, files })
236}
186 237
187 const videoData = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id) 238async function addVideo (options: {
188 videoData.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED 239 res: express.Response
189 videoData.duration = videoPhysicalFile['duration'] // duration was added by a previous middleware 240 videoPhysicalFile: express.VideoUploadFile
241 videoInfo: VideoCreate
242 files: express.UploadFiles
243}) {
244 const { res, videoPhysicalFile, videoInfo, files } = options
245 const videoChannel = res.locals.videoChannel
246 const user = res.locals.oauth.token.User
247
248 const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
249
250 videoData.state = CONFIG.TRANSCODING.ENABLED
251 ? VideoState.TO_TRANSCODE
252 : VideoState.PUBLISHED
253
254 videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
190 255
191 const video = new VideoModel(videoData) as MVideoFullLight 256 const video = new VideoModel(videoData) as MVideoFullLight
192 video.VideoChannel = res.locals.videoChannel 257 video.VideoChannel = videoChannel
193 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object 258 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
194 259
195 const videoFile = new VideoFileModel({ 260 const videoFile = new VideoFileModel({
@@ -217,7 +282,7 @@ async function addVideo (req: express.Request, res: express.Response) {
217 282
218 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ 283 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
219 video, 284 video,
220 files: req.files, 285 files,
221 fallback: type => generateVideoMiniature({ video, videoFile, type }) 286 fallback: type => generateVideoMiniature({ video, videoFile, type })
222 }) 287 })
223 288
@@ -253,7 +318,7 @@ async function addVideo (req: express.Request, res: express.Response) {
253 318
254 await autoBlacklistVideoIfNeeded({ 319 await autoBlacklistVideoIfNeeded({
255 video, 320 video,
256 user: res.locals.oauth.token.User, 321 user,
257 isRemote: false, 322 isRemote: false,
258 isNew: true, 323 isNew: true,
259 transaction: t 324 transaction: t
@@ -282,7 +347,7 @@ async function addVideo (req: express.Request, res: express.Response) {
282 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) })) 347 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
283 348
284 if (video.state === VideoState.TO_TRANSCODE) { 349 if (video.state === VideoState.TO_TRANSCODE) {
285 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, res.locals.oauth.token.User) 350 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
286 } 351 }
287 352
288 Hooks.runAction('action:api.video.uploaded', { video: videoCreated }) 353 Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index effdd98cb..fd3b45804 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -1,6 +1,7 @@
1import 'multer' 1import 'multer'
2import validator from 'validator' 2import { UploadFilesForCheck } from 'express'
3import { sep } from 'path' 3import { sep } from 'path'
4import validator from 'validator'
4 5
5function exists (value: any) { 6function exists (value: any) {
6 return value !== undefined && value !== null 7 return value !== undefined && value !== null
@@ -108,7 +109,7 @@ function isFileFieldValid (
108} 109}
109 110
110function isFileMimeTypeValid ( 111function isFileMimeTypeValid (
111 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], 112 files: UploadFilesForCheck,
112 mimeTypeRegex: string, 113 mimeTypeRegex: string,
113 field: string, 114 field: string,
114 optional = false 115 optional = false
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 87966798f..b33e088eb 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -1,4 +1,6 @@
1import { UploadFilesForCheck } from 'express'
1import { values } from 'lodash' 2import { values } from 'lodash'
3import * as magnetUtil from 'magnet-uri'
2import validator from 'validator' 4import validator from 'validator'
3import { VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared' 5import { VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared'
4import { 6import {
@@ -6,13 +8,12 @@ import {
6 MIMETYPES, 8 MIMETYPES,
7 VIDEO_CATEGORIES, 9 VIDEO_CATEGORIES,
8 VIDEO_LICENCES, 10 VIDEO_LICENCES,
11 VIDEO_LIVE,
9 VIDEO_PRIVACIES, 12 VIDEO_PRIVACIES,
10 VIDEO_RATE_TYPES, 13 VIDEO_RATE_TYPES,
11 VIDEO_STATES, 14 VIDEO_STATES
12 VIDEO_LIVE
13} from '../../initializers/constants' 15} from '../../initializers/constants'
14import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' 16import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc'
15import * as magnetUtil from 'magnet-uri'
16 17
17const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
18 19
@@ -81,7 +82,7 @@ function isVideoFileExtnameValid (value: string) {
81 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined) 82 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
82} 83}
83 84
84function isVideoFileMimeTypeValid (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 85function isVideoFileMimeTypeValid (files: UploadFilesForCheck) {
85 return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile') 86 return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile')
86} 87}
87 88
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index c0d3f8f32..ede22a3cc 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -2,7 +2,7 @@ import * as express from 'express'
2import * as multer from 'multer' 2import * as multer from 'multer'
3import { REMOTE_SCHEME } from '../initializers/constants' 3import { REMOTE_SCHEME } from '../initializers/constants'
4import { logger } from './logger' 4import { logger } from './logger'
5import { deleteFileAsync, generateRandomString } from './utils' 5import { deleteFileAndCatch, generateRandomString } from './utils'
6import { extname } from 'path' 6import { extname } from 'path'
7import { isArray } from './custom-validators/misc' 7import { isArray } from './custom-validators/misc'
8import { CONFIG } from '../initializers/config' 8import { CONFIG } from '../initializers/config'
@@ -36,15 +36,15 @@ function cleanUpReqFiles (req: { files: { [fieldname: string]: Express.Multer.Fi
36 if (!files) return 36 if (!files) return
37 37
38 if (isArray(files)) { 38 if (isArray(files)) {
39 (files as Express.Multer.File[]).forEach(f => deleteFileAsync(f.path)) 39 (files as Express.Multer.File[]).forEach(f => deleteFileAndCatch(f.path))
40 return 40 return
41 } 41 }
42 42
43 for (const key of Object.keys(files)) { 43 for (const key of Object.keys(files)) {
44 const file = files[key] 44 const file = files[key]
45 45
46 if (isArray(file)) file.forEach(f => deleteFileAsync(f.path)) 46 if (isArray(file)) file.forEach(f => deleteFileAndCatch(f.path))
47 else deleteFileAsync(file.path) 47 else deleteFileAndCatch(file.path)
48 } 48 }
49} 49}
50 50
diff --git a/server/helpers/upload.ts b/server/helpers/upload.ts
new file mode 100644
index 000000000..030a6b7d5
--- /dev/null
+++ b/server/helpers/upload.ts
@@ -0,0 +1,21 @@
1import { METAFILE_EXTNAME } from '@uploadx/core'
2import { remove } from 'fs-extra'
3import { join } from 'path'
4import { RESUMABLE_UPLOAD_DIRECTORY } from '../initializers/constants'
5
6function getResumableUploadPath (filename?: string) {
7 if (filename) return join(RESUMABLE_UPLOAD_DIRECTORY, filename)
8
9 return RESUMABLE_UPLOAD_DIRECTORY
10}
11
12function deleteResumableUploadMetaFile (filepath: string) {
13 return remove(filepath + METAFILE_EXTNAME)
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 getResumableUploadPath,
20 deleteResumableUploadMetaFile
21}
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts
index 0545e8996..6c95a43b6 100644
--- a/server/helpers/utils.ts
+++ b/server/helpers/utils.ts
@@ -6,7 +6,7 @@ import { CONFIG } from '../initializers/config'
6import { execPromise, execPromise2, randomBytesPromise, sha256 } from './core-utils' 6import { execPromise, execPromise2, randomBytesPromise, sha256 } from './core-utils'
7import { logger } from './logger' 7import { logger } from './logger'
8 8
9function deleteFileAsync (path: string) { 9function deleteFileAndCatch (path: string) {
10 remove(path) 10 remove(path)
11 .catch(err => logger.error('Cannot delete the file %s asynchronously.', path, { err })) 11 .catch(err => logger.error('Cannot delete the file %s asynchronously.', path, { err }))
12} 12}
@@ -83,7 +83,7 @@ function getUUIDFromFilename (filename: string) {
83// --------------------------------------------------------------------------- 83// ---------------------------------------------------------------------------
84 84
85export { 85export {
86 deleteFileAsync, 86 deleteFileAndCatch,
87 generateRandomString, 87 generateRandomString,
88 getFormattedObjects, 88 getFormattedObjects,
89 getSecureTorrentName, 89 getSecureTorrentName,
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index f807a1e58..6f388420e 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -208,7 +208,8 @@ const SCHEDULER_INTERVALS_MS = {
208 autoFollowIndexInstances: 60000 * 60 * 24, // 1 day 208 autoFollowIndexInstances: 60000 * 60 * 24, // 1 day
209 removeOldViews: 60000 * 60 * 24, // 1 day 209 removeOldViews: 60000 * 60 * 24, // 1 day
210 removeOldHistory: 60000 * 60 * 24, // 1 day 210 removeOldHistory: 60000 * 60 * 24, // 1 day
211 updateInboxStats: 1000 * 60// 1 minute 211 updateInboxStats: 1000 * 60, // 1 minute
212 removeDanglingResumableUploads: 60000 * 60 * 16 // 16 hours
212} 213}
213 214
214// --------------------------------------------------------------------------- 215// ---------------------------------------------------------------------------
@@ -285,6 +286,7 @@ const CONSTRAINTS_FIELDS = {
285 LIKES: { min: 0 }, 286 LIKES: { min: 0 },
286 DISLIKES: { min: 0 }, 287 DISLIKES: { min: 0 },
287 FILE_SIZE: { min: -1 }, 288 FILE_SIZE: { min: -1 },
289 PARTIAL_UPLOAD_SIZE: { max: 50 * 1024 * 1024 * 1024 }, // 50GB
288 URL: { min: 3, max: 2000 } // Length 290 URL: { min: 3, max: 2000 } // Length
289 }, 291 },
290 VIDEO_PLAYLISTS: { 292 VIDEO_PLAYLISTS: {
@@ -645,6 +647,7 @@ const LRU_CACHE = {
645 } 647 }
646} 648}
647 649
650const RESUMABLE_UPLOAD_DIRECTORY = join(CONFIG.STORAGE.TMP_DIR, 'resumable-uploads')
648const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') 651const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls')
649const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') 652const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
650 653
@@ -819,6 +822,7 @@ export {
819 PEERTUBE_VERSION, 822 PEERTUBE_VERSION,
820 LAZY_STATIC_PATHS, 823 LAZY_STATIC_PATHS,
821 SEARCH_INDEX, 824 SEARCH_INDEX,
825 RESUMABLE_UPLOAD_DIRECTORY,
822 HLS_REDUNDANCY_DIRECTORY, 826 HLS_REDUNDANCY_DIRECTORY,
823 P2P_MEDIA_LOADER_PEER_VERSION, 827 P2P_MEDIA_LOADER_PEER_VERSION,
824 ACTOR_IMAGES_SIZE, 828 ACTOR_IMAGES_SIZE,
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index cb58454cb..8dcff64e2 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -6,7 +6,7 @@ import { UserModel } from '../models/account/user'
6import { ApplicationModel } from '../models/application/application' 6import { ApplicationModel } from '../models/application/application'
7import { OAuthClientModel } from '../models/oauth/oauth-client' 7import { OAuthClientModel } from '../models/oauth/oauth-client'
8import { applicationExist, clientsExist, usersExist } from './checker-after-init' 8import { applicationExist, clientsExist, usersExist } from './checker-after-init'
9import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION } from './constants' 9import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION, RESUMABLE_UPLOAD_DIRECTORY } from './constants'
10import { sequelizeTypescript } from './database' 10import { sequelizeTypescript } from './database'
11import { ensureDir, remove } from 'fs-extra' 11import { ensureDir, remove } from 'fs-extra'
12import { CONFIG } from './config' 12import { CONFIG } from './config'
@@ -79,6 +79,9 @@ function createDirectoriesIfNotExist () {
79 // Playlist directories 79 // Playlist directories
80 tasks.push(ensureDir(HLS_STREAMING_PLAYLIST_DIRECTORY)) 80 tasks.push(ensureDir(HLS_STREAMING_PLAYLIST_DIRECTORY))
81 81
82 // Resumable upload directory
83 tasks.push(ensureDir(RESUMABLE_UPLOAD_DIRECTORY))
84
82 return Promise.all(tasks) 85 return Promise.all(tasks)
83} 86}
84 87
diff --git a/server/lib/moderation.ts b/server/lib/moderation.ts
index 5180b3299..925d64902 100644
--- a/server/lib/moderation.ts
+++ b/server/lib/moderation.ts
@@ -1,6 +1,8 @@
1import { VideoUploadFile } from 'express'
1import { PathLike } from 'fs-extra' 2import { PathLike } from 'fs-extra'
2import { Transaction } from 'sequelize/types' 3import { Transaction } from 'sequelize/types'
3import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger' 4import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger'
5import { afterCommitIfTransaction } from '@server/helpers/database-utils'
4import { logger } from '@server/helpers/logger' 6import { logger } from '@server/helpers/logger'
5import { AbuseModel } from '@server/models/abuse/abuse' 7import { AbuseModel } from '@server/models/abuse/abuse'
6import { VideoAbuseModel } from '@server/models/abuse/video-abuse' 8import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
@@ -28,7 +30,6 @@ import { VideoModel } from '../models/video/video'
28import { VideoCommentModel } from '../models/video/video-comment' 30import { VideoCommentModel } from '../models/video/video-comment'
29import { sendAbuse } from './activitypub/send/send-flag' 31import { sendAbuse } from './activitypub/send/send-flag'
30import { Notifier } from './notifier' 32import { Notifier } from './notifier'
31import { afterCommitIfTransaction } from '@server/helpers/database-utils'
32 33
33export type AcceptResult = { 34export type AcceptResult = {
34 accepted: boolean 35 accepted: boolean
@@ -38,7 +39,7 @@ export type AcceptResult = {
38// Can be filtered by plugins 39// Can be filtered by plugins
39function isLocalVideoAccepted (object: { 40function isLocalVideoAccepted (object: {
40 videoBody: VideoCreate 41 videoBody: VideoCreate
41 videoFile: Express.Multer.File & { duration?: number } 42 videoFile: VideoUploadFile
42 user: UserModel 43 user: UserModel
43}): AcceptResult { 44}): AcceptResult {
44 return { accepted: true } 45 return { accepted: true }
diff --git a/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts b/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
new file mode 100644
index 000000000..1acea7998
--- /dev/null
+++ b/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
@@ -0,0 +1,61 @@
1import * as bluebird from 'bluebird'
2import { readdir, remove, stat } from 'fs-extra'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { getResumableUploadPath } from '@server/helpers/upload'
5import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants'
6import { METAFILE_EXTNAME } from '@uploadx/core'
7import { AbstractScheduler } from './abstract-scheduler'
8
9const lTags = loggerTagsFactory('scheduler', 'resumable-upload', 'cleaner')
10
11export class RemoveDanglingResumableUploadsScheduler extends AbstractScheduler {
12
13 private static instance: AbstractScheduler
14 private lastExecutionTimeMs: number
15
16 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.removeDanglingResumableUploads
17
18 private constructor () {
19 super()
20
21 this.lastExecutionTimeMs = new Date().getTime()
22 }
23
24 protected async internalExecute () {
25 const path = getResumableUploadPath()
26 const files = await readdir(path)
27
28 const metafiles = files.filter(f => f.endsWith(METAFILE_EXTNAME))
29
30 if (metafiles.length === 0) return
31
32 logger.debug('Reading resumable video upload folder %s with %d files', path, metafiles.length, lTags())
33
34 try {
35 await bluebird.map(metafiles, metafile => {
36 return this.deleteIfOlderThan(metafile, this.lastExecutionTimeMs)
37 }, { concurrency: 5 })
38 } catch (error) {
39 logger.error('Failed to handle file during resumable video upload folder cleanup', { error, ...lTags() })
40 } finally {
41 this.lastExecutionTimeMs = new Date().getTime()
42 }
43 }
44
45 private async deleteIfOlderThan (metafile: string, olderThan: number) {
46 const metafilePath = getResumableUploadPath(metafile)
47 const statResult = await stat(metafilePath)
48
49 // Delete uploads that started since a long time
50 if (statResult.ctimeMs < olderThan) {
51 await remove(metafilePath)
52
53 const datafile = metafilePath.replace(new RegExp(`${METAFILE_EXTNAME}$`), '')
54 await remove(datafile)
55 }
56 }
57
58 static get Instance () {
59 return this.instance || (this.instance = new this())
60 }
61}
diff --git a/server/lib/video.ts b/server/lib/video.ts
index 9469b8178..21e4b7ff2 100644
--- a/server/lib/video.ts
+++ b/server/lib/video.ts
@@ -1,3 +1,4 @@
1import { UploadFiles } from 'express'
1import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
2import { DEFAULT_AUDIO_RESOLUTION, JOB_PRIORITY } from '@server/initializers/constants' 3import { DEFAULT_AUDIO_RESOLUTION, JOB_PRIORITY } from '@server/initializers/constants'
3import { sequelizeTypescript } from '@server/initializers/database' 4import { sequelizeTypescript } from '@server/initializers/database'
@@ -32,7 +33,7 @@ function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): Fil
32 33
33async function buildVideoThumbnailsFromReq (options: { 34async function buildVideoThumbnailsFromReq (options: {
34 video: MVideoThumbnail 35 video: MVideoThumbnail
35 files: { [fieldname: string]: Express.Multer.File[] } | Express.Multer.File[] 36 files: UploadFiles
36 fallback: (type: ThumbnailType) => Promise<MThumbnail> 37 fallback: (type: ThumbnailType) => Promise<MThumbnail>
37 automaticallyGenerated?: boolean 38 automaticallyGenerated?: boolean
38}) { 39}) {
diff --git a/server/middlewares/async.ts b/server/middlewares/async.ts
index 3d6e38809..0faa4fb8c 100644
--- a/server/middlewares/async.ts
+++ b/server/middlewares/async.ts
@@ -3,6 +3,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'
3import { ValidationChain } from 'express-validator' 3import { ValidationChain } from 'express-validator'
4import { ExpressPromiseHandler } from '@server/types/express' 4import { ExpressPromiseHandler } from '@server/types/express'
5import { retryTransactionWrapper } from '../helpers/database-utils' 5import { retryTransactionWrapper } from '../helpers/database-utils'
6import { HttpMethod, HttpStatusCode } from '@shared/core-utils'
6 7
7// Syntactic sugar to avoid try/catch in express controllers 8// Syntactic sugar to avoid try/catch in express controllers
8// Thanks: https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016 9// Thanks: https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index bb617d77c..d26bcd4a6 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -1,9 +1,10 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query, ValidationChain } from 'express-validator' 2import { body, header, param, query, ValidationChain } from 'express-validator'
3import { getResumableUploadPath } from '@server/helpers/upload'
3import { isAbleToUploadVideo } from '@server/lib/user' 4import { isAbleToUploadVideo } from '@server/lib/user'
4import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
5import { ExpressPromiseHandler } from '@server/types/express' 6import { ExpressPromiseHandler } from '@server/types/express'
6import { MVideoWithRights } from '@server/types/models' 7import { MUserAccountId, MVideoWithRights } from '@server/types/models'
7import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' 8import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
8import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 9import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
9import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' 10import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
@@ -47,6 +48,7 @@ import {
47 doesVideoExist, 48 doesVideoExist,
48 doesVideoFileOfVideoExist 49 doesVideoFileOfVideoExist
49} from '../../../helpers/middlewares' 50} from '../../../helpers/middlewares'
51import { deleteFileAndCatch } from '../../../helpers/utils'
50import { getVideoWithAttributes } from '../../../helpers/video' 52import { getVideoWithAttributes } from '../../../helpers/video'
51import { CONFIG } from '../../../initializers/config' 53import { CONFIG } from '../../../initializers/config'
52import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' 54import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
@@ -57,7 +59,7 @@ import { VideoModel } from '../../../models/video/video'
57import { authenticatePromiseIfNeeded } from '../../auth' 59import { authenticatePromiseIfNeeded } from '../../auth'
58import { areValidationErrors } from '../utils' 60import { areValidationErrors } from '../utils'
59 61
60const videosAddValidator = getCommonVideoEditAttributes().concat([ 62const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
61 body('videofile') 63 body('videofile')
62 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile')) 64 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
63 .withMessage('Should have a file'), 65 .withMessage('Should have a file'),
@@ -73,54 +75,117 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
73 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) 75 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
74 76
75 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 77 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
76 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
77 78
78 const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0] 79 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
79 const user = res.locals.oauth.token.User 80 const user = res.locals.oauth.token.User
80 81
81 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) 82 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
82
83 if (!isVideoFileMimeTypeValid(req.files)) {
84 res.status(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415)
85 .json({
86 error: 'This file is not supported. Please, make sure it is of the following type: ' +
87 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
88 })
89
90 return cleanUpReqFiles(req) 83 return cleanUpReqFiles(req)
91 } 84 }
92 85
93 if (!isVideoFileSizeValid(videoFile.size.toString())) { 86 try {
94 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413) 87 if (!videoFile.duration) await addDurationToVideo(videoFile)
95 .json({ 88 } catch (err) {
96 error: 'This file is too large.' 89 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
97 }) 90 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
91 .json({ error: 'Video file unreadable.' })
98 92
99 return cleanUpReqFiles(req) 93 return cleanUpReqFiles(req)
100 } 94 }
101 95
102 if (await isAbleToUploadVideo(user.id, videoFile.size) === false) { 96 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
103 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
104 .json({ error: 'The user video quota is exceeded with this video.' })
105 97
106 return cleanUpReqFiles(req) 98 return next()
107 } 99 }
100])
101
102/**
103 * Gets called after the last PUT request
104 */
105const videosAddResumableValidator = [
106 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
107 const user = res.locals.oauth.token.User
108
109 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
110 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename }
111
112 const cleanup = () => deleteFileAndCatch(file.path)
108 113
109 let duration: number 114 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
110 115
111 try { 116 try {
112 duration = await getDurationFromVideoFile(videoFile.path) 117 if (!file.duration) await addDurationToVideo(file)
113 } catch (err) { 118 } catch (err) {
114 logger.error('Invalid input file in videosAddValidator.', { err }) 119 logger.error('Invalid input file in videosAddResumableValidator.', { err })
115 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422) 120 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
116 .json({ error: 'Video file unreadable.' }) 121 .json({ error: 'Video file unreadable.' })
117 122
118 return cleanUpReqFiles(req) 123 return cleanup()
119 } 124 }
120 125
121 videoFile.duration = duration 126 if (!await isVideoAccepted(req, res, file)) return cleanup()
122 127
123 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req) 128 res.locals.videoFileResumable = file
129
130 return next()
131 }
132]
133
134/**
135 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
136 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
137 *
138 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
139 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
140 *
141 */
142const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
143 body('filename')
144 .isString()
145 .exists()
146 .withMessage('Should have a valid filename'),
147 body('name')
148 .trim()
149 .custom(isVideoNameValid)
150 .withMessage('Should have a valid name'),
151 body('channelId')
152 .customSanitizer(toIntOrNull)
153 .custom(isIdValid).withMessage('Should have correct video channel id'),
154
155 header('x-upload-content-length')
156 .isNumeric()
157 .exists()
158 .withMessage('Should specify the file length'),
159 header('x-upload-content-type')
160 .isString()
161 .exists()
162 .withMessage('Should specify the file mimetype'),
163
164 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
165 const videoFileMetadata = {
166 mimetype: req.headers['x-upload-content-type'] as string,
167 size: +req.headers['x-upload-content-length'],
168 originalname: req.body.name
169 }
170
171 const user = res.locals.oauth.token.User
172 const cleanup = () => cleanUpReqFiles(req)
173
174 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
175 parameters: req.body,
176 headers: req.headers,
177 files: req.files
178 })
179
180 if (areValidationErrors(req, res)) return cleanup()
181
182 const files = { videofile: [ videoFileMetadata ] }
183 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
184
185 // multer required unsetting the Content-Type, now we can set it for node-uploadx
186 req.headers['content-type'] = 'application/json; charset=utf-8'
187 // place previewfile in metadata so that uploadx saves it in .META
188 if (req.files['previewfile']) req.body.previewfile = req.files['previewfile']
124 189
125 return next() 190 return next()
126 } 191 }
@@ -478,7 +543,10 @@ const commonVideosFiltersValidator = [
478// --------------------------------------------------------------------------- 543// ---------------------------------------------------------------------------
479 544
480export { 545export {
481 videosAddValidator, 546 videosAddLegacyValidator,
547 videosAddResumableValidator,
548 videosAddResumableInitValidator,
549
482 videosUpdateValidator, 550 videosUpdateValidator,
483 videosGetValidator, 551 videosGetValidator,
484 videoFileMetadataGetValidator, 552 videoFileMetadataGetValidator,
@@ -515,7 +583,51 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response)
515 return false 583 return false
516} 584}
517 585
518async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) { 586async function commonVideoChecksPass (parameters: {
587 req: express.Request
588 res: express.Response
589 user: MUserAccountId
590 videoFileSize: number
591 files: express.UploadFilesForCheck
592}): Promise<boolean> {
593 const { req, res, user, videoFileSize, files } = parameters
594
595 if (areErrorsInScheduleUpdate(req, res)) return false
596
597 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
598
599 if (!isVideoFileMimeTypeValid(files)) {
600 res.status(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415)
601 .json({
602 error: 'This file is not supported. Please, make sure it is of the following type: ' +
603 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
604 })
605
606 return false
607 }
608
609 if (!isVideoFileSizeValid(videoFileSize.toString())) {
610 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
611 .json({ error: 'This file is too large. It exceeds the maximum file size authorized.' })
612
613 return false
614 }
615
616 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
617 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
618 .json({ error: 'The user video quota is exceeded with this video.' })
619
620 return false
621 }
622
623 return true
624}
625
626export async function isVideoAccepted (
627 req: express.Request,
628 res: express.Response,
629 videoFile: express.VideoUploadFile
630) {
519 // Check we accept this video 631 // Check we accept this video
520 const acceptParameters = { 632 const acceptParameters = {
521 videoBody: req.body, 633 videoBody: req.body,
@@ -538,3 +650,11 @@ async function isVideoAccepted (req: express.Request, res: express.Response, vid
538 650
539 return true 651 return true
540} 652}
653
654async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
655 const duration: number = await getDurationFromVideoFile(videoFile.path)
656
657 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
658
659 videoFile.duration = duration
660}
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index d0b0b9c21..143515838 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -13,6 +13,7 @@ import './plugins'
13import './redundancy' 13import './redundancy'
14import './search' 14import './search'
15import './services' 15import './services'
16import './upload-quota'
16import './user-notifications' 17import './user-notifications'
17import './user-subscriptions' 18import './user-subscriptions'
18import './users' 19import './users'
diff --git a/server/tests/api/check-params/upload-quota.ts b/server/tests/api/check-params/upload-quota.ts
new file mode 100644
index 000000000..d0fbec415
--- /dev/null
+++ b/server/tests/api/check-params/upload-quota.ts
@@ -0,0 +1,152 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import { expect } from 'chai'
5import { HttpStatusCode, randomInt } from '@shared/core-utils'
6import { getGoodVideoUrl, getMagnetURI, getMyVideoImports, importVideo } from '@shared/extra-utils/videos/video-imports'
7import { MyUser, VideoImport, VideoImportState, VideoPrivacy } from '@shared/models'
8import {
9 cleanupTests,
10 flushAndRunServer,
11 getMyUserInformation,
12 immutableAssign,
13 registerUser,
14 ServerInfo,
15 setAccessTokensToServers,
16 setDefaultVideoChannel,
17 updateUser,
18 uploadVideo,
19 userLogin,
20 waitJobs
21} from '../../../../shared/extra-utils'
22
23describe('Test upload quota', function () {
24 let server: ServerInfo
25 let rootId: number
26
27 // ---------------------------------------------------------------
28
29 before(async function () {
30 this.timeout(30000)
31
32 server = await flushAndRunServer(1)
33 await setAccessTokensToServers([ server ])
34 await setDefaultVideoChannel([ server ])
35
36 const res = await getMyUserInformation(server.url, server.accessToken)
37 rootId = (res.body as MyUser).id
38
39 await updateUser({
40 url: server.url,
41 userId: rootId,
42 accessToken: server.accessToken,
43 videoQuota: 42
44 })
45 })
46
47 describe('When having a video quota', function () {
48
49 it('Should fail with a registered user having too many videos with legacy upload', async function () {
50 this.timeout(30000)
51
52 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
53 await registerUser(server.url, user.username, user.password)
54 const userAccessToken = await userLogin(server, user)
55
56 const videoAttributes = { fixture: 'video_short2.webm' }
57 for (let i = 0; i < 5; i++) {
58 await uploadVideo(server.url, userAccessToken, videoAttributes)
59 }
60
61 await uploadVideo(server.url, userAccessToken, videoAttributes, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'legacy')
62 })
63
64 it('Should fail with a registered user having too many videos with resumable upload', async function () {
65 this.timeout(30000)
66
67 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
68 await registerUser(server.url, user.username, user.password)
69 const userAccessToken = await userLogin(server, user)
70
71 const videoAttributes = { fixture: 'video_short2.webm' }
72 for (let i = 0; i < 5; i++) {
73 await uploadVideo(server.url, userAccessToken, videoAttributes)
74 }
75
76 await uploadVideo(server.url, userAccessToken, videoAttributes, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'resumable')
77 })
78
79 it('Should fail to import with HTTP/Torrent/magnet', async function () {
80 this.timeout(120000)
81
82 const baseAttributes = {
83 channelId: server.videoChannel.id,
84 privacy: VideoPrivacy.PUBLIC
85 }
86 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { targetUrl: getGoodVideoUrl() }))
87 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { magnetUri: getMagnetURI() }))
88 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { torrentfile: 'video-720p.torrent' as any }))
89
90 await waitJobs([ server ])
91
92 const res = await getMyVideoImports(server.url, server.accessToken)
93
94 expect(res.body.total).to.equal(3)
95 const videoImports: VideoImport[] = res.body.data
96 expect(videoImports).to.have.lengthOf(3)
97
98 for (const videoImport of videoImports) {
99 expect(videoImport.state.id).to.equal(VideoImportState.FAILED)
100 expect(videoImport.error).not.to.be.undefined
101 expect(videoImport.error).to.contain('user video quota is exceeded')
102 }
103 })
104 })
105
106 describe('When having a daily video quota', function () {
107
108 it('Should fail with a user having too many videos daily', async function () {
109 await updateUser({
110 url: server.url,
111 userId: rootId,
112 accessToken: server.accessToken,
113 videoQuotaDaily: 42
114 })
115
116 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'legacy')
117 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'resumable')
118 })
119 })
120
121 describe('When having an absolute and daily video quota', function () {
122 it('Should fail if exceeding total quota', async function () {
123 await updateUser({
124 url: server.url,
125 userId: rootId,
126 accessToken: server.accessToken,
127 videoQuota: 42,
128 videoQuotaDaily: 1024 * 1024 * 1024
129 })
130
131 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'legacy')
132 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'resumable')
133 })
134
135 it('Should fail if exceeding daily quota', async function () {
136 await updateUser({
137 url: server.url,
138 userId: rootId,
139 accessToken: server.accessToken,
140 videoQuota: 1024 * 1024 * 1024,
141 videoQuotaDaily: 42
142 })
143
144 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'legacy')
145 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413, 'resumable')
146 })
147 })
148
149 after(async function () {
150 await cleanupTests([ server ])
151 })
152})
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index 2b03fde2d..dcff0d52b 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -1,10 +1,10 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4import { expect } from 'chai'
5import { omit } from 'lodash' 4import { omit } from 'lodash'
6import { join } from 'path' 5import { join } from 'path'
7import { User, UserRole, VideoImport, VideoImportState } from '../../../../shared' 6import { User, UserRole } from '../../../../shared'
7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
8import { 8import {
9 addVideoChannel, 9 addVideoChannel,
10 blockUser, 10 blockUser,
@@ -29,7 +29,6 @@ import {
29 ServerInfo, 29 ServerInfo,
30 setAccessTokensToServers, 30 setAccessTokensToServers,
31 unblockUser, 31 unblockUser,
32 updateUser,
33 uploadVideo, 32 uploadVideo,
34 userLogin 33 userLogin
35} from '../../../../shared/extra-utils' 34} from '../../../../shared/extra-utils'
@@ -39,11 +38,7 @@ import {
39 checkBadSortPagination, 38 checkBadSortPagination,
40 checkBadStartPagination 39 checkBadStartPagination
41} from '../../../../shared/extra-utils/requests/check-api-params' 40} from '../../../../shared/extra-utils/requests/check-api-params'
42import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
43import { getGoodVideoUrl, getMagnetURI, getMyVideoImports, importVideo } from '../../../../shared/extra-utils/videos/video-imports'
44import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model' 41import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model'
45import { VideoPrivacy } from '../../../../shared/models/videos'
46import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
47 42
48describe('Test users API validators', function () { 43describe('Test users API validators', function () {
49 const path = '/api/v1/users/' 44 const path = '/api/v1/users/'
@@ -1093,102 +1088,6 @@ describe('Test users API validators', function () {
1093 }) 1088 })
1094 }) 1089 })
1095 1090
1096 describe('When having a video quota', function () {
1097 it('Should fail with a user having too many videos', async function () {
1098 await updateUser({
1099 url: server.url,
1100 userId: rootId,
1101 accessToken: server.accessToken,
1102 videoQuota: 42
1103 })
1104
1105 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1106 })
1107
1108 it('Should fail with a registered user having too many videos', async function () {
1109 this.timeout(30000)
1110
1111 const user = {
1112 username: 'user3',
1113 password: 'my super password'
1114 }
1115 userAccessToken = await userLogin(server, user)
1116
1117 const videoAttributes = { fixture: 'video_short2.webm' }
1118 await uploadVideo(server.url, userAccessToken, videoAttributes)
1119 await uploadVideo(server.url, userAccessToken, videoAttributes)
1120 await uploadVideo(server.url, userAccessToken, videoAttributes)
1121 await uploadVideo(server.url, userAccessToken, videoAttributes)
1122 await uploadVideo(server.url, userAccessToken, videoAttributes)
1123 await uploadVideo(server.url, userAccessToken, videoAttributes, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1124 })
1125
1126 it('Should fail to import with HTTP/Torrent/magnet', async function () {
1127 this.timeout(120000)
1128
1129 const baseAttributes = {
1130 channelId: 1,
1131 privacy: VideoPrivacy.PUBLIC
1132 }
1133 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { targetUrl: getGoodVideoUrl() }))
1134 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { magnetUri: getMagnetURI() }))
1135 await importVideo(server.url, server.accessToken, immutableAssign(baseAttributes, { torrentfile: 'video-720p.torrent' as any }))
1136
1137 await waitJobs([ server ])
1138
1139 const res = await getMyVideoImports(server.url, server.accessToken)
1140
1141 expect(res.body.total).to.equal(3)
1142 const videoImports: VideoImport[] = res.body.data
1143 expect(videoImports).to.have.lengthOf(3)
1144
1145 for (const videoImport of videoImports) {
1146 expect(videoImport.state.id).to.equal(VideoImportState.FAILED)
1147 expect(videoImport.error).not.to.be.undefined
1148 expect(videoImport.error).to.contain('user video quota is exceeded')
1149 }
1150 })
1151 })
1152
1153 describe('When having a daily video quota', function () {
1154 it('Should fail with a user having too many videos daily', async function () {
1155 await updateUser({
1156 url: server.url,
1157 userId: rootId,
1158 accessToken: server.accessToken,
1159 videoQuotaDaily: 42
1160 })
1161
1162 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1163 })
1164 })
1165
1166 describe('When having an absolute and daily video quota', function () {
1167 it('Should fail if exceeding total quota', async function () {
1168 await updateUser({
1169 url: server.url,
1170 userId: rootId,
1171 accessToken: server.accessToken,
1172 videoQuota: 42,
1173 videoQuotaDaily: 1024 * 1024 * 1024
1174 })
1175
1176 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1177 })
1178
1179 it('Should fail if exceeding daily quota', async function () {
1180 await updateUser({
1181 url: server.url,
1182 userId: rootId,
1183 accessToken: server.accessToken,
1184 videoQuota: 1024 * 1024 * 1024,
1185 videoQuotaDaily: 42
1186 })
1187
1188 await uploadVideo(server.url, server.accessToken, {}, HttpStatusCode.PAYLOAD_TOO_LARGE_413)
1189 })
1190 })
1191
1192 describe('When asking a password reset', function () { 1091 describe('When asking a password reset', function () {
1193 const path = '/api/v1/users/ask-reset-password' 1092 const path = '/api/v1/users/ask-reset-password'
1194 1093
diff --git a/server/tests/api/check-params/videos.ts b/server/tests/api/check-params/videos.ts
index 188d1835c..c970c4a15 100644
--- a/server/tests/api/check-params/videos.ts
+++ b/server/tests/api/check-params/videos.ts
@@ -1,11 +1,12 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha'
3import * as chai from 'chai' 4import * as chai from 'chai'
4import { omit } from 'lodash' 5import { omit } from 'lodash'
5import 'mocha'
6import { join } from 'path' 6import { join } from 'path'
7import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' 7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
8import { 8import {
9 checkUploadVideoParam,
9 cleanupTests, 10 cleanupTests,
10 createUser, 11 createUser,
11 flushAndRunServer, 12 flushAndRunServer,
@@ -18,17 +19,18 @@ import {
18 makePutBodyRequest, 19 makePutBodyRequest,
19 makeUploadRequest, 20 makeUploadRequest,
20 removeVideo, 21 removeVideo,
22 root,
21 ServerInfo, 23 ServerInfo,
22 setAccessTokensToServers, 24 setAccessTokensToServers,
23 userLogin, 25 userLogin
24 root
25} from '../../../../shared/extra-utils' 26} from '../../../../shared/extra-utils'
26import { 27import {
27 checkBadCountPagination, 28 checkBadCountPagination,
28 checkBadSortPagination, 29 checkBadSortPagination,
29 checkBadStartPagination 30 checkBadStartPagination
30} from '../../../../shared/extra-utils/requests/check-api-params' 31} from '../../../../shared/extra-utils/requests/check-api-params'
31import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 32import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum'
33import { randomInt } from '@shared/core-utils'
32 34
33const expect = chai.expect 35const expect = chai.expect
34 36
@@ -183,7 +185,7 @@ describe('Test videos API validator', function () {
183 describe('When adding a video', function () { 185 describe('When adding a video', function () {
184 let baseCorrectParams 186 let baseCorrectParams
185 const baseCorrectAttaches = { 187 const baseCorrectAttaches = {
186 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm') 188 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm')
187 } 189 }
188 190
189 before(function () { 191 before(function () {
@@ -206,256 +208,243 @@ describe('Test videos API validator', function () {
206 } 208 }
207 }) 209 })
208 210
209 it('Should fail with nothing', async function () { 211 function runSuite (mode: 'legacy' | 'resumable') {
210 const fields = {}
211 const attaches = {}
212 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
213 })
214 212
215 it('Should fail without name', async function () { 213 it('Should fail with nothing', async function () {
216 const fields = omit(baseCorrectParams, 'name') 214 const fields = {}
217 const attaches = baseCorrectAttaches 215 const attaches = {}
216 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
217 })
218 218
219 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 219 it('Should fail without name', async function () {
220 }) 220 const fields = omit(baseCorrectParams, 'name')
221 const attaches = baseCorrectAttaches
221 222
222 it('Should fail with a long name', async function () { 223 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
223 const fields = immutableAssign(baseCorrectParams, { name: 'super'.repeat(65) }) 224 })
224 const attaches = baseCorrectAttaches
225 225
226 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 226 it('Should fail with a long name', async function () {
227 }) 227 const fields = immutableAssign(baseCorrectParams, { name: 'super'.repeat(65) })
228 const attaches = baseCorrectAttaches
228 229
229 it('Should fail with a bad category', async function () { 230 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
230 const fields = immutableAssign(baseCorrectParams, { category: 125 }) 231 })
231 const attaches = baseCorrectAttaches
232 232
233 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 233 it('Should fail with a bad category', async function () {
234 }) 234 const fields = immutableAssign(baseCorrectParams, { category: 125 })
235 const attaches = baseCorrectAttaches
235 236
236 it('Should fail with a bad licence', async function () { 237 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
237 const fields = immutableAssign(baseCorrectParams, { licence: 125 }) 238 })
238 const attaches = baseCorrectAttaches
239 239
240 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 240 it('Should fail with a bad licence', async function () {
241 }) 241 const fields = immutableAssign(baseCorrectParams, { licence: 125 })
242 const attaches = baseCorrectAttaches
242 243
243 it('Should fail with a bad language', async function () { 244 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
244 const fields = immutableAssign(baseCorrectParams, { language: 'a'.repeat(15) }) 245 })
245 const attaches = baseCorrectAttaches
246 246
247 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 247 it('Should fail with a bad language', async function () {
248 }) 248 const fields = immutableAssign(baseCorrectParams, { language: 'a'.repeat(15) })
249 const attaches = baseCorrectAttaches
249 250
250 it('Should fail with a long description', async function () { 251 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
251 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(2500) }) 252 })
252 const attaches = baseCorrectAttaches
253 253
254 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 254 it('Should fail with a long description', async function () {
255 }) 255 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(2500) })
256 const attaches = baseCorrectAttaches
256 257
257 it('Should fail with a long support text', async function () { 258 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
258 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) }) 259 })
259 const attaches = baseCorrectAttaches
260 260
261 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 261 it('Should fail with a long support text', async function () {
262 }) 262 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) })
263 const attaches = baseCorrectAttaches
263 264
264 it('Should fail without a channel', async function () { 265 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
265 const fields = omit(baseCorrectParams, 'channelId') 266 })
266 const attaches = baseCorrectAttaches
267 267
268 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 268 it('Should fail without a channel', async function () {
269 }) 269 const fields = omit(baseCorrectParams, 'channelId')
270 const attaches = baseCorrectAttaches
270 271
271 it('Should fail with a bad channel', async function () { 272 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
272 const fields = immutableAssign(baseCorrectParams, { channelId: 545454 }) 273 })
273 const attaches = baseCorrectAttaches
274 274
275 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 275 it('Should fail with a bad channel', async function () {
276 }) 276 const fields = immutableAssign(baseCorrectParams, { channelId: 545454 })
277 const attaches = baseCorrectAttaches
277 278
278 it('Should fail with another user channel', async function () { 279 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
279 const user = { 280 })
280 username: 'fake',
281 password: 'fake_password'
282 }
283 await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
284 281
285 const accessTokenUser = await userLogin(server, user) 282 it('Should fail with another user channel', async function () {
286 const res = await getMyUserInformation(server.url, accessTokenUser) 283 const user = {
287 const customChannelId = res.body.videoChannels[0].id 284 username: 'fake' + randomInt(0, 1500),
285 password: 'fake_password'
286 }
287 await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
288 288
289 const fields = immutableAssign(baseCorrectParams, { channelId: customChannelId }) 289 const accessTokenUser = await userLogin(server, user)
290 const attaches = baseCorrectAttaches 290 const res = await getMyUserInformation(server.url, accessTokenUser)
291 const customChannelId = res.body.videoChannels[0].id
291 292
292 await makeUploadRequest({ url: server.url, path: path + '/upload', token: userAccessToken, fields, attaches }) 293 const fields = immutableAssign(baseCorrectParams, { channelId: customChannelId })
293 }) 294 const attaches = baseCorrectAttaches
294 295
295 it('Should fail with too many tags', async function () { 296 await checkUploadVideoParam(server.url, userAccessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
296 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] }) 297 })
297 const attaches = baseCorrectAttaches
298 298
299 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 299 it('Should fail with too many tags', async function () {
300 }) 300 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] })
301 const attaches = baseCorrectAttaches
301 302
302 it('Should fail with a tag length too low', async function () { 303 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
303 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 't' ] }) 304 })
304 const attaches = baseCorrectAttaches
305 305
306 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 306 it('Should fail with a tag length too low', async function () {
307 }) 307 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 't' ] })
308 const attaches = baseCorrectAttaches
308 309
309 it('Should fail with a tag length too big', async function () { 310 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
310 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] }) 311 })
311 const attaches = baseCorrectAttaches
312 312
313 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 313 it('Should fail with a tag length too big', async function () {
314 }) 314 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] })
315 const attaches = baseCorrectAttaches
315 316
316 it('Should fail with a bad schedule update (miss updateAt)', async function () { 317 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
317 const fields = immutableAssign(baseCorrectParams, { 'scheduleUpdate[privacy]': VideoPrivacy.PUBLIC }) 318 })
318 const attaches = baseCorrectAttaches
319 319
320 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 320 it('Should fail with a bad schedule update (miss updateAt)', async function () {
321 }) 321 const fields = immutableAssign(baseCorrectParams, { scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } })
322 const attaches = baseCorrectAttaches
322 323
323 it('Should fail with a bad schedule update (wrong updateAt)', async function () { 324 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
324 const fields = immutableAssign(baseCorrectParams, {
325 'scheduleUpdate[privacy]': VideoPrivacy.PUBLIC,
326 'scheduleUpdate[updateAt]': 'toto'
327 }) 325 })
328 const attaches = baseCorrectAttaches
329 326
330 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 327 it('Should fail with a bad schedule update (wrong updateAt)', async function () {
331 }) 328 const fields = immutableAssign(baseCorrectParams, {
329 scheduleUpdate: {
330 privacy: VideoPrivacy.PUBLIC,
331 updateAt: 'toto'
332 }
333 })
334 const attaches = baseCorrectAttaches
332 335
333 it('Should fail with a bad originally published at attribute', async function () { 336 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
334 const fields = immutableAssign(baseCorrectParams, { originallyPublishedAt: 'toto' }) 337 })
335 const attaches = baseCorrectAttaches
336 338
337 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 339 it('Should fail with a bad originally published at attribute', async function () {
338 }) 340 const fields = immutableAssign(baseCorrectParams, { originallyPublishedAt: 'toto' })
341 const attaches = baseCorrectAttaches
339 342
340 it('Should fail without an input file', async function () { 343 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
341 const fields = baseCorrectParams 344 })
342 const attaches = {}
343 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
344 })
345 345
346 it('Should fail with an incorrect input file', async function () { 346 it('Should fail without an input file', async function () {
347 const fields = baseCorrectParams 347 const fields = baseCorrectParams
348 let attaches = { 348 const attaches = {}
349 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short_fake.webm') 349 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
350 }
351 await makeUploadRequest({
352 url: server.url,
353 path: path + '/upload',
354 token: server.accessToken,
355 fields,
356 attaches,
357 statusCodeExpected: HttpStatusCode.UNPROCESSABLE_ENTITY_422
358 }) 350 })
359 351
360 attaches = { 352 it('Should fail with an incorrect input file', async function () {
361 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mkv') 353 const fields = baseCorrectParams
362 } 354 let attaches = { fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short_fake.webm') }
363 await makeUploadRequest({ 355
364 url: server.url, 356 await checkUploadVideoParam(
365 path: path + '/upload', 357 server.url,
366 token: server.accessToken, 358 server.accessToken,
367 fields, 359 { ...fields, ...attaches },
368 attaches, 360 HttpStatusCode.UNPROCESSABLE_ENTITY_422,
369 statusCodeExpected: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 361 mode
362 )
363
364 attaches = { fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mkv') }
365 await checkUploadVideoParam(
366 server.url,
367 server.accessToken,
368 { ...fields, ...attaches },
369 HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
370 mode
371 )
370 }) 372 })
371 })
372 373
373 it('Should fail with an incorrect thumbnail file', async function () { 374 it('Should fail with an incorrect thumbnail file', async function () {
374 const fields = baseCorrectParams 375 const fields = baseCorrectParams
375 const attaches = { 376 const attaches = {
376 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4'), 377 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4'),
377 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 378 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
378 } 379 }
379 380
380 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 381 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
381 }) 382 })
382 383
383 it('Should fail with a big thumbnail file', async function () { 384 it('Should fail with a big thumbnail file', async function () {
384 const fields = baseCorrectParams 385 const fields = baseCorrectParams
385 const attaches = { 386 const attaches = {
386 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'), 387 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'),
387 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 388 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
388 } 389 }
389 390
390 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 391 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
391 }) 392 })
392 393
393 it('Should fail with an incorrect preview file', async function () { 394 it('Should fail with an incorrect preview file', async function () {
394 const fields = baseCorrectParams 395 const fields = baseCorrectParams
395 const attaches = { 396 const attaches = {
396 previewfile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4'), 397 previewfile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4'),
397 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 398 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
398 } 399 }
399 400
400 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 401 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
401 }) 402 })
402 403
403 it('Should fail with a big preview file', async function () { 404 it('Should fail with a big preview file', async function () {
404 const fields = baseCorrectParams 405 const fields = baseCorrectParams
405 const attaches = { 406 const attaches = {
406 previewfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'), 407 previewfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'),
407 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 408 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
408 } 409 }
409 410
410 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 411 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
411 }) 412 })
412 413
413 it('Should succeed with the correct parameters', async function () { 414 it('Should succeed with the correct parameters', async function () {
414 this.timeout(10000) 415 this.timeout(10000)
415 416
416 const fields = baseCorrectParams 417 const fields = baseCorrectParams
417 418
418 { 419 {
419 const attaches = baseCorrectAttaches 420 const attaches = baseCorrectAttaches
420 await makeUploadRequest({ 421 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.OK_200, mode)
421 url: server.url, 422 }
422 path: path + '/upload',
423 token: server.accessToken,
424 fields,
425 attaches,
426 statusCodeExpected: HttpStatusCode.OK_200
427 })
428 }
429 423
430 { 424 {
431 const attaches = immutableAssign(baseCorrectAttaches, { 425 const attaches = immutableAssign(baseCorrectAttaches, {
432 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 426 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
433 }) 427 })
434 428
435 await makeUploadRequest({ 429 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.OK_200, mode)
436 url: server.url, 430 }
437 path: path + '/upload',
438 token: server.accessToken,
439 fields,
440 attaches,
441 statusCodeExpected: HttpStatusCode.OK_200
442 })
443 }
444 431
445 { 432 {
446 const attaches = immutableAssign(baseCorrectAttaches, { 433 const attaches = immutableAssign(baseCorrectAttaches, {
447 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.ogv') 434 videofile: join(root(), 'server', 'tests', 'fixtures', 'video_short.ogv')
448 }) 435 })
449 436
450 await makeUploadRequest({ 437 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.OK_200, mode)
451 url: server.url, 438 }
452 path: path + '/upload', 439 })
453 token: server.accessToken, 440 }
454 fields, 441
455 attaches, 442 describe('Resumable upload', function () {
456 statusCodeExpected: HttpStatusCode.OK_200 443 runSuite('resumable')
457 }) 444 })
458 } 445
446 describe('Legacy upload', function () {
447 runSuite('legacy')
459 }) 448 })
460 }) 449 })
461 450
@@ -678,7 +667,7 @@ describe('Test videos API validator', function () {
678 }) 667 })
679 668
680 expect(res.body.data).to.be.an('array') 669 expect(res.body.data).to.be.an('array')
681 expect(res.body.data.length).to.equal(3) 670 expect(res.body.data.length).to.equal(6)
682 }) 671 })
683 672
684 it('Should fail without a correct uuid', async function () { 673 it('Should fail without a correct uuid', async function () {
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts
index fc8b447b7..5c07f8926 100644
--- a/server/tests/api/videos/index.ts
+++ b/server/tests/api/videos/index.ts
@@ -1,5 +1,6 @@
1import './audio-only' 1import './audio-only'
2import './multiple-servers' 2import './multiple-servers'
3import './resumable-upload'
3import './single-server' 4import './single-server'
4import './video-captions' 5import './video-captions'
5import './video-change-ownership' 6import './video-change-ownership'
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index 55e280e9f..41cd814e0 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -181,7 +181,7 @@ describe('Test multiple servers', function () {
181 thumbnailfile: 'thumbnail.jpg', 181 thumbnailfile: 'thumbnail.jpg',
182 previewfile: 'preview.jpg' 182 previewfile: 'preview.jpg'
183 } 183 }
184 await uploadVideo(servers[1].url, userAccessToken, videoAttributes) 184 await uploadVideo(servers[1].url, userAccessToken, videoAttributes, HttpStatusCode.OK_200, 'resumable')
185 185
186 // Transcoding 186 // Transcoding
187 await waitJobs(servers) 187 await waitJobs(servers)
diff --git a/server/tests/api/videos/resumable-upload.ts b/server/tests/api/videos/resumable-upload.ts
new file mode 100644
index 000000000..af9221c43
--- /dev/null
+++ b/server/tests/api/videos/resumable-upload.ts
@@ -0,0 +1,187 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { pathExists, readdir, stat } from 'fs-extra'
6import { join } from 'path'
7import { HttpStatusCode } from '@shared/core-utils'
8import {
9 buildAbsoluteFixturePath,
10 buildServerDirectory,
11 flushAndRunServer,
12 getMyUserInformation,
13 prepareResumableUpload,
14 sendDebugCommand,
15 sendResumableChunks,
16 ServerInfo,
17 setAccessTokensToServers,
18 setDefaultVideoChannel,
19 updateUser
20} from '@shared/extra-utils'
21import { MyUser, VideoPrivacy } from '@shared/models'
22
23const expect = chai.expect
24
25// Most classic resumable upload tests are done in other test suites
26
27describe('Test resumable upload', function () {
28 const defaultFixture = 'video_short.mp4'
29 let server: ServerInfo
30 let rootId: number
31
32 async function buildSize (fixture: string, size?: number) {
33 if (size !== undefined) return size
34
35 const baseFixture = buildAbsoluteFixturePath(fixture)
36 return (await stat(baseFixture)).size
37 }
38
39 async function prepareUpload (sizeArg?: number) {
40 const size = await buildSize(defaultFixture, sizeArg)
41
42 const attributes = {
43 name: 'video',
44 channelId: server.videoChannel.id,
45 privacy: VideoPrivacy.PUBLIC,
46 fixture: defaultFixture
47 }
48
49 const mimetype = 'video/mp4'
50
51 const res = await prepareResumableUpload({ url: server.url, token: server.accessToken, attributes, size, mimetype })
52
53 return res.header['location'].split('?')[1]
54 }
55
56 async function sendChunks (options: {
57 pathUploadId: string
58 size?: number
59 expectedStatus?: HttpStatusCode
60 contentLength?: number
61 contentRange?: string
62 contentRangeBuilder?: (start: number, chunk: any) => string
63 }) {
64 const { pathUploadId, expectedStatus, contentLength, contentRangeBuilder } = options
65
66 const size = await buildSize(defaultFixture, options.size)
67 const absoluteFilePath = buildAbsoluteFixturePath(defaultFixture)
68
69 return sendResumableChunks({
70 url: server.url,
71 token: server.accessToken,
72 pathUploadId,
73 videoFilePath: absoluteFilePath,
74 size,
75 contentLength,
76 contentRangeBuilder,
77 specialStatus: expectedStatus
78 })
79 }
80
81 async function checkFileSize (uploadIdArg: string, expectedSize: number | null) {
82 const uploadId = uploadIdArg.replace(/^upload_id=/, '')
83
84 const subPath = join('tmp', 'resumable-uploads', uploadId)
85 const filePath = buildServerDirectory(server, subPath)
86 const exists = await pathExists(filePath)
87
88 if (expectedSize === null) {
89 expect(exists).to.be.false
90 return
91 }
92
93 expect(exists).to.be.true
94
95 expect((await stat(filePath)).size).to.equal(expectedSize)
96 }
97
98 async function countResumableUploads () {
99 const subPath = join('tmp', 'resumable-uploads')
100 const filePath = buildServerDirectory(server, subPath)
101
102 const files = await readdir(filePath)
103 return files.length
104 }
105
106 before(async function () {
107 this.timeout(30000)
108
109 server = await flushAndRunServer(1)
110 await setAccessTokensToServers([ server ])
111 await setDefaultVideoChannel([ server ])
112
113 const res = await getMyUserInformation(server.url, server.accessToken)
114 rootId = (res.body as MyUser).id
115
116 await updateUser({
117 url: server.url,
118 userId: rootId,
119 accessToken: server.accessToken,
120 videoQuota: 10_000_000
121 })
122 })
123
124 describe('Directory cleaning', function () {
125
126 it('Should correctly delete files after an upload', async function () {
127 const uploadId = await prepareUpload()
128 await sendChunks({ pathUploadId: uploadId })
129
130 expect(await countResumableUploads()).to.equal(0)
131 })
132
133 it('Should not delete files after an unfinished upload', async function () {
134 await prepareUpload()
135
136 expect(await countResumableUploads()).to.equal(2)
137 })
138
139 it('Should not delete recent uploads', async function () {
140 await sendDebugCommand(server.url, server.accessToken, { command: 'remove-dandling-resumable-uploads' })
141
142 expect(await countResumableUploads()).to.equal(2)
143 })
144
145 it('Should delete old uploads', async function () {
146 await sendDebugCommand(server.url, server.accessToken, { command: 'remove-dandling-resumable-uploads' })
147
148 expect(await countResumableUploads()).to.equal(0)
149 })
150 })
151
152 describe('Resumable upload and chunks', function () {
153
154 it('Should accept the same amount of chunks', async function () {
155 const uploadId = await prepareUpload()
156 await sendChunks({ pathUploadId: uploadId })
157
158 await checkFileSize(uploadId, null)
159 })
160
161 it('Should not accept more chunks than expected', async function () {
162 const size = 100
163 const uploadId = await prepareUpload(size)
164
165 await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.CONFLICT_409 })
166 await checkFileSize(uploadId, 0)
167 })
168
169 it('Should not accept more chunks than expected with an invalid content length/content range', async function () {
170 const uploadId = await prepareUpload(1500)
171
172 await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.BAD_REQUEST_400, contentLength: 1000 })
173 await checkFileSize(uploadId, 0)
174 })
175
176 it('Should not accept more chunks than expected with an invalid content length', async function () {
177 const uploadId = await prepareUpload(500)
178
179 const size = 1000
180
181 const contentRangeBuilder = start => `bytes ${start}-${start + size - 1}/${size}`
182 await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.BAD_REQUEST_400, contentRangeBuilder, contentLength: size })
183 await checkFileSize(uploadId, 0)
184 })
185 })
186
187})
diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts
index a79648bf7..1058a1e9c 100644
--- a/server/tests/api/videos/single-server.ts
+++ b/server/tests/api/videos/single-server.ts
@@ -1,9 +1,9 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha'
3import * as chai from 'chai' 4import * as chai from 'chai'
4import { keyBy } from 'lodash' 5import { keyBy } from 'lodash'
5import 'mocha' 6
6import { VideoPrivacy } from '../../../../shared/models/videos'
7import { 7import {
8 checkVideoFilesWereRemoved, 8 checkVideoFilesWereRemoved,
9 cleanupTests, 9 cleanupTests,
@@ -28,430 +28,432 @@ import {
28 viewVideo, 28 viewVideo,
29 wait 29 wait
30} from '../../../../shared/extra-utils' 30} from '../../../../shared/extra-utils'
31import { VideoPrivacy } from '../../../../shared/models/videos'
32import { HttpStatusCode } from '@shared/core-utils'
31 33
32const expect = chai.expect 34const expect = chai.expect
33 35
34describe('Test a single server', function () { 36describe('Test a single server', function () {
35 let server: ServerInfo = null
36 let videoId = -1
37 let videoId2 = -1
38 let videoUUID = ''
39 let videosListBase: any[] = null
40
41 const getCheckAttributes = () => ({
42 name: 'my super name',
43 category: 2,
44 licence: 6,
45 language: 'zh',
46 nsfw: true,
47 description: 'my super description',
48 support: 'my super support text',
49 account: {
50 name: 'root',
51 host: 'localhost:' + server.port
52 },
53 isLocal: true,
54 duration: 5,
55 tags: [ 'tag1', 'tag2', 'tag3' ],
56 privacy: VideoPrivacy.PUBLIC,
57 commentsEnabled: true,
58 downloadEnabled: true,
59 channel: {
60 displayName: 'Main root channel',
61 name: 'root_channel',
62 description: '',
63 isLocal: true
64 },
65 fixture: 'video_short.webm',
66 files: [
67 {
68 resolution: 720,
69 size: 218910
70 }
71 ]
72 })
73
74 const updateCheckAttributes = () => ({
75 name: 'my super video updated',
76 category: 4,
77 licence: 2,
78 language: 'ar',
79 nsfw: false,
80 description: 'my super description updated',
81 support: 'my super support text updated',
82 account: {
83 name: 'root',
84 host: 'localhost:' + server.port
85 },
86 isLocal: true,
87 tags: [ 'tagup1', 'tagup2' ],
88 privacy: VideoPrivacy.PUBLIC,
89 duration: 5,
90 commentsEnabled: false,
91 downloadEnabled: false,
92 channel: {
93 name: 'root_channel',
94 displayName: 'Main root channel',
95 description: '',
96 isLocal: true
97 },
98 fixture: 'video_short3.webm',
99 files: [
100 {
101 resolution: 720,
102 size: 292677
103 }
104 ]
105 })
106
107 before(async function () {
108 this.timeout(30000)
109
110 server = await flushAndRunServer(1)
111
112 await setAccessTokensToServers([ server ])
113 })
114
115 it('Should list video categories', async function () {
116 const res = await getVideoCategories(server.url)
117
118 const categories = res.body
119 expect(Object.keys(categories)).to.have.length.above(10)
120
121 expect(categories[11]).to.equal('News & Politics')
122 })
123
124 it('Should list video licences', async function () {
125 const res = await getVideoLicences(server.url)
126
127 const licences = res.body
128 expect(Object.keys(licences)).to.have.length.above(5)
129
130 expect(licences[3]).to.equal('Attribution - No Derivatives')
131 })
132
133 it('Should list video languages', async function () {
134 const res = await getVideoLanguages(server.url)
135
136 const languages = res.body
137 expect(Object.keys(languages)).to.have.length.above(5)
138
139 expect(languages['ru']).to.equal('Russian')
140 })
141
142 it('Should list video privacies', async function () {
143 const res = await getVideoPrivacies(server.url)
144
145 const privacies = res.body
146 expect(Object.keys(privacies)).to.have.length.at.least(3)
147
148 expect(privacies[3]).to.equal('Private')
149 })
150
151 it('Should not have videos', async function () {
152 const res = await getVideosList(server.url)
153
154 expect(res.body.total).to.equal(0)
155 expect(res.body.data).to.be.an('array')
156 expect(res.body.data.length).to.equal(0)
157 })
158 37
159 it('Should upload the video', async function () { 38 function runSuite (mode: 'legacy' | 'resumable') {
160 this.timeout(10000) 39 let server: ServerInfo = null
40 let videoId = -1
41 let videoId2 = -1
42 let videoUUID = ''
43 let videosListBase: any[] = null
161 44
162 const videoAttributes = { 45 const getCheckAttributes = () => ({
163 name: 'my super name', 46 name: 'my super name',
164 category: 2, 47 category: 2,
165 nsfw: true,
166 licence: 6, 48 licence: 6,
167 tags: [ 'tag1', 'tag2', 'tag3' ] 49 language: 'zh',
168 } 50 nsfw: true,
169 const res = await uploadVideo(server.url, server.accessToken, videoAttributes) 51 description: 'my super description',
170 expect(res.body.video).to.not.be.undefined 52 support: 'my super support text',
171 expect(res.body.video.id).to.equal(1) 53 account: {
172 expect(res.body.video.uuid).to.have.length.above(5) 54 name: 'root',
173 55 host: 'localhost:' + server.port
174 videoId = res.body.video.id 56 },
175 videoUUID = res.body.video.uuid 57 isLocal: true,
176 }) 58 duration: 5,
177 59 tags: [ 'tag1', 'tag2', 'tag3' ],
178 it('Should get and seed the uploaded video', async function () { 60 privacy: VideoPrivacy.PUBLIC,
179 this.timeout(5000) 61 commentsEnabled: true,
180 62 downloadEnabled: true,
181 const res = await getVideosList(server.url) 63 channel: {
182 64 displayName: 'Main root channel',
183 expect(res.body.total).to.equal(1) 65 name: 'root_channel',
184 expect(res.body.data).to.be.an('array') 66 description: '',
185 expect(res.body.data.length).to.equal(1) 67 isLocal: true
186 68 },
187 const video = res.body.data[0] 69 fixture: 'video_short.webm',
188 await completeVideoCheck(server.url, video, getCheckAttributes()) 70 files: [
189 }) 71 {
72 resolution: 720,
73 size: 218910
74 }
75 ]
76 })
77
78 const updateCheckAttributes = () => ({
79 name: 'my super video updated',
80 category: 4,
81 licence: 2,
82 language: 'ar',
83 nsfw: false,
84 description: 'my super description updated',
85 support: 'my super support text updated',
86 account: {
87 name: 'root',
88 host: 'localhost:' + server.port
89 },
90 isLocal: true,
91 tags: [ 'tagup1', 'tagup2' ],
92 privacy: VideoPrivacy.PUBLIC,
93 duration: 5,
94 commentsEnabled: false,
95 downloadEnabled: false,
96 channel: {
97 name: 'root_channel',
98 displayName: 'Main root channel',
99 description: '',
100 isLocal: true
101 },
102 fixture: 'video_short3.webm',
103 files: [
104 {
105 resolution: 720,
106 size: 292677
107 }
108 ]
109 })
190 110
191 it('Should get the video by UUID', async function () { 111 before(async function () {
192 this.timeout(5000) 112 this.timeout(30000)
193 113
194 const res = await getVideo(server.url, videoUUID) 114 server = await flushAndRunServer(1)
195 115
196 const video = res.body 116 await setAccessTokensToServers([ server ])
197 await completeVideoCheck(server.url, video, getCheckAttributes()) 117 })
198 })
199 118
200 it('Should have the views updated', async function () { 119 it('Should list video categories', async function () {
201 this.timeout(20000) 120 const res = await getVideoCategories(server.url)
202 121
203 await viewVideo(server.url, videoId) 122 const categories = res.body
204 await viewVideo(server.url, videoId) 123 expect(Object.keys(categories)).to.have.length.above(10)
205 await viewVideo(server.url, videoId)
206 124
207 await wait(1500) 125 expect(categories[11]).to.equal('News & Politics')
126 })
208 127
209 await viewVideo(server.url, videoId) 128 it('Should list video licences', async function () {
210 await viewVideo(server.url, videoId) 129 const res = await getVideoLicences(server.url)
211 130
212 await wait(1500) 131 const licences = res.body
132 expect(Object.keys(licences)).to.have.length.above(5)
213 133
214 await viewVideo(server.url, videoId) 134 expect(licences[3]).to.equal('Attribution - No Derivatives')
215 await viewVideo(server.url, videoId) 135 })
216 136
217 // Wait the repeatable job 137 it('Should list video languages', async function () {
218 await wait(8000) 138 const res = await getVideoLanguages(server.url)
219 139
220 const res = await getVideo(server.url, videoId) 140 const languages = res.body
141 expect(Object.keys(languages)).to.have.length.above(5)
221 142
222 const video = res.body 143 expect(languages['ru']).to.equal('Russian')
223 expect(video.views).to.equal(3) 144 })
224 })
225 145
226 it('Should remove the video', async function () { 146 it('Should list video privacies', async function () {
227 await removeVideo(server.url, server.accessToken, videoId) 147 const res = await getVideoPrivacies(server.url)
228 148
229 await checkVideoFilesWereRemoved(videoUUID, 1) 149 const privacies = res.body
230 }) 150 expect(Object.keys(privacies)).to.have.length.at.least(3)
231 151
232 it('Should not have videos', async function () { 152 expect(privacies[3]).to.equal('Private')
233 const res = await getVideosList(server.url) 153 })
234 154
235 expect(res.body.total).to.equal(0) 155 it('Should not have videos', async function () {
236 expect(res.body.data).to.be.an('array') 156 const res = await getVideosList(server.url)
237 expect(res.body.data).to.have.lengthOf(0)
238 })
239 157
240 it('Should upload 6 videos', async function () { 158 expect(res.body.total).to.equal(0)
241 this.timeout(25000) 159 expect(res.body.data).to.be.an('array')
160 expect(res.body.data.length).to.equal(0)
161 })
242 162
243 const videos = new Set([ 163 it('Should upload the video', async function () {
244 'video_short.mp4', 'video_short.ogv', 'video_short.webm', 164 this.timeout(10000)
245 'video_short1.webm', 'video_short2.webm', 'video_short3.webm'
246 ])
247 165
248 for (const video of videos) {
249 const videoAttributes = { 166 const videoAttributes = {
250 name: video + ' name', 167 name: 'my super name',
251 description: video + ' description',
252 category: 2, 168 category: 2,
253 licence: 1,
254 language: 'en',
255 nsfw: true, 169 nsfw: true,
256 tags: [ 'tag1', 'tag2', 'tag3' ], 170 licence: 6,
257 fixture: video 171 tags: [ 'tag1', 'tag2', 'tag3' ]
258 } 172 }
173 const res = await uploadVideo(server.url, server.accessToken, videoAttributes, HttpStatusCode.OK_200, mode)
174 expect(res.body.video).to.not.be.undefined
175 expect(res.body.video.id).to.equal(1)
176 expect(res.body.video.uuid).to.have.length.above(5)
259 177
260 await uploadVideo(server.url, server.accessToken, videoAttributes) 178 videoId = res.body.video.id
261 } 179 videoUUID = res.body.video.uuid
262 }) 180 })
263 181
264 it('Should have the correct durations', async function () { 182 it('Should get and seed the uploaded video', async function () {
265 const res = await getVideosList(server.url) 183 this.timeout(5000)
266
267 expect(res.body.total).to.equal(6)
268 const videos = res.body.data
269 expect(videos).to.be.an('array')
270 expect(videos).to.have.lengthOf(6)
271
272 const videosByName = keyBy<{ duration: number }>(videos, 'name')
273 expect(videosByName['video_short.mp4 name'].duration).to.equal(5)
274 expect(videosByName['video_short.ogv name'].duration).to.equal(5)
275 expect(videosByName['video_short.webm name'].duration).to.equal(5)
276 expect(videosByName['video_short1.webm name'].duration).to.equal(10)
277 expect(videosByName['video_short2.webm name'].duration).to.equal(5)
278 expect(videosByName['video_short3.webm name'].duration).to.equal(5)
279 })
280 184
281 it('Should have the correct thumbnails', async function () { 185 const res = await getVideosList(server.url)
282 const res = await getVideosList(server.url)
283 186
284 const videos = res.body.data 187 expect(res.body.total).to.equal(1)
285 // For the next test 188 expect(res.body.data).to.be.an('array')
286 videosListBase = videos 189 expect(res.body.data.length).to.equal(1)
287 190
288 for (const video of videos) { 191 const video = res.body.data[0]
289 const videoName = video.name.replace(' name', '') 192 await completeVideoCheck(server.url, video, getCheckAttributes())
290 await testImage(server.url, videoName, video.thumbnailPath) 193 })
291 }
292 })
293 194
294 it('Should list only the two first videos', async function () { 195 it('Should get the video by UUID', async function () {
295 const res = await getVideosListPagination(server.url, 0, 2, 'name') 196 this.timeout(5000)
296 197
297 const videos = res.body.data 198 const res = await getVideo(server.url, videoUUID)
298 expect(res.body.total).to.equal(6)
299 expect(videos.length).to.equal(2)
300 expect(videos[0].name).to.equal(videosListBase[0].name)
301 expect(videos[1].name).to.equal(videosListBase[1].name)
302 })
303 199
304 it('Should list only the next three videos', async function () { 200 const video = res.body
305 const res = await getVideosListPagination(server.url, 2, 3, 'name') 201 await completeVideoCheck(server.url, video, getCheckAttributes())
202 })
306 203
307 const videos = res.body.data 204 it('Should have the views updated', async function () {
308 expect(res.body.total).to.equal(6) 205 this.timeout(20000)
309 expect(videos.length).to.equal(3)
310 expect(videos[0].name).to.equal(videosListBase[2].name)
311 expect(videos[1].name).to.equal(videosListBase[3].name)
312 expect(videos[2].name).to.equal(videosListBase[4].name)
313 })
314 206
315 it('Should list the last video', async function () { 207 await viewVideo(server.url, videoId)
316 const res = await getVideosListPagination(server.url, 5, 6, 'name') 208 await viewVideo(server.url, videoId)
209 await viewVideo(server.url, videoId)
317 210
318 const videos = res.body.data 211 await wait(1500)
319 expect(res.body.total).to.equal(6)
320 expect(videos.length).to.equal(1)
321 expect(videos[0].name).to.equal(videosListBase[5].name)
322 })
323 212
324 it('Should not have the total field', async function () { 213 await viewVideo(server.url, videoId)
325 const res = await getVideosListPagination(server.url, 5, 6, 'name', true) 214 await viewVideo(server.url, videoId)
326 215
327 const videos = res.body.data 216 await wait(1500)
328 expect(res.body.total).to.not.exist
329 expect(videos.length).to.equal(1)
330 expect(videos[0].name).to.equal(videosListBase[5].name)
331 })
332 217
333 it('Should list and sort by name in descending order', async function () { 218 await viewVideo(server.url, videoId)
334 const res = await getVideosListSort(server.url, '-name') 219 await viewVideo(server.url, videoId)
335
336 const videos = res.body.data
337 expect(res.body.total).to.equal(6)
338 expect(videos.length).to.equal(6)
339 expect(videos[0].name).to.equal('video_short.webm name')
340 expect(videos[1].name).to.equal('video_short.ogv name')
341 expect(videos[2].name).to.equal('video_short.mp4 name')
342 expect(videos[3].name).to.equal('video_short3.webm name')
343 expect(videos[4].name).to.equal('video_short2.webm name')
344 expect(videos[5].name).to.equal('video_short1.webm name')
345
346 videoId = videos[3].uuid
347 videoId2 = videos[5].uuid
348 })
349 220
350 it('Should list and sort by trending in descending order', async function () { 221 // Wait the repeatable job
351 const res = await getVideosListPagination(server.url, 0, 2, '-trending') 222 await wait(8000)
352 223
353 const videos = res.body.data 224 const res = await getVideo(server.url, videoId)
354 expect(res.body.total).to.equal(6)
355 expect(videos.length).to.equal(2)
356 })
357 225
358 it('Should list and sort by hotness in descending order', async function () { 226 const video = res.body
359 const res = await getVideosListPagination(server.url, 0, 2, '-hot') 227 expect(video.views).to.equal(3)
228 })
360 229
361 const videos = res.body.data 230 it('Should remove the video', async function () {
362 expect(res.body.total).to.equal(6) 231 await removeVideo(server.url, server.accessToken, videoId)
363 expect(videos.length).to.equal(2)
364 })
365 232
366 it('Should list and sort by best in descending order', async function () { 233 await checkVideoFilesWereRemoved(videoUUID, 1)
367 const res = await getVideosListPagination(server.url, 0, 2, '-best') 234 })
368 235
369 const videos = res.body.data 236 it('Should not have videos', async function () {
370 expect(res.body.total).to.equal(6) 237 const res = await getVideosList(server.url)
371 expect(videos.length).to.equal(2)
372 })
373 238
374 it('Should update a video', async function () { 239 expect(res.body.total).to.equal(0)
375 const attributes = { 240 expect(res.body.data).to.be.an('array')
376 name: 'my super video updated', 241 expect(res.body.data).to.have.lengthOf(0)
377 category: 4, 242 })
378 licence: 2,
379 language: 'ar',
380 nsfw: false,
381 description: 'my super description updated',
382 commentsEnabled: false,
383 downloadEnabled: false,
384 tags: [ 'tagup1', 'tagup2' ]
385 }
386 await updateVideo(server.url, server.accessToken, videoId, attributes)
387 })
388 243
389 it('Should filter by tags and category', async function () { 244 it('Should upload 6 videos', async function () {
390 const res1 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 4 ] }) 245 this.timeout(25000)
391 expect(res1.body.total).to.equal(1)
392 expect(res1.body.data[0].name).to.equal('my super video updated')
393 246
394 const res2 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 3 ] }) 247 const videos = new Set([
395 expect(res2.body.total).to.equal(0) 248 'video_short.mp4', 'video_short.ogv', 'video_short.webm',
396 }) 249 'video_short1.webm', 'video_short2.webm', 'video_short3.webm'
250 ])
397 251
398 it('Should have the video updated', async function () { 252 for (const video of videos) {
399 this.timeout(60000) 253 const videoAttributes = {
254 name: video + ' name',
255 description: video + ' description',
256 category: 2,
257 licence: 1,
258 language: 'en',
259 nsfw: true,
260 tags: [ 'tag1', 'tag2', 'tag3' ],
261 fixture: video
262 }
400 263
401 const res = await getVideo(server.url, videoId) 264 await uploadVideo(server.url, server.accessToken, videoAttributes, HttpStatusCode.OK_200, mode)
402 const video = res.body 265 }
266 })
267
268 it('Should have the correct durations', async function () {
269 const res = await getVideosList(server.url)
270
271 expect(res.body.total).to.equal(6)
272 const videos = res.body.data
273 expect(videos).to.be.an('array')
274 expect(videos).to.have.lengthOf(6)
275
276 const videosByName = keyBy<{ duration: number }>(videos, 'name')
277 expect(videosByName['video_short.mp4 name'].duration).to.equal(5)
278 expect(videosByName['video_short.ogv name'].duration).to.equal(5)
279 expect(videosByName['video_short.webm name'].duration).to.equal(5)
280 expect(videosByName['video_short1.webm name'].duration).to.equal(10)
281 expect(videosByName['video_short2.webm name'].duration).to.equal(5)
282 expect(videosByName['video_short3.webm name'].duration).to.equal(5)
283 })
284
285 it('Should have the correct thumbnails', async function () {
286 const res = await getVideosList(server.url)
287
288 const videos = res.body.data
289 // For the next test
290 videosListBase = videos
291
292 for (const video of videos) {
293 const videoName = video.name.replace(' name', '')
294 await testImage(server.url, videoName, video.thumbnailPath)
295 }
296 })
297
298 it('Should list only the two first videos', async function () {
299 const res = await getVideosListPagination(server.url, 0, 2, 'name')
300
301 const videos = res.body.data
302 expect(res.body.total).to.equal(6)
303 expect(videos.length).to.equal(2)
304 expect(videos[0].name).to.equal(videosListBase[0].name)
305 expect(videos[1].name).to.equal(videosListBase[1].name)
306 })
307
308 it('Should list only the next three videos', async function () {
309 const res = await getVideosListPagination(server.url, 2, 3, 'name')
310
311 const videos = res.body.data
312 expect(res.body.total).to.equal(6)
313 expect(videos.length).to.equal(3)
314 expect(videos[0].name).to.equal(videosListBase[2].name)
315 expect(videos[1].name).to.equal(videosListBase[3].name)
316 expect(videos[2].name).to.equal(videosListBase[4].name)
317 })
318
319 it('Should list the last video', async function () {
320 const res = await getVideosListPagination(server.url, 5, 6, 'name')
321
322 const videos = res.body.data
323 expect(res.body.total).to.equal(6)
324 expect(videos.length).to.equal(1)
325 expect(videos[0].name).to.equal(videosListBase[5].name)
326 })
327
328 it('Should not have the total field', async function () {
329 const res = await getVideosListPagination(server.url, 5, 6, 'name', true)
330
331 const videos = res.body.data
332 expect(res.body.total).to.not.exist
333 expect(videos.length).to.equal(1)
334 expect(videos[0].name).to.equal(videosListBase[5].name)
335 })
336
337 it('Should list and sort by name in descending order', async function () {
338 const res = await getVideosListSort(server.url, '-name')
339
340 const videos = res.body.data
341 expect(res.body.total).to.equal(6)
342 expect(videos.length).to.equal(6)
343 expect(videos[0].name).to.equal('video_short.webm name')
344 expect(videos[1].name).to.equal('video_short.ogv name')
345 expect(videos[2].name).to.equal('video_short.mp4 name')
346 expect(videos[3].name).to.equal('video_short3.webm name')
347 expect(videos[4].name).to.equal('video_short2.webm name')
348 expect(videos[5].name).to.equal('video_short1.webm name')
349
350 videoId = videos[3].uuid
351 videoId2 = videos[5].uuid
352 })
353
354 it('Should list and sort by trending in descending order', async function () {
355 const res = await getVideosListPagination(server.url, 0, 2, '-trending')
356
357 const videos = res.body.data
358 expect(res.body.total).to.equal(6)
359 expect(videos.length).to.equal(2)
360 })
361
362 it('Should list and sort by hotness in descending order', async function () {
363 const res = await getVideosListPagination(server.url, 0, 2, '-hot')
364
365 const videos = res.body.data
366 expect(res.body.total).to.equal(6)
367 expect(videos.length).to.equal(2)
368 })
369
370 it('Should list and sort by best in descending order', async function () {
371 const res = await getVideosListPagination(server.url, 0, 2, '-best')
372
373 const videos = res.body.data
374 expect(res.body.total).to.equal(6)
375 expect(videos.length).to.equal(2)
376 })
377
378 it('Should update a video', async function () {
379 const attributes = {
380 name: 'my super video updated',
381 category: 4,
382 licence: 2,
383 language: 'ar',
384 nsfw: false,
385 description: 'my super description updated',
386 commentsEnabled: false,
387 downloadEnabled: false,
388 tags: [ 'tagup1', 'tagup2' ]
389 }
390 await updateVideo(server.url, server.accessToken, videoId, attributes)
391 })
403 392
404 await completeVideoCheck(server.url, video, updateCheckAttributes()) 393 it('Should filter by tags and category', async function () {
405 }) 394 const res1 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 4 ] })
395 expect(res1.body.total).to.equal(1)
396 expect(res1.body.data[0].name).to.equal('my super video updated')
406 397
407 it('Should update only the tags of a video', async function () { 398 const res2 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 3 ] })
408 const attributes = { 399 expect(res2.body.total).to.equal(0)
409 tags: [ 'supertag', 'tag1', 'tag2' ] 400 })
410 }
411 await updateVideo(server.url, server.accessToken, videoId, attributes)
412 401
413 const res = await getVideo(server.url, videoId) 402 it('Should have the video updated', async function () {
414 const video = res.body 403 this.timeout(60000)
415 404
416 await completeVideoCheck(server.url, video, Object.assign(updateCheckAttributes(), attributes)) 405 const res = await getVideo(server.url, videoId)
417 }) 406 const video = res.body
418 407
419 it('Should update only the description of a video', async function () { 408 await completeVideoCheck(server.url, video, updateCheckAttributes())
420 const attributes = { 409 })
421 description: 'hello everybody'
422 }
423 await updateVideo(server.url, server.accessToken, videoId, attributes)
424 410
425 const res = await getVideo(server.url, videoId) 411 it('Should update only the tags of a video', async function () {
426 const video = res.body 412 const attributes = {
413 tags: [ 'supertag', 'tag1', 'tag2' ]
414 }
415 await updateVideo(server.url, server.accessToken, videoId, attributes)
427 416
428 const expectedAttributes = Object.assign(updateCheckAttributes(), { tags: [ 'supertag', 'tag1', 'tag2' ] }, attributes) 417 const res = await getVideo(server.url, videoId)
429 await completeVideoCheck(server.url, video, expectedAttributes) 418 const video = res.body
430 })
431 419
432 it('Should like a video', async function () { 420 await completeVideoCheck(server.url, video, Object.assign(updateCheckAttributes(), attributes))
433 await rateVideo(server.url, server.accessToken, videoId, 'like') 421 })
434 422
435 const res = await getVideo(server.url, videoId) 423 it('Should update only the description of a video', async function () {
436 const video = res.body 424 const attributes = {
425 description: 'hello everybody'
426 }
427 await updateVideo(server.url, server.accessToken, videoId, attributes)
437 428
438 expect(video.likes).to.equal(1) 429 const res = await getVideo(server.url, videoId)
439 expect(video.dislikes).to.equal(0) 430 const video = res.body
440 })
441 431
442 it('Should dislike the same video', async function () { 432 const expectedAttributes = Object.assign(updateCheckAttributes(), { tags: [ 'supertag', 'tag1', 'tag2' ] }, attributes)
443 await rateVideo(server.url, server.accessToken, videoId, 'dislike') 433 await completeVideoCheck(server.url, video, expectedAttributes)
434 })
444 435
445 const res = await getVideo(server.url, videoId) 436 it('Should like a video', async function () {
446 const video = res.body 437 await rateVideo(server.url, server.accessToken, videoId, 'like')
447 438
448 expect(video.likes).to.equal(0) 439 const res = await getVideo(server.url, videoId)
449 expect(video.dislikes).to.equal(1) 440 const video = res.body
450 })
451 441
452 it('Should sort by originallyPublishedAt', async function () { 442 expect(video.likes).to.equal(1)
453 { 443 expect(video.dislikes).to.equal(0)
444 })
454 445
446 it('Should dislike the same video', async function () {
447 await rateVideo(server.url, server.accessToken, videoId, 'dislike')
448
449 const res = await getVideo(server.url, videoId)
450 const video = res.body
451
452 expect(video.likes).to.equal(0)
453 expect(video.dislikes).to.equal(1)
454 })
455
456 it('Should sort by originallyPublishedAt', async function () {
455 { 457 {
456 const now = new Date() 458 const now = new Date()
457 const attributes = { originallyPublishedAt: now.toISOString() } 459 const attributes = { originallyPublishedAt: now.toISOString() }
@@ -483,10 +485,18 @@ describe('Test a single server', function () {
483 expect(names[4]).to.equal('video_short.ogv name') 485 expect(names[4]).to.equal('video_short.ogv name')
484 expect(names[5]).to.equal('video_short.mp4 name') 486 expect(names[5]).to.equal('video_short.mp4 name')
485 } 487 }
486 } 488 })
489
490 after(async function () {
491 await cleanupTests([ server ])
492 })
493 }
494
495 describe('Legacy upload', function () {
496 runSuite('legacy')
487 }) 497 })
488 498
489 after(async function () { 499 describe('Resumable upload', function () {
490 await cleanupTests([ server ]) 500 runSuite('resumable')
491 }) 501 })
492}) 502})
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts
index 1c99f26df..ea5ffd239 100644
--- a/server/tests/api/videos/video-transcoder.ts
+++ b/server/tests/api/videos/video-transcoder.ts
@@ -361,106 +361,117 @@ describe('Test video transcoding', function () {
361 361
362 describe('Audio upload', function () { 362 describe('Audio upload', function () {
363 363
364 before(async function () { 364 function runSuite (mode: 'legacy' | 'resumable') {
365 await updateCustomSubConfig(servers[1].url, servers[1].accessToken, { 365
366 transcoding: { 366 before(async function () {
367 hls: { enabled: true }, 367 await updateCustomSubConfig(servers[1].url, servers[1].accessToken, {
368 webtorrent: { enabled: true }, 368 transcoding: {
369 resolutions: { 369 hls: { enabled: true },
370 '0p': false, 370 webtorrent: { enabled: true },
371 '240p': false, 371 resolutions: {
372 '360p': false, 372 '0p': false,
373 '480p': false, 373 '240p': false,
374 '720p': false, 374 '360p': false,
375 '1080p': false, 375 '480p': false,
376 '1440p': false, 376 '720p': false,
377 '2160p': false 377 '1080p': false,
378 '1440p': false,
379 '2160p': false
380 }
378 } 381 }
379 } 382 })
380 }) 383 })
381 })
382
383 it('Should merge an audio file with the preview file', async function () {
384 this.timeout(60_000)
385
386 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
387 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg)
388 384
389 await waitJobs(servers) 385 it('Should merge an audio file with the preview file', async function () {
386 this.timeout(60_000)
390 387
391 for (const server of servers) { 388 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
392 const res = await getVideosList(server.url) 389 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg, HttpStatusCode.OK_200, mode)
393 390
394 const video = res.body.data.find(v => v.name === 'audio_with_preview') 391 await waitJobs(servers)
395 const res2 = await getVideo(server.url, video.id)
396 const videoDetails: VideoDetails = res2.body
397 392
398 expect(videoDetails.files).to.have.lengthOf(1) 393 for (const server of servers) {
394 const res = await getVideosList(server.url)
399 395
400 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: HttpStatusCode.OK_200 }) 396 const video = res.body.data.find(v => v.name === 'audio_with_preview')
401 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: HttpStatusCode.OK_200 }) 397 const res2 = await getVideo(server.url, video.id)
398 const videoDetails: VideoDetails = res2.body
402 399
403 const magnetUri = videoDetails.files[0].magnetUri 400 expect(videoDetails.files).to.have.lengthOf(1)
404 expect(magnetUri).to.contain('.mp4')
405 }
406 })
407 401
408 it('Should upload an audio file and choose a default background image', async function () { 402 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: HttpStatusCode.OK_200 })
409 this.timeout(60_000) 403 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: HttpStatusCode.OK_200 })
410 404
411 const videoAttributesArg = { name: 'audio_without_preview', fixture: 'sample.ogg' } 405 const magnetUri = videoDetails.files[0].magnetUri
412 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg) 406 expect(magnetUri).to.contain('.mp4')
407 }
408 })
413 409
414 await waitJobs(servers) 410 it('Should upload an audio file and choose a default background image', async function () {
411 this.timeout(60_000)
415 412
416 for (const server of servers) { 413 const videoAttributesArg = { name: 'audio_without_preview', fixture: 'sample.ogg' }
417 const res = await getVideosList(server.url) 414 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg, HttpStatusCode.OK_200, mode)
418 415
419 const video = res.body.data.find(v => v.name === 'audio_without_preview') 416 await waitJobs(servers)
420 const res2 = await getVideo(server.url, video.id)
421 const videoDetails = res2.body
422 417
423 expect(videoDetails.files).to.have.lengthOf(1) 418 for (const server of servers) {
419 const res = await getVideosList(server.url)
424 420
425 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: HttpStatusCode.OK_200 }) 421 const video = res.body.data.find(v => v.name === 'audio_without_preview')
426 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: HttpStatusCode.OK_200 }) 422 const res2 = await getVideo(server.url, video.id)
423 const videoDetails = res2.body
427 424
428 const magnetUri = videoDetails.files[0].magnetUri 425 expect(videoDetails.files).to.have.lengthOf(1)
429 expect(magnetUri).to.contain('.mp4')
430 }
431 })
432 426
433 it('Should upload an audio file and create an audio version only', async function () { 427 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: HttpStatusCode.OK_200 })
434 this.timeout(60_000) 428 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: HttpStatusCode.OK_200 })
435 429
436 await updateCustomSubConfig(servers[1].url, servers[1].accessToken, { 430 const magnetUri = videoDetails.files[0].magnetUri
437 transcoding: { 431 expect(magnetUri).to.contain('.mp4')
438 hls: { enabled: true },
439 webtorrent: { enabled: true },
440 resolutions: {
441 '0p': true,
442 '240p': false,
443 '360p': false
444 }
445 } 432 }
446 }) 433 })
447 434
448 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' } 435 it('Should upload an audio file and create an audio version only', async function () {
449 const resVideo = await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg) 436 this.timeout(60_000)
437
438 await updateCustomSubConfig(servers[1].url, servers[1].accessToken, {
439 transcoding: {
440 hls: { enabled: true },
441 webtorrent: { enabled: true },
442 resolutions: {
443 '0p': true,
444 '240p': false,
445 '360p': false
446 }
447 }
448 })
450 449
451 await waitJobs(servers) 450 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
451 const resVideo = await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributesArg, HttpStatusCode.OK_200, mode)
452 452
453 for (const server of servers) { 453 await waitJobs(servers)
454 const res2 = await getVideo(server.url, resVideo.body.video.id) 454
455 const videoDetails: VideoDetails = res2.body 455 for (const server of servers) {
456 const res2 = await getVideo(server.url, resVideo.body.video.id)
457 const videoDetails: VideoDetails = res2.body
456 458
457 for (const files of [ videoDetails.files, videoDetails.streamingPlaylists[0].files ]) { 459 for (const files of [ videoDetails.files, videoDetails.streamingPlaylists[0].files ]) {
458 expect(files).to.have.lengthOf(2) 460 expect(files).to.have.lengthOf(2)
459 expect(files.find(f => f.resolution.id === 0)).to.not.be.undefined 461 expect(files.find(f => f.resolution.id === 0)).to.not.be.undefined
462 }
460 } 463 }
461 }
462 464
463 await updateConfigForTranscoding(servers[1]) 465 await updateConfigForTranscoding(servers[1])
466 })
467 }
468
469 describe('Legacy upload', function () {
470 runSuite('legacy')
471 })
472
473 describe('Resumable upload', function () {
474 runSuite('resumable')
464 }) 475 })
465 }) 476 })
466 477
diff --git a/server/typings/express/index.d.ts b/server/typings/express/index.d.ts
index cf3e7ae34..55b6e0039 100644
--- a/server/typings/express/index.d.ts
+++ b/server/typings/express/index.d.ts
@@ -19,6 +19,9 @@ import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server'
19import { MVideoImportDefault } from '@server/types/models/video/video-import' 19import { MVideoImportDefault } from '@server/types/models/video/video-import'
20import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element' 20import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element'
21import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate' 21import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate'
22import { HttpMethod } from '@shared/core-utils/miscs/http-methods'
23import { VideoCreate } from '@shared/models'
24import { File as UploadXFile, Metadata } from '@uploadx/core'
22import { RegisteredPlugin } from '../../lib/plugins/plugin-manager' 25import { RegisteredPlugin } from '../../lib/plugins/plugin-manager'
23import { 26import {
24 MAccountDefault, 27 MAccountDefault,
@@ -37,86 +40,125 @@ import {
37 MVideoThumbnail, 40 MVideoThumbnail,
38 MVideoWithRights 41 MVideoWithRights
39} from '../../types/models' 42} from '../../types/models'
40
41declare module 'express' { 43declare module 'express' {
42 export interface Request { 44 export interface Request {
43 query: any 45 query: any
46 method: HttpMethod
44 } 47 }
45 interface Response { 48
46 locals: PeerTubeLocals 49 // Upload using multer or uploadx middleware
50 export type MulterOrUploadXFile = UploadXFile | Express.Multer.File
51
52 export type UploadFiles = {
53 [fieldname: string]: MulterOrUploadXFile[]
54 } | MulterOrUploadXFile[]
55
56 // Partial object used by some functions to check the file mimetype/extension
57 export type UploadFileForCheck = {
58 originalname: string
59 mimetype: string
47 } 60 }
48}
49 61
50interface PeerTubeLocals { 62 export type UploadFilesForCheck = {
51 videoAll?: MVideoFullLight 63 [fieldname: string]: UploadFileForCheck[]
52 onlyImmutableVideo?: MVideoImmutable 64 } | UploadFileForCheck[]
53 onlyVideo?: MVideoThumbnail
54 onlyVideoWithRights?: MVideoWithRights
55 videoId?: MVideoIdThumbnail
56 65
57 videoLive?: MVideoLive 66 // Upload file with a duration added by our middleware
67 export type VideoUploadFile = Pick<Express.Multer.File, 'path' | 'filename' | 'size'> & {
68 duration: number
69 }
58 70
59 videoShare?: MVideoShareActor 71 // Extends Metadata property of UploadX object
72 export type UploadXFileMetadata = Metadata & VideoCreate & {
73 previewfile: Express.Multer.File[]
74 thumbnailfile: Express.Multer.File[]
75 }
60 76
61 videoFile?: MVideoFile 77 // Our custom UploadXFile object using our custom metadata
78 export type CustomUploadXFile <T extends Metadata> = UploadXFile & { metadata: T }
62 79
63 videoImport?: MVideoImportDefault 80 export type EnhancedUploadXFile = CustomUploadXFile<UploadXFileMetadata> & {
81 duration: number
82 path: string
83 filename: string
84 }
64 85
65 videoBlacklist?: MVideoBlacklist 86 // Extends locals property from Response
87 interface Response {
88 locals: {
89 videoAll?: MVideoFullLight
90 onlyImmutableVideo?: MVideoImmutable
91 onlyVideo?: MVideoThumbnail
92 onlyVideoWithRights?: MVideoWithRights
93 videoId?: MVideoIdThumbnail
66 94
67 videoCaption?: MVideoCaptionVideo 95 videoLive?: MVideoLive
68 96
69 abuse?: MAbuseReporter 97 videoShare?: MVideoShareActor
70 abuseMessage?: MAbuseMessage
71 98
72 videoStreamingPlaylist?: MStreamingPlaylist 99 videoFile?: MVideoFile
73 100
74 videoChannel?: MChannelBannerAccountDefault 101 videoFileResumable?: EnhancedUploadXFile
75 102
76 videoPlaylistFull?: MVideoPlaylistFull 103 videoImport?: MVideoImportDefault
77 videoPlaylistSummary?: MVideoPlaylistFullSummary
78 104
79 videoPlaylistElement?: MVideoPlaylistElement 105 videoBlacklist?: MVideoBlacklist
80 videoPlaylistElementAP?: MVideoPlaylistElementVideoUrlPlaylistPrivacy
81 106
82 accountVideoRate?: MAccountVideoRateAccountVideo 107 videoCaption?: MVideoCaptionVideo
83 108
84 videoCommentFull?: MCommentOwnerVideoReply 109 abuse?: MAbuseReporter
85 videoCommentThread?: MComment 110 abuseMessage?: MAbuseMessage
86 111
87 follow?: MActorFollowActorsDefault 112 videoStreamingPlaylist?: MStreamingPlaylist
88 subscription?: MActorFollowActorsDefaultSubscription
89 113
90 nextOwner?: MAccountDefault 114 videoChannel?: MChannelBannerAccountDefault
91 videoChangeOwnership?: MVideoChangeOwnershipFull
92 115
93 account?: MAccountDefault 116 videoPlaylistFull?: MVideoPlaylistFull
117 videoPlaylistSummary?: MVideoPlaylistFullSummary
94 118
95 actorUrl?: MActorUrl 119 videoPlaylistElement?: MVideoPlaylistElement
96 actorFull?: MActorFull 120 videoPlaylistElementAP?: MVideoPlaylistElementVideoUrlPlaylistPrivacy
97 121
98 user?: MUserDefault 122 accountVideoRate?: MAccountVideoRateAccountVideo
99 123
100 server?: MServer 124 videoCommentFull?: MCommentOwnerVideoReply
125 videoCommentThread?: MComment
101 126
102 videoRedundancy?: MVideoRedundancyVideo 127 follow?: MActorFollowActorsDefault
128 subscription?: MActorFollowActorsDefaultSubscription
103 129
104 accountBlock?: MAccountBlocklist 130 nextOwner?: MAccountDefault
105 serverBlock?: MServerBlocklist 131 videoChangeOwnership?: MVideoChangeOwnershipFull
106 132
107 oauth?: { 133 account?: MAccountDefault
108 token: MOAuthTokenUser
109 }
110 134
111 signature?: { 135 actorUrl?: MActorUrl
112 actor: MActorAccountChannelId 136 actorFull?: MActorFull
113 } 137
138 user?: MUserDefault
139
140 server?: MServer
141
142 videoRedundancy?: MVideoRedundancyVideo
114 143
115 authenticated?: boolean 144 accountBlock?: MAccountBlocklist
145 serverBlock?: MServerBlocklist
116 146
117 registeredPlugin?: RegisteredPlugin 147 oauth?: {
148 token: MOAuthTokenUser
149 }
118 150
119 externalAuth?: RegisterServerAuthExternalOptions 151 signature?: {
152 actor: MActorAccountChannelId
153 }
120 154
121 plugin?: MPlugin 155 authenticated?: boolean
156
157 registeredPlugin?: RegisteredPlugin
158
159 externalAuth?: RegisterServerAuthExternalOptions
160
161 plugin?: MPlugin
162 }
163 }
122} 164}