aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/config.ts5
-rw-r--r--server/controllers/api/videos/index.ts16
-rw-r--r--server/controllers/api/videos/source.ts206
-rw-r--r--server/controllers/api/videos/update.ts1
-rw-r--r--server/controllers/api/videos/upload.ts11
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts1
-rw-r--r--server/helpers/image-utils.ts2
-rw-r--r--server/initializers/checker-before-init.ts1
-rw-r--r--server/initializers/config.ts5
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/migrations/0800-video-replace-file.ts38
-rw-r--r--server/lib/activitypub/context.ts3
-rw-r--r--server/lib/activitypub/videos/shared/creator.ts1
-rw-r--r--server/lib/activitypub/videos/shared/object-to-model-attributes.ts4
-rw-r--r--server/lib/activitypub/videos/updater.ts4
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts20
-rw-r--r--server/lib/moderation.ts4
-rw-r--r--server/lib/server-config-manager.ts5
-rw-r--r--server/lib/thumbnail.ts25
-rw-r--r--server/lib/video-blacklist.ts10
-rw-r--r--server/lib/video-pre-import.ts1
-rw-r--r--server/middlewares/validators/config.ts2
-rw-r--r--server/middlewares/validators/videos/index.ts6
-rw-r--r--server/middlewares/validators/videos/shared/index.ts2
-rw-r--r--server/middlewares/validators/videos/shared/upload.ts39
-rw-r--r--server/middlewares/validators/videos/shared/video-validators.ts104
-rw-r--r--server/middlewares/validators/videos/video-source.ts108
-rw-r--r--server/middlewares/validators/videos/video-studio.ts12
-rw-r--r--server/middlewares/validators/videos/videos.ts112
-rw-r--r--server/models/video/formatter/video-activity-pub-format.ts2
-rw-r--r--server/models/video/formatter/video-api-format.ts1
-rw-r--r--server/models/video/sql/video/shared/video-table-attributes.ts1
-rw-r--r--server/models/video/video-source.ts45
-rw-r--r--server/models/video/video.ts8
-rw-r--r--server/tests/api/check-params/config.ts5
-rw-r--r--server/tests/api/check-params/video-source.ts148
-rw-r--r--server/tests/api/server/config.ts9
-rw-r--r--server/tests/api/videos/index.ts2
-rw-r--r--server/tests/api/videos/resumable-upload.ts8
-rw-r--r--server/tests/api/videos/video-source.ts447
-rw-r--r--server/tests/cli/prune-storage.ts22
-rw-r--r--server/tests/shared/videos.ts2
-rw-r--r--server/types/express.d.ts7
43 files changed, 1216 insertions, 241 deletions
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 0980ec10a..c5c4c8a74 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -284,6 +284,11 @@ function customConfig (): CustomConfig {
284 enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED 284 enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
285 } 285 }
286 }, 286 },
287 videoFile: {
288 update: {
289 enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
290 }
291 },
287 import: { 292 import: {
288 videos: { 293 videos: {
289 concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY, 294 concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 520d8cbbb..3cdd42289 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -26,7 +26,6 @@ import {
26 setDefaultVideosSort, 26 setDefaultVideosSort,
27 videosCustomGetValidator, 27 videosCustomGetValidator,
28 videosGetValidator, 28 videosGetValidator,
29 videoSourceGetValidator,
30 videosRemoveValidator, 29 videosRemoveValidator,
31 videosSortValidator 30 videosSortValidator
32} from '../../../middlewares' 31} from '../../../middlewares'
@@ -39,7 +38,9 @@ import { filesRouter } from './files'
39import { videoImportsRouter } from './import' 38import { videoImportsRouter } from './import'
40import { liveRouter } from './live' 39import { liveRouter } from './live'
41import { ownershipVideoRouter } from './ownership' 40import { ownershipVideoRouter } from './ownership'
41import { videoPasswordRouter } from './passwords'
42import { rateVideoRouter } from './rate' 42import { rateVideoRouter } from './rate'
43import { videoSourceRouter } from './source'
43import { statsRouter } from './stats' 44import { statsRouter } from './stats'
44import { storyboardRouter } from './storyboard' 45import { storyboardRouter } from './storyboard'
45import { studioRouter } from './studio' 46import { studioRouter } from './studio'
@@ -48,7 +49,6 @@ import { transcodingRouter } from './transcoding'
48import { updateRouter } from './update' 49import { updateRouter } from './update'
49import { uploadRouter } from './upload' 50import { uploadRouter } from './upload'
50import { viewRouter } from './view' 51import { viewRouter } from './view'
51import { videoPasswordRouter } from './passwords'
52 52
53const auditLogger = auditLoggerFactory('videos') 53const auditLogger = auditLoggerFactory('videos')
54const videosRouter = express.Router() 54const videosRouter = express.Router()
@@ -72,6 +72,7 @@ videosRouter.use('/', transcodingRouter)
72videosRouter.use('/', tokenRouter) 72videosRouter.use('/', tokenRouter)
73videosRouter.use('/', videoPasswordRouter) 73videosRouter.use('/', videoPasswordRouter)
74videosRouter.use('/', storyboardRouter) 74videosRouter.use('/', storyboardRouter)
75videosRouter.use('/', videoSourceRouter)
75 76
76videosRouter.get('/categories', 77videosRouter.get('/categories',
77 openapiOperationDoc({ operationId: 'getCategories' }), 78 openapiOperationDoc({ operationId: 'getCategories' }),
@@ -108,13 +109,6 @@ videosRouter.get('/:id/description',
108 asyncMiddleware(getVideoDescription) 109 asyncMiddleware(getVideoDescription)
109) 110)
110 111
111videosRouter.get('/:id/source',
112 openapiOperationDoc({ operationId: 'getVideoSource' }),
113 authenticate,
114 asyncMiddleware(videoSourceGetValidator),
115 getVideoSource
116)
117
118videosRouter.get('/:id', 112videosRouter.get('/:id',
119 openapiOperationDoc({ operationId: 'getVideo' }), 113 openapiOperationDoc({ operationId: 'getVideo' }),
120 optionalAuthenticate, 114 optionalAuthenticate,
@@ -177,10 +171,6 @@ async function getVideoDescription (req: express.Request, res: express.Response)
177 return res.json({ description }) 171 return res.json({ description })
178} 172}
179 173
180function getVideoSource (req: express.Request, res: express.Response) {
181 return res.json(res.locals.videoSource.toFormattedJSON())
182}
183
184async function listVideos (req: express.Request, res: express.Response) { 174async function listVideos (req: express.Request, res: express.Response) {
185 const serverActor = await getServerActor() 175 const serverActor = await getServerActor()
186 176
diff --git a/server/controllers/api/videos/source.ts b/server/controllers/api/videos/source.ts
new file mode 100644
index 000000000..b20c4af0e
--- /dev/null
+++ b/server/controllers/api/videos/source.ts
@@ -0,0 +1,206 @@
1import express from 'express'
2import { move } from 'fs-extra'
3import { sequelizeTypescript } from '@server/initializers/database'
4import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue'
5import { Hooks } from '@server/lib/plugins/hooks'
6import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail'
7import { uploadx } from '@server/lib/uploadx'
8import { buildMoveToObjectStorageJob } from '@server/lib/video'
9import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
10import { buildNewFile } from '@server/lib/video-file'
11import { VideoPathManager } from '@server/lib/video-path-manager'
12import { buildNextVideoState } from '@server/lib/video-state'
13import { openapiOperationDoc } from '@server/middlewares/doc'
14import { VideoModel } from '@server/models/video/video'
15import { VideoSourceModel } from '@server/models/video/video-source'
16import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
17import { HttpStatusCode, VideoState } from '@shared/models'
18import { logger, loggerTagsFactory } from '../../../helpers/logger'
19import {
20 asyncMiddleware,
21 authenticate,
22 replaceVideoSourceResumableInitValidator,
23 replaceVideoSourceResumableValidator,
24 videoSourceGetLatestValidator
25} from '../../../middlewares'
26
27const lTags = loggerTagsFactory('api', 'video')
28
29const videoSourceRouter = express.Router()
30
31videoSourceRouter.get('/:id/source',
32 openapiOperationDoc({ operationId: 'getVideoSource' }),
33 authenticate,
34 asyncMiddleware(videoSourceGetLatestValidator),
35 getVideoLatestSource
36)
37
38videoSourceRouter.post('/:id/source/replace-resumable',
39 authenticate,
40 asyncMiddleware(replaceVideoSourceResumableInitValidator),
41 (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
42)
43
44videoSourceRouter.delete('/:id/source/replace-resumable',
45 authenticate,
46 (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
47)
48
49videoSourceRouter.put('/:id/source/replace-resumable',
50 authenticate,
51 uploadx.upload, // uploadx doesn't next() before the file upload completes
52 asyncMiddleware(replaceVideoSourceResumableValidator),
53 asyncMiddleware(replaceVideoSourceResumable)
54)
55
56// ---------------------------------------------------------------------------
57
58export {
59 videoSourceRouter
60}
61
62// ---------------------------------------------------------------------------
63
64function getVideoLatestSource (req: express.Request, res: express.Response) {
65 return res.json(res.locals.videoSource.toFormattedJSON())
66}
67
68async function replaceVideoSourceResumable (req: express.Request, res: express.Response) {
69 const videoPhysicalFile = res.locals.updateVideoFileResumable
70 const user = res.locals.oauth.token.User
71
72 const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
73 const originalFilename = videoPhysicalFile.originalname
74
75 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(res.locals.videoAll.uuid)
76
77 try {
78 const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(res.locals.videoAll, videoFile)
79 await move(videoPhysicalFile.path, destination)
80
81 let oldWebVideoFiles: MVideoFile[] = []
82 let oldStreamingPlaylists: MStreamingPlaylistFiles[] = []
83
84 const inputFileUpdatedAt = new Date()
85
86 const video = await sequelizeTypescript.transaction(async transaction => {
87 const video = await VideoModel.loadFull(res.locals.videoAll.id, transaction)
88
89 oldWebVideoFiles = video.VideoFiles
90 oldStreamingPlaylists = video.VideoStreamingPlaylists
91
92 for (const file of video.VideoFiles) {
93 await file.destroy({ transaction })
94 }
95 for (const playlist of oldStreamingPlaylists) {
96 await playlist.destroy({ transaction })
97 }
98
99 videoFile.videoId = video.id
100 await videoFile.save({ transaction })
101
102 video.VideoFiles = [ videoFile ]
103 video.VideoStreamingPlaylists = []
104
105 video.state = buildNextVideoState()
106 video.duration = videoPhysicalFile.duration
107 video.inputFileUpdatedAt = inputFileUpdatedAt
108 await video.save({ transaction })
109
110 await autoBlacklistVideoIfNeeded({
111 video,
112 user,
113 isRemote: false,
114 isNew: false,
115 isNewFile: true,
116 transaction
117 })
118
119 return video
120 })
121
122 await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })
123
124 await VideoSourceModel.create({
125 filename: originalFilename,
126 videoId: video.id,
127 createdAt: inputFileUpdatedAt
128 })
129
130 await regenerateMiniaturesIfNeeded(video)
131 await video.VideoChannel.setAsUpdated()
132 await addVideoJobsAfterUpload(video, video.getMaxQualityFile())
133
134 logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid))
135
136 Hooks.runAction('action:api.video.file-updated', { video, req, res })
137
138 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
139 } finally {
140 videoFileMutexReleaser()
141 }
142}
143
144async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
145 const jobs: (CreateJobArgument & CreateJobOptions)[] = [
146 {
147 type: 'manage-video-torrent' as 'manage-video-torrent',
148 payload: {
149 videoId: video.id,
150 videoFileId: videoFile.id,
151 action: 'create'
152 }
153 },
154
155 {
156 type: 'generate-video-storyboard' as 'generate-video-storyboard',
157 payload: {
158 videoUUID: video.uuid,
159 // No need to federate, we process these jobs sequentially
160 federate: false
161 }
162 },
163
164 {
165 type: 'federate-video' as 'federate-video',
166 payload: {
167 videoUUID: video.uuid,
168 isNewVideo: false
169 }
170 }
171 ]
172
173 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
174 jobs.push(await buildMoveToObjectStorageJob({ video, isNewVideo: false, previousVideoState: undefined }))
175 }
176
177 if (video.state === VideoState.TO_TRANSCODE) {
178 jobs.push({
179 type: 'transcoding-job-builder' as 'transcoding-job-builder',
180 payload: {
181 videoUUID: video.uuid,
182 optimizeJob: {
183 isNewVideo: false
184 }
185 }
186 })
187 }
188
189 return JobQueue.Instance.createSequentialJobFlow(...jobs)
190}
191
192async function removeOldFiles (options: {
193 video: MVideo
194 files: MVideoFile[]
195 playlists: MStreamingPlaylistFiles[]
196}) {
197 const { video, files, playlists } = options
198
199 for (const file of files) {
200 await video.removeWebVideoFile(file)
201 }
202
203 for (const playlist of playlists) {
204 await video.removeStreamingPlaylistFiles(playlist)
205 }
206}
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index 28ec2cf37..1edc509dc 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -130,6 +130,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
130 user: res.locals.oauth.token.User, 130 user: res.locals.oauth.token.User,
131 isRemote: false, 131 isRemote: false,
132 isNew: false, 132 isNew: false,
133 isNewFile: false,
133 transaction: t 134 transaction: t
134 }) 135 })
135 136
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
index 27fef0b1a..e520bf4b5 100644
--- a/server/controllers/api/videos/upload.ts
+++ b/server/controllers/api/videos/upload.ts
@@ -11,8 +11,9 @@ import { buildNewFile } from '@server/lib/video-file'
11import { VideoPathManager } from '@server/lib/video-path-manager' 11import { VideoPathManager } from '@server/lib/video-path-manager'
12import { buildNextVideoState } from '@server/lib/video-state' 12import { buildNextVideoState } from '@server/lib/video-state'
13import { openapiOperationDoc } from '@server/middlewares/doc' 13import { openapiOperationDoc } from '@server/middlewares/doc'
14import { VideoPasswordModel } from '@server/models/video/video-password'
14import { VideoSourceModel } from '@server/models/video/video-source' 15import { VideoSourceModel } from '@server/models/video/video-source'
15import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' 16import { MVideoFile, MVideoFullLight } from '@server/types/models'
16import { uuidToShort } from '@shared/extra-utils' 17import { uuidToShort } from '@shared/extra-utils'
17import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models' 18import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
18import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 19import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
@@ -33,7 +34,6 @@ import {
33} from '../../../middlewares' 34} from '../../../middlewares'
34import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 35import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
35import { VideoModel } from '../../../models/video/video' 36import { VideoModel } from '../../../models/video/video'
36import { VideoPasswordModel } from '@server/models/video/video-password'
37 37
38const lTags = loggerTagsFactory('api', 'video') 38const lTags = loggerTagsFactory('api', 'video')
39const auditLogger = auditLoggerFactory('videos') 39const auditLogger = auditLoggerFactory('videos')
@@ -109,7 +109,7 @@ async function addVideoLegacy (req: express.Request, res: express.Response) {
109} 109}
110 110
111async function addVideoResumable (req: express.Request, res: express.Response) { 111async function addVideoResumable (req: express.Request, res: express.Response) {
112 const videoPhysicalFile = res.locals.videoFileResumable 112 const videoPhysicalFile = res.locals.uploadVideoFileResumable
113 const videoInfo = videoPhysicalFile.metadata 113 const videoInfo = videoPhysicalFile.metadata
114 const files = { previewfile: videoInfo.previewfile, thumbnailfile: videoInfo.thumbnailfile } 114 const files = { previewfile: videoInfo.previewfile, thumbnailfile: videoInfo.thumbnailfile }
115 115
@@ -193,6 +193,7 @@ async function addVideo (options: {
193 user, 193 user,
194 isRemote: false, 194 isRemote: false,
195 isNew: true, 195 isNew: true,
196 isNewFile: true,
196 transaction: t 197 transaction: t
197 }) 198 })
198 199
@@ -209,7 +210,7 @@ async function addVideo (options: {
209 // Channel has a new content, set as updated 210 // Channel has a new content, set as updated
210 await videoCreated.VideoChannel.setAsUpdated() 211 await videoCreated.VideoChannel.setAsUpdated()
211 212
212 addVideoJobsAfterUpload(videoCreated, videoFile, user) 213 addVideoJobsAfterUpload(videoCreated, videoFile)
213 .catch(err => logger.error('Cannot build new video jobs of %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) })) 214 .catch(err => logger.error('Cannot build new video jobs of %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))
214 215
215 Hooks.runAction('action:api.video.uploaded', { video: videoCreated, req, res }) 216 Hooks.runAction('action:api.video.uploaded', { video: videoCreated, req, res })
@@ -223,7 +224,7 @@ async function addVideo (options: {
223 } 224 }
224} 225}
225 226
226async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile, user: MUserId) { 227async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
227 const jobs: (CreateJobArgument & CreateJobOptions)[] = [ 228 const jobs: (CreateJobArgument & CreateJobOptions)[] = [
228 { 229 {
229 type: 'manage-video-torrent' as 'manage-video-torrent', 230 type: 'manage-video-torrent' as 'manage-video-torrent',
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index 573a29754..07e25b8ba 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -76,6 +76,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
76 isDateValid(video.published) && 76 isDateValid(video.published) &&
77 isDateValid(video.updated) && 77 isDateValid(video.updated) &&
78 (!video.originallyPublishedAt || isDateValid(video.originallyPublishedAt)) && 78 (!video.originallyPublishedAt || isDateValid(video.originallyPublishedAt)) &&
79 (!video.uploadDate || isDateValid(video.uploadDate)) &&
79 (!video.content || isRemoteVideoContentValid(video.mediaType, video.content)) && 80 (!video.content || isRemoteVideoContentValid(video.mediaType, video.content)) &&
80 video.attributedTo.length !== 0 81 video.attributedTo.length !== 0
81} 82}
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts
index 7b77e694a..2a8bb6e6e 100644
--- a/server/helpers/image-utils.ts
+++ b/server/helpers/image-utils.ts
@@ -63,6 +63,8 @@ async function generateImageFromVideoFile (options: {
63 } catch (err) { 63 } catch (err) {
64 logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() }) 64 logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
65 } 65 }
66
67 throw err
66 } 68 }
67} 69}
68 70
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index a872fcba3..f77b0defb 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -40,6 +40,7 @@ function checkMissedConfig () {
40 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p', 40 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
41 'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled', 41 'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled',
42 'video_studio.enabled', 'video_studio.remote_runners.enabled', 42 'video_studio.enabled', 'video_studio.remote_runners.enabled',
43 'video_file.update.enabled',
43 'remote_runners.stalled_jobs.vod', 'remote_runners.stalled_jobs.live', 44 'remote_runners.stalled_jobs.vod', 'remote_runners.stalled_jobs.live',
44 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'import.videos.timeout', 45 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'import.videos.timeout',
45 'import.video_channel_synchronization.enabled', 'import.video_channel_synchronization.max_per_user', 46 'import.video_channel_synchronization.enabled', 'import.video_channel_synchronization.max_per_user',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 37cd852f1..f12d9b85a 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -435,6 +435,11 @@ const CONFIG = {
435 get ENABLED () { return config.get<boolean>('video_studio.remote_runners.enabled') } 435 get ENABLED () { return config.get<boolean>('video_studio.remote_runners.enabled') }
436 } 436 }
437 }, 437 },
438 VIDEO_FILE: {
439 UPDATE: {
440 get ENABLED () { return config.get<boolean>('video_file.update.enabled') }
441 }
442 },
438 IMPORT: { 443 IMPORT: {
439 VIDEOS: { 444 VIDEOS: {
440 get CONCURRENCY () { return config.get<number>('import.videos.concurrency') }, 445 get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index e09f0e3c6..9e5a02854 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
27 27
28// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
29 29
30const LAST_MIGRATION_VERSION = 795 30const LAST_MIGRATION_VERSION = 800
31 31
32// --------------------------------------------------------------------------- 32// ---------------------------------------------------------------------------
33 33
diff --git a/server/initializers/migrations/0800-video-replace-file.ts b/server/initializers/migrations/0800-video-replace-file.ts
new file mode 100644
index 000000000..f924a4d92
--- /dev/null
+++ b/server/initializers/migrations/0800-video-replace-file.ts
@@ -0,0 +1,38 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8 const { transaction } = utils
9
10 {
11 const query = 'DELETE FROM "videoSource" WHERE "videoId" IS NULL'
12 await utils.sequelize.query(query, { transaction })
13 }
14
15 {
16 const query = 'ALTER TABLE "videoSource" ALTER COLUMN "videoId" SET NOT NULL'
17 await utils.sequelize.query(query, { transaction })
18 }
19
20 {
21 const data = {
22 type: Sequelize.DATE,
23 allowNull: true,
24 defaultValue: null
25 }
26
27 await utils.queryInterface.addColumn('video', 'inputFileUpdatedAt', data, { transaction })
28 }
29}
30
31function down (options) {
32 throw new Error('Not implemented.')
33}
34
35export {
36 up,
37 down
38}
diff --git a/server/lib/activitypub/context.ts b/server/lib/activitypub/context.ts
index 750276a11..eba6d636d 100644
--- a/server/lib/activitypub/context.ts
+++ b/server/lib/activitypub/context.ts
@@ -60,6 +60,9 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
60 }, 60 },
61 61
62 originallyPublishedAt: 'sc:datePublished', 62 originallyPublishedAt: 'sc:datePublished',
63
64 uploadDate: 'sc:uploadDate',
65
63 views: { 66 views: {
64 '@type': 'sc:Number', 67 '@type': 'sc:Number',
65 '@id': 'pt:views' 68 '@id': 'pt:views'
diff --git a/server/lib/activitypub/videos/shared/creator.ts b/server/lib/activitypub/videos/shared/creator.ts
index bc139e4fa..512d14d82 100644
--- a/server/lib/activitypub/videos/shared/creator.ts
+++ b/server/lib/activitypub/videos/shared/creator.ts
@@ -49,6 +49,7 @@ export class APVideoCreator extends APVideoAbstractBuilder {
49 user: undefined, 49 user: undefined,
50 isRemote: true, 50 isRemote: true,
51 isNew: true, 51 isNew: true,
52 isNewFile: true,
52 transaction: t 53 transaction: t
53 }) 54 })
54 55
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
index a9e0bed97..6cbe72e27 100644
--- a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
+++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -231,6 +231,10 @@ function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: Vi
231 ? new Date(videoObject.originallyPublishedAt) 231 ? new Date(videoObject.originallyPublishedAt)
232 : null, 232 : null,
233 233
234 inputFileUpdatedAt: videoObject.uploadDate
235 ? new Date(videoObject.uploadDate)
236 : null,
237
234 updatedAt: new Date(videoObject.updated), 238 updatedAt: new Date(videoObject.updated),
235 views: videoObject.views, 239 views: videoObject.views,
236 remote: true, 240 remote: true,
diff --git a/server/lib/activitypub/videos/updater.ts b/server/lib/activitypub/videos/updater.ts
index 522d7b043..acb087895 100644
--- a/server/lib/activitypub/videos/updater.ts
+++ b/server/lib/activitypub/videos/updater.ts
@@ -38,6 +38,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
38 { videoObject: this.videoObject, ...this.lTags() } 38 { videoObject: this.videoObject, ...this.lTags() }
39 ) 39 )
40 40
41 const oldInputFileUpdatedAt = this.video.inputFileUpdatedAt
42
41 try { 43 try {
42 const channelActor = await this.getOrCreateVideoChannelFromVideoObject() 44 const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
43 45
@@ -74,6 +76,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
74 user: undefined, 76 user: undefined,
75 isRemote: true, 77 isRemote: true,
76 isNew: false, 78 isNew: false,
79 isNewFile: oldInputFileUpdatedAt !== videoUpdated.inputFileUpdatedAt,
77 transaction: undefined 80 transaction: undefined
78 }) 81 })
79 82
@@ -129,6 +132,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
129 this.video.createdAt = videoData.createdAt 132 this.video.createdAt = videoData.createdAt
130 this.video.publishedAt = videoData.publishedAt 133 this.video.publishedAt = videoData.publishedAt
131 this.video.originallyPublishedAt = videoData.originallyPublishedAt 134 this.video.originallyPublishedAt = videoData.originallyPublishedAt
135 this.video.inputFileUpdatedAt = videoData.inputFileUpdatedAt
132 this.video.privacy = videoData.privacy 136 this.video.privacy = videoData.privacy
133 this.video.channelId = videoData.channelId 137 this.video.channelId = videoData.channelId
134 this.video.views = videoData.views 138 this.video.views = videoData.views
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
index ae886de35..982280b55 100644
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -7,7 +7,7 @@ import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
8import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live' 8import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live'
9import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths' 9import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths'
10import { generateLocalVideoMiniature } from '@server/lib/thumbnail' 10import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail'
11import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding' 11import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding'
12import { VideoPathManager } from '@server/lib/video-path-manager' 12import { VideoPathManager } from '@server/lib/video-path-manager'
13import { moveToNextState } from '@server/lib/video-state' 13import { moveToNextState } from '@server/lib/video-state'
@@ -197,23 +197,7 @@ async function replaceLiveByReplay (options: {
197 } 197 }
198 198
199 // Regenerate the thumbnail & preview? 199 // Regenerate the thumbnail & preview?
200 if (videoWithFiles.getMiniature().automaticallyGenerated === true) { 200 await regenerateMiniaturesIfNeeded(videoWithFiles)
201 const miniature = await generateLocalVideoMiniature({
202 video: videoWithFiles,
203 videoFile: videoWithFiles.getMaxQualityFile(),
204 type: ThumbnailType.MINIATURE
205 })
206 await videoWithFiles.addAndSaveThumbnail(miniature)
207 }
208
209 if (videoWithFiles.getPreview().automaticallyGenerated === true) {
210 const preview = await generateLocalVideoMiniature({
211 video: videoWithFiles,
212 videoFile: videoWithFiles.getMaxQualityFile(),
213 type: ThumbnailType.PREVIEW
214 })
215 await videoWithFiles.addAndSaveThumbnail(preview)
216 }
217 201
218 // We consider this is a new video 202 // We consider this is a new video
219 await moveToNextState({ video: videoWithFiles, isNewVideo: true }) 203 await moveToNextState({ video: videoWithFiles, isNewVideo: true })
diff --git a/server/lib/moderation.ts b/server/lib/moderation.ts
index dc5d8c83c..db8284872 100644
--- a/server/lib/moderation.ts
+++ b/server/lib/moderation.ts
@@ -36,7 +36,7 @@ export type AcceptResult = {
36// --------------------------------------------------------------------------- 36// ---------------------------------------------------------------------------
37 37
38// Stub function that can be filtered by plugins 38// Stub function that can be filtered by plugins
39function isLocalVideoAccepted (object: { 39function isLocalVideoFileAccepted (object: {
40 videoBody: VideoCreate 40 videoBody: VideoCreate
41 videoFile: VideoUploadFile 41 videoFile: VideoUploadFile
42 user: UserModel 42 user: UserModel
@@ -201,7 +201,7 @@ function createAccountAbuse (options: {
201export { 201export {
202 isLocalLiveVideoAccepted, 202 isLocalLiveVideoAccepted,
203 203
204 isLocalVideoAccepted, 204 isLocalVideoFileAccepted,
205 isLocalVideoThreadAccepted, 205 isLocalVideoThreadAccepted,
206 isRemoteVideoCommentAccepted, 206 isRemoteVideoCommentAccepted,
207 isLocalVideoCommentReplyAccepted, 207 isLocalVideoCommentReplyAccepted,
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
index 5ce89b16d..beb5d4d82 100644
--- a/server/lib/server-config-manager.ts
+++ b/server/lib/server-config-manager.ts
@@ -171,6 +171,11 @@ class ServerConfigManager {
171 enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED 171 enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
172 } 172 }
173 }, 173 },
174 videoFile: {
175 update: {
176 enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
177 }
178 },
174 import: { 179 import: {
175 videos: { 180 videos: {
176 http: { 181 http: {
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
index d95442795..0b98da14f 100644
--- a/server/lib/thumbnail.ts
+++ b/server/lib/thumbnail.ts
@@ -4,7 +4,7 @@ import { generateImageFilename, generateImageFromVideoFile } from '../helpers/im
4import { CONFIG } from '../initializers/config' 4import { CONFIG } from '../initializers/config'
5import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants' 5import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
6import { ThumbnailModel } from '../models/video/thumbnail' 6import { ThumbnailModel } from '../models/video/thumbnail'
7import { MVideoFile, MVideoThumbnail, MVideoUUID } from '../types/models' 7import { MVideoFile, MVideoThumbnail, MVideoUUID, MVideoWithAllFiles } from '../types/models'
8import { MThumbnail } from '../types/models/video/thumbnail' 8import { MThumbnail } from '../types/models/video/thumbnail'
9import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist' 9import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist'
10import { VideoPathManager } from './video-path-manager' 10import { VideoPathManager } from './video-path-manager'
@@ -187,8 +187,31 @@ function updateRemoteVideoThumbnail (options: {
187 187
188// --------------------------------------------------------------------------- 188// ---------------------------------------------------------------------------
189 189
190async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles) {
191 if (video.getMiniature().automaticallyGenerated === true) {
192 const miniature = await generateLocalVideoMiniature({
193 video,
194 videoFile: video.getMaxQualityFile(),
195 type: ThumbnailType.MINIATURE
196 })
197 await video.addAndSaveThumbnail(miniature)
198 }
199
200 if (video.getPreview().automaticallyGenerated === true) {
201 const preview = await generateLocalVideoMiniature({
202 video,
203 videoFile: video.getMaxQualityFile(),
204 type: ThumbnailType.PREVIEW
205 })
206 await video.addAndSaveThumbnail(preview)
207 }
208}
209
210// ---------------------------------------------------------------------------
211
190export { 212export {
191 generateLocalVideoMiniature, 213 generateLocalVideoMiniature,
214 regenerateMiniaturesIfNeeded,
192 updateLocalVideoMiniatureFromUrl, 215 updateLocalVideoMiniatureFromUrl,
193 updateLocalVideoMiniatureFromExisting, 216 updateLocalVideoMiniatureFromExisting,
194 updateRemoteVideoThumbnail, 217 updateRemoteVideoThumbnail,
diff --git a/server/lib/video-blacklist.ts b/server/lib/video-blacklist.ts
index cb1ea834c..d5664a1b9 100644
--- a/server/lib/video-blacklist.ts
+++ b/server/lib/video-blacklist.ts
@@ -27,13 +27,14 @@ async function autoBlacklistVideoIfNeeded (parameters: {
27 user?: MUser 27 user?: MUser
28 isRemote: boolean 28 isRemote: boolean
29 isNew: boolean 29 isNew: boolean
30 isNewFile: boolean
30 notify?: boolean 31 notify?: boolean
31 transaction?: Transaction 32 transaction?: Transaction
32}) { 33}) {
33 const { video, user, isRemote, isNew, notify = true, transaction } = parameters 34 const { video, user, isRemote, isNew, isNewFile, notify = true, transaction } = parameters
34 const doAutoBlacklist = await Hooks.wrapFun( 35 const doAutoBlacklist = await Hooks.wrapFun(
35 autoBlacklistNeeded, 36 autoBlacklistNeeded,
36 { video, user, isRemote, isNew }, 37 { video, user, isRemote, isNew, isNewFile },
37 'filter:video.auto-blacklist.result' 38 'filter:video.auto-blacklist.result'
38 ) 39 )
39 40
@@ -128,14 +129,15 @@ function autoBlacklistNeeded (parameters: {
128 video: MVideoWithBlacklistLight 129 video: MVideoWithBlacklistLight
129 isRemote: boolean 130 isRemote: boolean
130 isNew: boolean 131 isNew: boolean
132 isNewFile: boolean
131 user?: MUser 133 user?: MUser
132}) { 134}) {
133 const { user, video, isRemote, isNew } = parameters 135 const { user, video, isRemote, isNew, isNewFile } = parameters
134 136
135 // Already blacklisted 137 // Already blacklisted
136 if (video.VideoBlacklist) return false 138 if (video.VideoBlacklist) return false
137 if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED || !user) return false 139 if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED || !user) return false
138 if (isRemote || isNew === false) return false 140 if (isRemote || (isNew === false && isNewFile === false)) return false
139 141
140 if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)) return false 142 if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)) return false
141 143
diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts
index 381f1f535..fcb9f77d7 100644
--- a/server/lib/video-pre-import.ts
+++ b/server/lib/video-pre-import.ts
@@ -89,6 +89,7 @@ async function insertFromImportIntoDB (parameters: {
89 notify: false, 89 notify: false,
90 isRemote: false, 90 isRemote: false,
91 isNew: true, 91 isNew: true,
92 isNewFile: true,
92 transaction: t 93 transaction: t
93 }) 94 })
94 95
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index a6dbba524..4c1aa26c1 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -65,6 +65,8 @@ const customConfigUpdateValidator = [
65 body('videoStudio.enabled').isBoolean(), 65 body('videoStudio.enabled').isBoolean(),
66 body('videoStudio.remoteRunners.enabled').isBoolean(), 66 body('videoStudio.remoteRunners.enabled').isBoolean(),
67 67
68 body('videoFile.update.enabled').isBoolean(),
69
68 body('import.videos.concurrency').isInt({ min: 0 }), 70 body('import.videos.concurrency').isInt({ min: 0 }),
69 body('import.videos.http.enabled').isBoolean(), 71 body('import.videos.http.enabled').isBoolean(),
70 body('import.videos.torrent.enabled').isBoolean(), 72 body('import.videos.torrent.enabled').isBoolean(),
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index 0c824c314..8c6fc49b1 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -1,12 +1,13 @@
1export * from './video-blacklist' 1export * from './video-blacklist'
2export * from './video-captions' 2export * from './video-captions'
3export * from './video-channel-sync'
3export * from './video-channels' 4export * from './video-channels'
4export * from './video-comments' 5export * from './video-comments'
5export * from './video-files' 6export * from './video-files'
6export * from './video-imports' 7export * from './video-imports'
7export * from './video-live' 8export * from './video-live'
8export * from './video-ownership-changes' 9export * from './video-ownership-changes'
9export * from './video-view' 10export * from './video-passwords'
10export * from './video-rates' 11export * from './video-rates'
11export * from './video-shares' 12export * from './video-shares'
12export * from './video-source' 13export * from './video-source'
@@ -14,6 +15,5 @@ export * from './video-stats'
14export * from './video-studio' 15export * from './video-studio'
15export * from './video-token' 16export * from './video-token'
16export * from './video-transcoding' 17export * from './video-transcoding'
18export * from './video-view'
17export * from './videos' 19export * from './videos'
18export * from './video-channel-sync'
19export * from './video-passwords'
diff --git a/server/middlewares/validators/videos/shared/index.ts b/server/middlewares/validators/videos/shared/index.ts
new file mode 100644
index 000000000..eb11dcc6a
--- /dev/null
+++ b/server/middlewares/validators/videos/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './upload'
2export * from './video-validators'
diff --git a/server/middlewares/validators/videos/shared/upload.ts b/server/middlewares/validators/videos/shared/upload.ts
new file mode 100644
index 000000000..ea0dddc3c
--- /dev/null
+++ b/server/middlewares/validators/videos/shared/upload.ts
@@ -0,0 +1,39 @@
1import express from 'express'
2import { logger } from '@server/helpers/logger'
3import { getVideoStreamDuration } from '@shared/ffmpeg'
4import { HttpStatusCode } from '@shared/models'
5
6export async function addDurationToVideoFileIfNeeded (options: {
7 res: express.Response
8 videoFile: { path: string, duration?: number }
9 middlewareName: string
10}) {
11 const { res, middlewareName, videoFile } = options
12
13 try {
14 if (!videoFile.duration) await addDurationToVideo(videoFile)
15 } catch (err) {
16 logger.error('Invalid input file in ' + middlewareName, { err })
17
18 res.fail({
19 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
20 message: 'Video file unreadable.'
21 })
22 return false
23 }
24
25 return true
26}
27
28// ---------------------------------------------------------------------------
29// Private
30// ---------------------------------------------------------------------------
31
32async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
33 const duration = await getVideoStreamDuration(videoFile.path)
34
35 // FFmpeg may not be able to guess video duration
36 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
37 if (isNaN(duration)) videoFile.duration = 0
38 else videoFile.duration = duration
39}
diff --git a/server/middlewares/validators/videos/shared/video-validators.ts b/server/middlewares/validators/videos/shared/video-validators.ts
new file mode 100644
index 000000000..72536011d
--- /dev/null
+++ b/server/middlewares/validators/videos/shared/video-validators.ts
@@ -0,0 +1,104 @@
1import express from 'express'
2import { isVideoFileMimeTypeValid, isVideoFileSizeValid } from '@server/helpers/custom-validators/videos'
3import { logger } from '@server/helpers/logger'
4import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
5import { isLocalVideoFileAccepted } from '@server/lib/moderation'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { MUserAccountId, MVideo } from '@server/types/models'
8import { HttpStatusCode, ServerErrorCode, ServerFilterHookName, VideoState } from '@shared/models'
9import { checkUserQuota } from '../../shared'
10
11export async function commonVideoFileChecks (options: {
12 res: express.Response
13 user: MUserAccountId
14 videoFileSize: number
15 files: express.UploadFilesForCheck
16}): Promise<boolean> {
17 const { res, user, videoFileSize, files } = options
18
19 if (!isVideoFileMimeTypeValid(files)) {
20 res.fail({
21 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
22 message: 'This file is not supported. Please, make sure it is of the following type: ' +
23 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
24 })
25 return false
26 }
27
28 if (!isVideoFileSizeValid(videoFileSize.toString())) {
29 res.fail({
30 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
31 message: 'This file is too large. It exceeds the maximum file size authorized.',
32 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
33 })
34 return false
35 }
36
37 if (await checkUserQuota(user, videoFileSize, res) === false) return false
38
39 return true
40}
41
42export async function isVideoFileAccepted (options: {
43 req: express.Request
44 res: express.Response
45 videoFile: express.VideoUploadFile
46 hook: Extract<ServerFilterHookName, 'filter:api.video.upload.accept.result' | 'filter:api.video.update-file.accept.result'>
47}) {
48 const { req, res, videoFile } = options
49
50 // Check we accept this video
51 const acceptParameters = {
52 videoBody: req.body,
53 videoFile,
54 user: res.locals.oauth.token.User
55 }
56 const acceptedResult = await Hooks.wrapFun(
57 isLocalVideoFileAccepted,
58 acceptParameters,
59 'filter:api.video.upload.accept.result'
60 )
61
62 if (!acceptedResult || acceptedResult.accepted !== true) {
63 logger.info('Refused local video file.', { acceptedResult, acceptParameters })
64 res.fail({
65 status: HttpStatusCode.FORBIDDEN_403,
66 message: acceptedResult.errorMessage || 'Refused local video file'
67 })
68 return false
69 }
70
71 return true
72}
73
74export function checkVideoFileCanBeEdited (video: MVideo, res: express.Response) {
75 if (video.isLive) {
76 res.fail({
77 status: HttpStatusCode.BAD_REQUEST_400,
78 message: 'Cannot edit a live video'
79 })
80
81 return false
82 }
83
84 if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
85 res.fail({
86 status: HttpStatusCode.CONFLICT_409,
87 message: 'Cannot edit video that is already waiting for transcoding/edition'
88 })
89
90 return false
91 }
92
93 const validStates = new Set([ VideoState.PUBLISHED, VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED, VideoState.TRANSCODING_FAILED ])
94 if (!validStates.has(video.state)) {
95 res.fail({
96 status: HttpStatusCode.BAD_REQUEST_400,
97 message: 'Video state is not compatible with edition'
98 })
99
100 return false
101 }
102
103 return true
104}
diff --git a/server/middlewares/validators/videos/video-source.ts b/server/middlewares/validators/videos/video-source.ts
index c6d8f1a81..bbccb58b0 100644
--- a/server/middlewares/validators/videos/video-source.ts
+++ b/server/middlewares/validators/videos/video-source.ts
@@ -1,20 +1,31 @@
1import express from 'express' 1import express from 'express'
2import { body, header } from 'express-validator'
3import { getResumableUploadPath } from '@server/helpers/upload'
2import { getVideoWithAttributes } from '@server/helpers/video' 4import { getVideoWithAttributes } from '@server/helpers/video'
5import { CONFIG } from '@server/initializers/config'
6import { uploadx } from '@server/lib/uploadx'
3import { VideoSourceModel } from '@server/models/video/video-source' 7import { VideoSourceModel } from '@server/models/video/video-source'
4import { MVideoFullLight } from '@server/types/models' 8import { MVideoFullLight } from '@server/types/models'
5import { HttpStatusCode, UserRight } from '@shared/models' 9import { HttpStatusCode, UserRight } from '@shared/models'
10import { Metadata as UploadXMetadata } from '@uploadx/core'
11import { logger } from '../../../helpers/logger'
6import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared' 12import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared'
13import { addDurationToVideoFileIfNeeded, checkVideoFileCanBeEdited, commonVideoFileChecks, isVideoFileAccepted } from './shared'
7 14
8const videoSourceGetValidator = [ 15export const videoSourceGetLatestValidator = [
9 isValidVideoIdParam('id'), 16 isValidVideoIdParam('id'),
10 17
11 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 18 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
12 if (areValidationErrors(req, res)) return 19 if (areValidationErrors(req, res)) return
13 if (!await doesVideoExist(req.params.id, res, 'for-api')) return 20 if (!await doesVideoExist(req.params.id, res, 'all')) return
14 21
15 const video = getVideoWithAttributes(res) as MVideoFullLight 22 const video = getVideoWithAttributes(res) as MVideoFullLight
16 23
17 res.locals.videoSource = await VideoSourceModel.loadByVideoId(video.id) 24 const user = res.locals.oauth.token.User
25 if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return
26
27 res.locals.videoSource = await VideoSourceModel.loadLatest(video.id)
28
18 if (!res.locals.videoSource) { 29 if (!res.locals.videoSource) {
19 return res.fail({ 30 return res.fail({
20 status: HttpStatusCode.NOT_FOUND_404, 31 status: HttpStatusCode.NOT_FOUND_404,
@@ -22,13 +33,98 @@ const videoSourceGetValidator = [
22 }) 33 })
23 } 34 }
24 35
36 return next()
37 }
38]
39
40export const replaceVideoSourceResumableValidator = [
41 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
42 const body: express.CustomUploadXFile<UploadXMetadata> = req.body
43 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename }
44 const cleanup = () => uploadx.storage.delete(file).catch(err => logger.error('Cannot delete the file %s', file.name, { err }))
45
46 if (!await checkCanUpdateVideoFile({ req, res })) {
47 return cleanup()
48 }
49
50 if (!await addDurationToVideoFileIfNeeded({ videoFile: file, res, middlewareName: 'updateVideoFileResumableValidator' })) {
51 return cleanup()
52 }
53
54 if (!await isVideoFileAccepted({ req, res, videoFile: file, hook: 'filter:api.video.update-file.accept.result' })) {
55 return cleanup()
56 }
57
58 res.locals.updateVideoFileResumable = { ...file, originalname: file.filename }
59
60 return next()
61 }
62]
63
64export const replaceVideoSourceResumableInitValidator = [
65 body('filename')
66 .exists(),
67
68 header('x-upload-content-length')
69 .isNumeric()
70 .exists()
71 .withMessage('Should specify the file length'),
72 header('x-upload-content-type')
73 .isString()
74 .exists()
75 .withMessage('Should specify the file mimetype'),
76
77 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
25 const user = res.locals.oauth.token.User 78 const user = res.locals.oauth.token.User
26 if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return 79
80 logger.debug('Checking updateVideoFileResumableInitValidator parameters and headers', {
81 parameters: req.body,
82 headers: req.headers
83 })
84
85 if (areValidationErrors(req, res, { omitLog: true })) return
86
87 if (!await checkCanUpdateVideoFile({ req, res })) return
88
89 const videoFileMetadata = {
90 mimetype: req.headers['x-upload-content-type'] as string,
91 size: +req.headers['x-upload-content-length'],
92 originalname: req.body.filename
93 }
94
95 const files = { videofile: [ videoFileMetadata ] }
96 if (await commonVideoFileChecks({ res, user, videoFileSize: videoFileMetadata.size, files }) === false) return
27 97
28 return next() 98 return next()
29 } 99 }
30] 100]
31 101
32export { 102// ---------------------------------------------------------------------------
33 videoSourceGetValidator 103// Private
104// ---------------------------------------------------------------------------
105
106async function checkCanUpdateVideoFile (options: {
107 req: express.Request
108 res: express.Response
109}) {
110 const { req, res } = options
111
112 if (!CONFIG.VIDEO_FILE.UPDATE.ENABLED) {
113 res.fail({
114 status: HttpStatusCode.FORBIDDEN_403,
115 message: 'Updating the file of an existing video is not allowed on this instance'
116 })
117 return false
118 }
119
120 if (!await doesVideoExist(req.params.id, res)) return false
121
122 const user = res.locals.oauth.token.User
123 const video = res.locals.videoAll
124
125 if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return false
126
127 if (!checkVideoFileCanBeEdited(video, res)) return false
128
129 return true
34} 130}
diff --git a/server/middlewares/validators/videos/video-studio.ts b/server/middlewares/validators/videos/video-studio.ts
index 7a68f88e5..a375af60a 100644
--- a/server/middlewares/validators/videos/video-studio.ts
+++ b/server/middlewares/validators/videos/video-studio.ts
@@ -11,8 +11,9 @@ import { cleanUpReqFiles } from '@server/helpers/express-utils'
11import { CONFIG } from '@server/initializers/config' 11import { CONFIG } from '@server/initializers/config'
12import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio' 12import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio'
13import { isAudioFile } from '@shared/ffmpeg' 13import { isAudioFile } from '@shared/ffmpeg'
14import { HttpStatusCode, UserRight, VideoState, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models' 14import { HttpStatusCode, UserRight, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models'
15import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared' 15import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared'
16import { checkVideoFileCanBeEdited } from './shared'
16 17
17const videoStudioAddEditionValidator = [ 18const videoStudioAddEditionValidator = [
18 param('videoId') 19 param('videoId')
@@ -66,14 +67,7 @@ const videoStudioAddEditionValidator = [
66 if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req) 67 if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
67 68
68 const video = res.locals.videoAll 69 const video = res.locals.videoAll
69 if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) { 70 if (!checkVideoFileCanBeEdited(video, res)) return cleanUpReqFiles(req)
70 res.fail({
71 status: HttpStatusCode.CONFLICT_409,
72 message: 'Cannot edit video that is already waiting for transcoding/edition'
73 })
74
75 return cleanUpReqFiles(req)
76 }
77 71
78 const user = res.locals.oauth.token.User 72 const user = res.locals.oauth.token.User
79 if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) 73 if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index b39d13a23..aea3453b5 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -2,13 +2,12 @@ import express from 'express'
2import { body, header, param, query, ValidationChain } from 'express-validator' 2import { body, header, param, query, ValidationChain } from 'express-validator'
3import { isTestInstance } from '@server/helpers/core-utils' 3import { isTestInstance } from '@server/helpers/core-utils'
4import { getResumableUploadPath } from '@server/helpers/upload' 4import { getResumableUploadPath } from '@server/helpers/upload'
5import { uploadx } from '@server/lib/uploadx'
6import { Redis } from '@server/lib/redis' 5import { Redis } from '@server/lib/redis'
6import { uploadx } from '@server/lib/uploadx'
7import { getServerActor } from '@server/models/application/application' 7import { getServerActor } from '@server/models/application/application'
8import { ExpressPromiseHandler } from '@server/types/express-handler' 8import { ExpressPromiseHandler } from '@server/types/express-handler'
9import { MUserAccountId, MVideoFullLight } from '@server/types/models' 9import { MUserAccountId, MVideoFullLight } from '@server/types/models'
10import { arrayify, getAllPrivacies } from '@shared/core-utils' 10import { arrayify, getAllPrivacies } from '@shared/core-utils'
11import { getVideoStreamDuration } from '@shared/ffmpeg'
12import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models' 11import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models'
13import { 12import {
14 exists, 13 exists,
@@ -27,8 +26,6 @@ import {
27 isValidPasswordProtectedPrivacy, 26 isValidPasswordProtectedPrivacy,
28 isVideoCategoryValid, 27 isVideoCategoryValid,
29 isVideoDescriptionValid, 28 isVideoDescriptionValid,
30 isVideoFileMimeTypeValid,
31 isVideoFileSizeValid,
32 isVideoFilterValid, 29 isVideoFilterValid,
33 isVideoImageValid, 30 isVideoImageValid,
34 isVideoIncludeValid, 31 isVideoIncludeValid,
@@ -44,21 +41,19 @@ import { logger } from '../../../helpers/logger'
44import { getVideoWithAttributes } from '../../../helpers/video' 41import { getVideoWithAttributes } from '../../../helpers/video'
45import { CONFIG } from '../../../initializers/config' 42import { CONFIG } from '../../../initializers/config'
46import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' 43import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
47import { isLocalVideoAccepted } from '../../../lib/moderation'
48import { Hooks } from '../../../lib/plugins/hooks'
49import { VideoModel } from '../../../models/video/video' 44import { VideoModel } from '../../../models/video/video'
50import { 45import {
51 areValidationErrors, 46 areValidationErrors,
52 checkCanAccessVideoStaticFiles, 47 checkCanAccessVideoStaticFiles,
53 checkCanSeeVideo, 48 checkCanSeeVideo,
54 checkUserCanManageVideo, 49 checkUserCanManageVideo,
55 checkUserQuota,
56 doesVideoChannelOfAccountExist, 50 doesVideoChannelOfAccountExist,
57 doesVideoExist, 51 doesVideoExist,
58 doesVideoFileOfVideoExist, 52 doesVideoFileOfVideoExist,
59 isValidVideoIdParam, 53 isValidVideoIdParam,
60 isValidVideoPasswordHeader 54 isValidVideoPasswordHeader
61} from '../shared' 55} from '../shared'
56import { addDurationToVideoFileIfNeeded, commonVideoFileChecks, isVideoFileAccepted } from './shared'
62 57
63const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ 58const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
64 body('videofile') 59 body('videofile')
@@ -83,26 +78,15 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
83 const videoFile: express.VideoUploadFile = req.files['videofile'][0] 78 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
84 const user = res.locals.oauth.token.User 79 const user = res.locals.oauth.token.User
85 80
86 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) { 81 if (
82 !await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files }) ||
83 !isValidPasswordProtectedPrivacy(req, res) ||
84 !await addDurationToVideoFileIfNeeded({ videoFile, res, middlewareName: 'videosAddvideosAddLegacyValidatorResumableValidator' }) ||
85 !await isVideoFileAccepted({ req, res, videoFile, hook: 'filter:api.video.upload.accept.result' })
86 ) {
87 return cleanUpReqFiles(req) 87 return cleanUpReqFiles(req)
88 } 88 }
89 89
90 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
91
92 try {
93 if (!videoFile.duration) await addDurationToVideo(videoFile)
94 } catch (err) {
95 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
96
97 res.fail({
98 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
99 message: 'Video file unreadable.'
100 })
101 return cleanUpReqFiles(req)
102 }
103
104 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
105
106 return next() 90 return next()
107 } 91 }
108]) 92])
@@ -146,22 +130,10 @@ const videosAddResumableValidator = [
146 await Redis.Instance.setUploadSession(uploadId) 130 await Redis.Instance.setUploadSession(uploadId)
147 131
148 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup() 132 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
133 if (!await addDurationToVideoFileIfNeeded({ videoFile: file, res, middlewareName: 'videosAddResumableValidator' })) return cleanup()
134 if (!await isVideoFileAccepted({ req, res, videoFile: file, hook: 'filter:api.video.upload.accept.result' })) return cleanup()
149 135
150 try { 136 res.locals.uploadVideoFileResumable = { ...file, originalname: file.filename }
151 if (!file.duration) await addDurationToVideo(file)
152 } catch (err) {
153 logger.error('Invalid input file in videosAddResumableValidator.', { err })
154
155 res.fail({
156 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
157 message: 'Video file unreadable.'
158 })
159 return cleanup()
160 }
161
162 if (!await isVideoAccepted(req, res, file)) return cleanup()
163
164 res.locals.videoFileResumable = { ...file, originalname: file.filename }
165 137
166 return next() 138 return next()
167 } 139 }
@@ -604,76 +576,20 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response)
604 return false 576 return false
605} 577}
606 578
607async function commonVideoChecksPass (parameters: { 579async function commonVideoChecksPass (options: {
608 req: express.Request 580 req: express.Request
609 res: express.Response 581 res: express.Response
610 user: MUserAccountId 582 user: MUserAccountId
611 videoFileSize: number 583 videoFileSize: number
612 files: express.UploadFilesForCheck 584 files: express.UploadFilesForCheck
613}): Promise<boolean> { 585}): Promise<boolean> {
614 const { req, res, user, videoFileSize, files } = parameters 586 const { req, res, user } = options
615 587
616 if (areErrorsInScheduleUpdate(req, res)) return false 588 if (areErrorsInScheduleUpdate(req, res)) return false
617 589
618 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false 590 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
619 591
620 if (!isVideoFileMimeTypeValid(files)) { 592 if (!await commonVideoFileChecks(options)) return false
621 res.fail({
622 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
623 message: 'This file is not supported. Please, make sure it is of the following type: ' +
624 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
625 })
626 return false
627 }
628
629 if (!isVideoFileSizeValid(videoFileSize.toString())) {
630 res.fail({
631 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
632 message: 'This file is too large. It exceeds the maximum file size authorized.',
633 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
634 })
635 return false
636 }
637
638 if (await checkUserQuota(user, videoFileSize, res) === false) return false
639
640 return true
641}
642
643export async function isVideoAccepted (
644 req: express.Request,
645 res: express.Response,
646 videoFile: express.VideoUploadFile
647) {
648 // Check we accept this video
649 const acceptParameters = {
650 videoBody: req.body,
651 videoFile,
652 user: res.locals.oauth.token.User
653 }
654 const acceptedResult = await Hooks.wrapFun(
655 isLocalVideoAccepted,
656 acceptParameters,
657 'filter:api.video.upload.accept.result'
658 )
659
660 if (!acceptedResult || acceptedResult.accepted !== true) {
661 logger.info('Refused local video.', { acceptedResult, acceptParameters })
662 res.fail({
663 status: HttpStatusCode.FORBIDDEN_403,
664 message: acceptedResult.errorMessage || 'Refused local video'
665 })
666 return false
667 }
668 593
669 return true 594 return true
670} 595}
671
672async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
673 const duration = await getVideoStreamDuration(videoFile.path)
674
675 // FFmpeg may not be able to guess video duration
676 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
677 if (isNaN(duration)) videoFile.duration = 0
678 else videoFile.duration = duration
679}
diff --git a/server/models/video/formatter/video-activity-pub-format.ts b/server/models/video/formatter/video-activity-pub-format.ts
index c0d3d5f3e..a5b3e9ca6 100644
--- a/server/models/video/formatter/video-activity-pub-format.ts
+++ b/server/models/video/formatter/video-activity-pub-format.ts
@@ -76,6 +76,8 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
76 76
77 updated: video.updatedAt.toISOString(), 77 updated: video.updatedAt.toISOString(),
78 78
79 uploadDate: video.inputFileUpdatedAt?.toISOString(),
80
79 tag: buildTags(video), 81 tag: buildTags(video),
80 82
81 mediaType: 'text/markdown', 83 mediaType: 'text/markdown',
diff --git a/server/models/video/formatter/video-api-format.ts b/server/models/video/formatter/video-api-format.ts
index 1af51d132..7a58f5d3c 100644
--- a/server/models/video/formatter/video-api-format.ts
+++ b/server/models/video/formatter/video-api-format.ts
@@ -149,6 +149,7 @@ export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetail
149 commentsEnabled: video.commentsEnabled, 149 commentsEnabled: video.commentsEnabled,
150 downloadEnabled: video.downloadEnabled, 150 downloadEnabled: video.downloadEnabled,
151 waitTranscoding: video.waitTranscoding, 151 waitTranscoding: video.waitTranscoding,
152 inputFileUpdatedAt: video.inputFileUpdatedAt,
152 state: { 153 state: {
153 id: video.state, 154 id: video.state,
154 label: getStateLabel(video.state) 155 label: getStateLabel(video.state)
diff --git a/server/models/video/sql/video/shared/video-table-attributes.ts b/server/models/video/sql/video/shared/video-table-attributes.ts
index e0fa9d7c1..ef625c57b 100644
--- a/server/models/video/sql/video/shared/video-table-attributes.ts
+++ b/server/models/video/sql/video/shared/video-table-attributes.ts
@@ -263,6 +263,7 @@ export class VideoTableAttributes {
263 'state', 263 'state',
264 'publishedAt', 264 'publishedAt',
265 'originallyPublishedAt', 265 'originallyPublishedAt',
266 'inputFileUpdatedAt',
266 'channelId', 267 'channelId',
267 'createdAt', 268 'createdAt',
268 'updatedAt', 269 'updatedAt',
diff --git a/server/models/video/video-source.ts b/server/models/video/video-source.ts
index e306b160d..1b6868b85 100644
--- a/server/models/video/video-source.ts
+++ b/server/models/video/video-source.ts
@@ -1,27 +1,18 @@
1import { Op } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { 2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3 AllowNull, 3import { VideoSource } from '@shared/models/videos/video-source'
4 BelongsTo,
5 Column,
6 CreatedAt,
7 ForeignKey,
8 Model,
9 Table,
10 UpdatedAt
11} from 'sequelize-typescript'
12import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { getSort } from '../shared'
13import { VideoModel } from './video' 6import { VideoModel } from './video'
14 7
15@Table({ 8@Table({
16 tableName: 'videoSource', 9 tableName: 'videoSource',
17 indexes: [ 10 indexes: [
18 { 11 {
19 fields: [ 'videoId' ], 12 fields: [ 'videoId' ]
20 where: { 13 },
21 videoId: { 14 {
22 [Op.ne]: null 15 fields: [ { name: 'createdAt', order: 'DESC' } ]
23 }
24 }
25 } 16 }
26 ] 17 ]
27}) 18})
@@ -40,16 +31,26 @@ export class VideoSourceModel extends Model<Partial<AttributesOnly<VideoSourceMo
40 @Column 31 @Column
41 videoId: number 32 videoId: number
42 33
43 @BelongsTo(() => VideoModel) 34 @BelongsTo(() => VideoModel, {
35 foreignKey: {
36 allowNull: false
37 },
38 onDelete: 'cascade'
39 })
44 Video: VideoModel 40 Video: VideoModel
45 41
46 static loadByVideoId (videoId) { 42 static loadLatest (videoId: number, transaction?: Transaction) {
47 return VideoSourceModel.findOne({ where: { videoId } }) 43 return VideoSourceModel.findOne({
44 where: { videoId },
45 order: getSort('-createdAt'),
46 transaction
47 })
48 } 48 }
49 49
50 toFormattedJSON () { 50 toFormattedJSON (): VideoSource {
51 return { 51 return {
52 filename: this.filename 52 filename: this.filename,
53 createdAt: this.createdAt.toISOString()
53 } 54 }
54 } 55 }
55} 56}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 4c6297243..2fe701436 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -546,6 +546,12 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
546 @Column 546 @Column
547 state: VideoState 547 state: VideoState
548 548
549 // We already have the information in videoSource table for local videos, but we prefer to normalize it for performance
550 // And also to store the info from remote instances
551 @AllowNull(true)
552 @Column
553 inputFileUpdatedAt: Date
554
549 @CreatedAt 555 @CreatedAt
550 createdAt: Date 556 createdAt: Date
551 557
@@ -610,7 +616,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
610 @HasOne(() => VideoSourceModel, { 616 @HasOne(() => VideoSourceModel, {
611 foreignKey: { 617 foreignKey: {
612 name: 'videoId', 618 name: 'videoId',
613 allowNull: true 619 allowNull: false
614 }, 620 },
615 onDelete: 'CASCADE' 621 onDelete: 'CASCADE'
616 }) 622 })
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 80b616ccf..2f523d4ce 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -170,6 +170,11 @@ describe('Test config API validators', function () {
170 enabled: true 170 enabled: true
171 } 171 }
172 }, 172 },
173 videoFile: {
174 update: {
175 enabled: true
176 }
177 },
173 import: { 178 import: {
174 videos: { 179 videos: {
175 concurrency: 1, 180 concurrency: 1,
diff --git a/server/tests/api/check-params/video-source.ts b/server/tests/api/check-params/video-source.ts
index ca324bb9d..3c641ccd3 100644
--- a/server/tests/api/check-params/video-source.ts
+++ b/server/tests/api/check-params/video-source.ts
@@ -1,5 +1,12 @@
1import { HttpStatusCode } from '@shared/models' 1import { HttpStatusCode } from '@shared/models'
2import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' 2import {
3 cleanupTests,
4 createSingleServer,
5 PeerTubeServer,
6 setAccessTokensToServers,
7 setDefaultVideoChannel,
8 waitJobs
9} from '@shared/server-commands'
3 10
4describe('Test video sources API validator', function () { 11describe('Test video sources API validator', function () {
5 let server: PeerTubeServer = null 12 let server: PeerTubeServer = null
@@ -7,35 +14,138 @@ describe('Test video sources API validator', function () {
7 let userToken: string 14 let userToken: string
8 15
9 before(async function () { 16 before(async function () {
10 this.timeout(30000) 17 this.timeout(120000)
11 18
12 server = await createSingleServer(1) 19 server = await createSingleServer(1)
13 await setAccessTokensToServers([ server ]) 20 await setAccessTokensToServers([ server ])
21 await setDefaultVideoChannel([ server ])
14 22
15 const created = await server.videos.quickUpload({ name: 'video' }) 23 userToken = await server.users.generateUserAndToken('user1')
16 uuid = created.uuid
17
18 userToken = await server.users.generateUserAndToken('user')
19 }) 24 })
20 25
21 it('Should fail without a valid uuid', async function () { 26 describe('When getting latest source', function () {
22 await server.videos.getSource({ id: '4da6fde3-88f7-4d16-b119-108df563d0b0', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
23 })
24 27
25 it('Should receive 404 when passing a non existing video id', async function () { 28 before(async function () {
26 await server.videos.getSource({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 29 const created = await server.videos.quickUpload({ name: 'video' })
27 }) 30 uuid = created.uuid
31 })
28 32
29 it('Should not get the source as unauthenticated', async function () { 33 it('Should fail without a valid uuid', async function () {
30 await server.videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, token: null }) 34 await server.videos.getSource({ id: '4da6fde3-88f7-4d16-b119-108df563d0b0', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
31 }) 35 })
36
37 it('Should receive 404 when passing a non existing video id', async function () {
38 await server.videos.getSource({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
39 })
40
41 it('Should not get the source as unauthenticated', async function () {
42 await server.videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, token: null })
43 })
44
45 it('Should not get the source with another user', async function () {
46 await server.videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: userToken })
47 })
32 48
33 it('Should not get the source with another user', async function () { 49 it('Should succeed with the correct parameters get the source as another user', async function () {
34 await server.videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: userToken }) 50 await server.videos.getSource({ id: uuid })
51 })
35 }) 52 })
36 53
37 it('Should succeed with the correct parameters get the source as another user', async function () { 54 describe('When updating source video file', function () {
38 await server.videos.getSource({ id: uuid }) 55 let userAccessToken: string
56 let userId: number
57
58 let videoId: string
59 let userVideoId: string
60
61 before(async function () {
62 const res = await server.users.generate('user2')
63 userAccessToken = res.token
64 userId = res.userId
65
66 const { uuid } = await server.videos.quickUpload({ name: 'video' })
67 videoId = uuid
68
69 await waitJobs([ server ])
70 })
71
72 it('Should fail if not enabled on the instance', async function () {
73 await server.config.disableFileUpdate()
74
75 await server.videos.replaceSourceFile({ videoId, fixture: 'video_short.mp4', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
76 })
77
78 it('Should fail on an unknown video', async function () {
79 await server.config.enableFileUpdate()
80
81 await server.videos.replaceSourceFile({ videoId: 404, fixture: 'video_short.mp4', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
82 })
83
84 it('Should fail with an invalid video', async function () {
85 await server.config.enableLive({ allowReplay: false })
86
87 const { video } = await server.live.quickCreate({ saveReplay: false, permanentLive: true })
88 await server.videos.replaceSourceFile({
89 videoId: video.uuid,
90 fixture: 'video_short.mp4',
91 expectedStatus: HttpStatusCode.BAD_REQUEST_400
92 })
93 })
94
95 it('Should fail without token', async function () {
96 await server.videos.replaceSourceFile({
97 token: null,
98 videoId,
99 fixture: 'video_short.mp4',
100 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
101 })
102 })
103
104 it('Should fail with another user', async function () {
105 await server.videos.replaceSourceFile({
106 token: userAccessToken,
107 videoId,
108 fixture: 'video_short.mp4',
109 expectedStatus: HttpStatusCode.FORBIDDEN_403
110 })
111 })
112
113 it('Should fail with an incorrect input file', async function () {
114 await server.videos.replaceSourceFile({
115 fixture: 'video_short_fake.webm',
116 videoId,
117 expectedStatus: HttpStatusCode.UNPROCESSABLE_ENTITY_422
118 })
119
120 await server.videos.replaceSourceFile({
121 fixture: 'video_short.mkv',
122 videoId,
123 expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415
124 })
125 })
126
127 it('Should fail if quota is exceeded', async function () {
128 this.timeout(60000)
129
130 const { uuid } = await server.videos.quickUpload({ name: 'user video' })
131 userVideoId = uuid
132 await waitJobs([ server ])
133
134 await server.users.update({ userId, videoQuota: 1 })
135 await server.videos.replaceSourceFile({
136 token: userAccessToken,
137 videoId: uuid,
138 fixture: 'video_short.mp4',
139 expectedStatus: HttpStatusCode.FORBIDDEN_403
140 })
141 })
142
143 it('Should succeed with the correct params', async function () {
144 this.timeout(60000)
145
146 await server.users.update({ userId, videoQuota: 1000 * 1000 * 1000 })
147 await server.videos.replaceSourceFile({ videoId: userVideoId, fixture: 'video_short.mp4' })
148 })
39 }) 149 })
40 150
41 after(async function () { 151 after(async function () {
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 0e700eddb..a614d92d2 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -105,6 +105,8 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
105 expect(data.videoStudio.enabled).to.be.false 105 expect(data.videoStudio.enabled).to.be.false
106 expect(data.videoStudio.remoteRunners.enabled).to.be.false 106 expect(data.videoStudio.remoteRunners.enabled).to.be.false
107 107
108 expect(data.videoFile.update.enabled).to.be.false
109
108 expect(data.import.videos.concurrency).to.equal(2) 110 expect(data.import.videos.concurrency).to.equal(2)
109 expect(data.import.videos.http.enabled).to.be.true 111 expect(data.import.videos.http.enabled).to.be.true
110 expect(data.import.videos.torrent.enabled).to.be.true 112 expect(data.import.videos.torrent.enabled).to.be.true
@@ -216,6 +218,8 @@ function checkUpdatedConfig (data: CustomConfig) {
216 expect(data.videoStudio.enabled).to.be.true 218 expect(data.videoStudio.enabled).to.be.true
217 expect(data.videoStudio.remoteRunners.enabled).to.be.true 219 expect(data.videoStudio.remoteRunners.enabled).to.be.true
218 220
221 expect(data.videoFile.update.enabled).to.be.true
222
219 expect(data.import.videos.concurrency).to.equal(4) 223 expect(data.import.videos.concurrency).to.equal(4)
220 expect(data.import.videos.http.enabled).to.be.false 224 expect(data.import.videos.http.enabled).to.be.false
221 expect(data.import.videos.torrent.enabled).to.be.false 225 expect(data.import.videos.torrent.enabled).to.be.false
@@ -386,6 +390,11 @@ const newCustomConfig: CustomConfig = {
386 enabled: true 390 enabled: true
387 } 391 }
388 }, 392 },
393 videoFile: {
394 update: {
395 enabled: true
396 }
397 },
389 import: { 398 import: {
390 videos: { 399 videos: {
391 concurrency: 4, 400 concurrency: 4,
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts
index 9c79b3aa6..01d0c5852 100644
--- a/server/tests/api/videos/index.ts
+++ b/server/tests/api/videos/index.ts
@@ -13,11 +13,11 @@ import './video-imports'
13import './video-nsfw' 13import './video-nsfw'
14import './video-playlists' 14import './video-playlists'
15import './video-playlist-thumbnails' 15import './video-playlist-thumbnails'
16import './video-source'
16import './video-privacy' 17import './video-privacy'
17import './video-schedule-update' 18import './video-schedule-update'
18import './videos-common-filters' 19import './videos-common-filters'
19import './videos-history' 20import './videos-history'
20import './videos-overview' 21import './videos-overview'
21import './video-source'
22import './video-static-file-privacy' 22import './video-static-file-privacy'
23import './video-storyboard' 23import './video-storyboard'
diff --git a/server/tests/api/videos/resumable-upload.ts b/server/tests/api/videos/resumable-upload.ts
index 91eb61833..cac1201e9 100644
--- a/server/tests/api/videos/resumable-upload.ts
+++ b/server/tests/api/videos/resumable-upload.ts
@@ -11,6 +11,7 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ
11// Most classic resumable upload tests are done in other test suites 11// Most classic resumable upload tests are done in other test suites
12 12
13describe('Test resumable upload', function () { 13describe('Test resumable upload', function () {
14 const path = '/api/v1/videos/upload-resumable'
14 const defaultFixture = 'video_short.mp4' 15 const defaultFixture = 'video_short.mp4'
15 let server: PeerTubeServer 16 let server: PeerTubeServer
16 let rootId: number 17 let rootId: number
@@ -44,7 +45,7 @@ describe('Test resumable upload', function () {
44 45
45 const mimetype = 'video/mp4' 46 const mimetype = 'video/mp4'
46 47
47 const res = await server.videos.prepareResumableUpload({ token, attributes, size, mimetype, originalName, lastModified }) 48 const res = await server.videos.prepareResumableUpload({ path, token, attributes, size, mimetype, originalName, lastModified })
48 49
49 return res.header['location'].split('?')[1] 50 return res.header['location'].split('?')[1]
50 } 51 }
@@ -66,6 +67,7 @@ describe('Test resumable upload', function () {
66 67
67 return server.videos.sendResumableChunks({ 68 return server.videos.sendResumableChunks({
68 token, 69 token,
70 path,
69 pathUploadId, 71 pathUploadId,
70 videoFilePath: absoluteFilePath, 72 videoFilePath: absoluteFilePath,
71 size, 73 size,
@@ -125,7 +127,7 @@ describe('Test resumable upload', function () {
125 it('Should correctly delete files after an upload', async function () { 127 it('Should correctly delete files after an upload', async function () {
126 const uploadId = await prepareUpload() 128 const uploadId = await prepareUpload()
127 await sendChunks({ pathUploadId: uploadId }) 129 await sendChunks({ pathUploadId: uploadId })
128 await server.videos.endResumableUpload({ pathUploadId: uploadId }) 130 await server.videos.endResumableUpload({ path, pathUploadId: uploadId })
129 131
130 expect(await countResumableUploads()).to.equal(0) 132 expect(await countResumableUploads()).to.equal(0)
131 }) 133 })
@@ -251,7 +253,7 @@ describe('Test resumable upload', function () {
251 const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken }) 253 const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
252 254
253 await sendChunks({ pathUploadId: uploadId1 }) 255 await sendChunks({ pathUploadId: uploadId1 })
254 await server.videos.endResumableUpload({ pathUploadId: uploadId1 }) 256 await server.videos.endResumableUpload({ path, pathUploadId: uploadId1 })
255 257
256 const uploadId2 = await prepareUpload({ originalName, lastModified, token: server.accessToken }) 258 const uploadId2 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
257 expect(uploadId1).to.equal(uploadId2) 259 expect(uploadId1).to.equal(uploadId2)
diff --git a/server/tests/api/videos/video-source.ts b/server/tests/api/videos/video-source.ts
index 5ecf8316f..8669f342e 100644
--- a/server/tests/api/videos/video-source.ts
+++ b/server/tests/api/videos/video-source.ts
@@ -1,36 +1,447 @@
1import { expect } from 'chai' 1import { expect } from 'chai'
2import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' 2import { expectStartWith } from '@server/tests/shared'
3/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
4import { areMockObjectStorageTestsDisabled, getAllFiles } from '@shared/core-utils'
5import { HttpStatusCode } from '@shared/models'
6import {
7 cleanupTests,
8 createMultipleServers,
9 doubleFollow,
10 makeGetRequest,
11 makeRawRequest,
12 ObjectStorageCommand,
13 PeerTubeServer,
14 setAccessTokensToServers,
15 setDefaultAccountAvatar,
16 setDefaultVideoChannel,
17 waitJobs
18} from '@shared/server-commands'
3 19
4describe('Test video source', () => { 20describe('Test a video file replacement', function () {
5 let server: PeerTubeServer = null 21 let servers: PeerTubeServer[] = []
6 const fixture = 'video_short.webm' 22
23 let replaceDate: Date
24 let userToken: string
25 let uuid: string
7 26
8 before(async function () { 27 before(async function () {
9 this.timeout(30000) 28 this.timeout(50000)
29
30 servers = await createMultipleServers(2)
31
32 // Get the access tokens
33 await setAccessTokensToServers(servers)
34 await setDefaultVideoChannel(servers)
35 await setDefaultAccountAvatar(servers)
36
37 await servers[0].config.enableFileUpdate()
10 38
11 server = await createSingleServer(1) 39 userToken = await servers[0].users.generateUserAndToken('user1')
12 await setAccessTokensToServers([ server ]) 40
41 // Server 1 and server 2 follow each other
42 await doubleFollow(servers[0], servers[1])
13 }) 43 })
14 44
15 it('Should get the source filename with legacy upload', async function () { 45 describe('Getting latest video source', () => {
16 this.timeout(30000) 46 const fixture = 'video_short.webm'
47 const uuids: string[] = []
48
49 it('Should get the source filename with legacy upload', async function () {
50 this.timeout(30000)
51
52 const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'legacy' })
53 uuids.push(uuid)
17 54
18 const { uuid } = await server.videos.upload({ attributes: { name: 'my video', fixture }, mode: 'legacy' }) 55 const source = await servers[0].videos.getSource({ id: uuid })
56 expect(source.filename).to.equal(fixture)
57 })
19 58
20 const source = await server.videos.getSource({ id: uuid }) 59 it('Should get the source filename with resumable upload', async function () {
21 expect(source.filename).to.equal(fixture) 60 this.timeout(30000)
61
62 const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'resumable' })
63 uuids.push(uuid)
64
65 const source = await servers[0].videos.getSource({ id: uuid })
66 expect(source.filename).to.equal(fixture)
67 })
68
69 after(async function () {
70 this.timeout(60000)
71
72 for (const uuid of uuids) {
73 await servers[0].videos.remove({ id: uuid })
74 }
75
76 await waitJobs(servers)
77 })
22 }) 78 })
23 79
24 it('Should get the source filename with resumable upload', async function () { 80 describe('Updating video source', function () {
25 this.timeout(30000) 81
82 describe('Filesystem', function () {
83
84 it('Should replace a video file with transcoding disabled', async function () {
85 this.timeout(120000)
86
87 await servers[0].config.disableTranscoding()
88
89 const { uuid } = await servers[0].videos.quickUpload({ name: 'fs without transcoding', fixture: 'video_short_720p.mp4' })
90 await waitJobs(servers)
91
92 for (const server of servers) {
93 const video = await server.videos.get({ id: uuid })
94
95 const files = getAllFiles(video)
96 expect(files).to.have.lengthOf(1)
97 expect(files[0].resolution.id).to.equal(720)
98 }
99
100 await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' })
101 await waitJobs(servers)
102
103 for (const server of servers) {
104 const video = await server.videos.get({ id: uuid })
105
106 const files = getAllFiles(video)
107 expect(files).to.have.lengthOf(1)
108 expect(files[0].resolution.id).to.equal(360)
109 }
110 })
111
112 it('Should replace a video file with transcoding enabled', async function () {
113 this.timeout(120000)
114
115 const previousPaths: string[] = []
116
117 await servers[0].config.enableTranscoding(true, true, true)
118
119 const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'fs with transcoding', fixture: 'video_short_720p.mp4' })
120 uuid = videoUUID
121
122 await waitJobs(servers)
123
124 for (const server of servers) {
125 const video = await server.videos.get({ id: uuid })
126 expect(video.inputFileUpdatedAt).to.be.null
127
128 const files = getAllFiles(video)
129 expect(files).to.have.lengthOf(6 * 2)
130
131 // Grab old paths to ensure we'll regenerate
132
133 previousPaths.push(video.previewPath)
134 previousPaths.push(video.thumbnailPath)
135
136 for (const file of files) {
137 previousPaths.push(file.fileUrl)
138 previousPaths.push(file.torrentUrl)
139 previousPaths.push(file.metadataUrl)
140
141 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
142 previousPaths.push(JSON.stringify(metadata))
143 }
144
145 const { storyboards } = await server.storyboard.list({ id: uuid })
146 for (const s of storyboards) {
147 previousPaths.push(s.storyboardPath)
148 }
149 }
150
151 replaceDate = new Date()
152
153 await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' })
154 await waitJobs(servers)
155
156 for (const server of servers) {
157 const video = await server.videos.get({ id: uuid })
158
159 expect(video.inputFileUpdatedAt).to.not.be.null
160 expect(new Date(video.inputFileUpdatedAt)).to.be.above(replaceDate)
161
162 const files = getAllFiles(video)
163 expect(files).to.have.lengthOf(4 * 2)
164
165 expect(previousPaths).to.not.include(video.previewPath)
166 expect(previousPaths).to.not.include(video.thumbnailPath)
167
168 await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 })
169 await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
170
171 for (const file of files) {
172 expect(previousPaths).to.not.include(file.fileUrl)
173 expect(previousPaths).to.not.include(file.torrentUrl)
174 expect(previousPaths).to.not.include(file.metadataUrl)
175
176 await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
177 await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 })
178
179 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
180 expect(previousPaths).to.not.include(JSON.stringify(metadata))
181 }
182
183 const { storyboards } = await server.storyboard.list({ id: uuid })
184 for (const s of storyboards) {
185 expect(previousPaths).to.not.include(s.storyboardPath)
186
187 await makeGetRequest({ url: server.url, path: s.storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
188 }
189 }
190
191 await servers[0].config.enableMinimumTranscoding()
192 })
193
194 it('Should have cleaned up old files', async function () {
195 {
196 const count = await servers[0].servers.countFiles('storyboards')
197 expect(count).to.equal(2)
198 }
199
200 {
201 const count = await servers[0].servers.countFiles('web-videos')
202 expect(count).to.equal(5 + 1) // +1 for private directory
203 }
204
205 {
206 const count = await servers[0].servers.countFiles('streaming-playlists/hls')
207 expect(count).to.equal(1 + 1) // +1 for private directory
208 }
209
210 {
211 const count = await servers[0].servers.countFiles('torrents')
212 expect(count).to.equal(9)
213 }
214 })
215
216 it('Should have the correct source input', async function () {
217 const source = await servers[0].videos.getSource({ id: uuid })
218
219 expect(source.filename).to.equal('video_short_360p.mp4')
220 expect(new Date(source.createdAt)).to.be.above(replaceDate)
221 })
222
223 it('Should not have regenerated miniatures that were previously uploaded', async function () {
224 this.timeout(120000)
225
226 const { uuid } = await servers[0].videos.upload({
227 attributes: {
228 name: 'custom miniatures',
229 thumbnailfile: 'custom-thumbnail.jpg',
230 previewfile: 'custom-preview.jpg'
231 }
232 })
233
234 await waitJobs(servers)
235
236 const previousPaths: string[] = []
237
238 for (const server of servers) {
239 const video = await server.videos.get({ id: uuid })
240
241 previousPaths.push(video.previewPath)
242 previousPaths.push(video.thumbnailPath)
243
244 await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 })
245 await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
246 }
247
248 await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' })
249 await waitJobs(servers)
250
251 for (const server of servers) {
252 const video = await server.videos.get({ id: uuid })
253
254 expect(previousPaths).to.include(video.previewPath)
255 expect(previousPaths).to.include(video.thumbnailPath)
256
257 await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 })
258 await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
259 }
260 })
261 })
262
263 describe('Autoblacklist', function () {
264
265 function updateAutoBlacklist (enabled: boolean) {
266 return servers[0].config.updateExistingSubConfig({
267 newConfig: {
268 autoBlacklist: {
269 videos: {
270 ofUsers: {
271 enabled
272 }
273 }
274 }
275 }
276 })
277 }
278
279 async function expectBlacklist (uuid: string, value: boolean) {
280 const video = await servers[0].videos.getWithToken({ id: uuid })
281
282 expect(video.blacklisted).to.equal(value)
283 }
284
285 before(async function () {
286 await updateAutoBlacklist(true)
287 })
288
289 it('Should auto blacklist an unblacklisted video after file replacement', async function () {
290 this.timeout(120000)
291
292 const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' })
293 await waitJobs(servers)
294 await expectBlacklist(uuid, true)
295
296 await servers[0].blacklist.remove({ videoId: uuid })
297 await expectBlacklist(uuid, false)
298
299 await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short_360p.mp4' })
300 await waitJobs(servers)
301
302 await expectBlacklist(uuid, true)
303 })
304
305 it('Should auto blacklist an already blacklisted video after file replacement', async function () {
306 this.timeout(120000)
307
308 const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' })
309 await waitJobs(servers)
310 await expectBlacklist(uuid, true)
311
312 await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short_360p.mp4' })
313 await waitJobs(servers)
314
315 await expectBlacklist(uuid, true)
316 })
317
318 it('Should not auto blacklist if auto blacklist has been disabled between the upload and the replacement', async function () {
319 this.timeout(120000)
320
321 const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' })
322 await waitJobs(servers)
323 await expectBlacklist(uuid, true)
324
325 await servers[0].blacklist.remove({ videoId: uuid })
326 await expectBlacklist(uuid, false)
327
328 await updateAutoBlacklist(false)
329
330 await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short1.webm' })
331 await waitJobs(servers)
332
333 await expectBlacklist(uuid, false)
334 })
335 })
336
337 describe('With object storage enabled', function () {
338 if (areMockObjectStorageTestsDisabled()) return
339
340 const objectStorage = new ObjectStorageCommand()
341
342 before(async function () {
343 this.timeout(120000)
344
345 const configOverride = objectStorage.getDefaultMockConfig()
346 await objectStorage.prepareDefaultMockBuckets()
347
348 await servers[0].kill()
349 await servers[0].run(configOverride)
350 })
351
352 it('Should replace a video file with transcoding disabled', async function () {
353 this.timeout(120000)
354
355 await servers[0].config.disableTranscoding()
356
357 const { uuid } = await servers[0].videos.quickUpload({
358 name: 'object storage without transcoding',
359 fixture: 'video_short_720p.mp4'
360 })
361 await waitJobs(servers)
362
363 for (const server of servers) {
364 const video = await server.videos.get({ id: uuid })
365
366 const files = getAllFiles(video)
367 expect(files).to.have.lengthOf(1)
368 expect(files[0].resolution.id).to.equal(720)
369 expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl())
370 }
371
372 await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' })
373 await waitJobs(servers)
374
375 for (const server of servers) {
376 const video = await server.videos.get({ id: uuid })
377
378 const files = getAllFiles(video)
379 expect(files).to.have.lengthOf(1)
380 expect(files[0].resolution.id).to.equal(360)
381 expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl())
382 }
383 })
384
385 it('Should replace a video file with transcoding enabled', async function () {
386 this.timeout(120000)
387
388 const previousPaths: string[] = []
389
390 await servers[0].config.enableTranscoding(true, true, true)
391
392 const { uuid: videoUUID } = await servers[0].videos.quickUpload({
393 name: 'object storage with transcoding',
394 fixture: 'video_short_360p.mp4'
395 })
396 uuid = videoUUID
397
398 await waitJobs(servers)
399
400 for (const server of servers) {
401 const video = await server.videos.get({ id: uuid })
402
403 const files = getAllFiles(video)
404 expect(files).to.have.lengthOf(4 * 2)
405
406 for (const file of files) {
407 previousPaths.push(file.fileUrl)
408 }
409
410 for (const file of video.files) {
411 expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl())
412 }
413
414 for (const file of video.streamingPlaylists[0].files) {
415 expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl())
416 }
417 }
418
419 await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_240p.mp4' })
420 await waitJobs(servers)
421
422 for (const server of servers) {
423 const video = await server.videos.get({ id: uuid })
424
425 const files = getAllFiles(video)
426 expect(files).to.have.lengthOf(3 * 2)
427
428 for (const file of files) {
429 expect(previousPaths).to.not.include(file.fileUrl)
430 }
26 431
27 const { uuid } = await server.videos.upload({ attributes: { name: 'my video', fixture }, mode: 'resumable' }) 432 for (const file of video.files) {
433 expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl())
434 }
28 435
29 const source = await server.videos.getSource({ id: uuid }) 436 for (const file of video.streamingPlaylists[0].files) {
30 expect(source.filename).to.equal(fixture) 437 expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl())
438 }
439 }
440 })
441 })
31 }) 442 })
32 443
33 after(async function () { 444 after(async function () {
34 await cleanupTests([ server ]) 445 await cleanupTests(servers)
35 }) 446 })
36}) 447})
diff --git a/server/tests/cli/prune-storage.ts b/server/tests/cli/prune-storage.ts
index 00f63570f..72a4b1332 100644
--- a/server/tests/cli/prune-storage.ts
+++ b/server/tests/cli/prune-storage.ts
@@ -19,12 +19,6 @@ import {
19 waitJobs 19 waitJobs
20} from '@shared/server-commands' 20} from '@shared/server-commands'
21 21
22async function countFiles (server: PeerTubeServer, directory: string) {
23 const files = await readdir(server.servers.buildDirectory(directory))
24
25 return files.length
26}
27
28async function assertNotExists (server: PeerTubeServer, directory: string, substring: string) { 22async function assertNotExists (server: PeerTubeServer, directory: string, substring: string) {
29 const files = await readdir(server.servers.buildDirectory(directory)) 23 const files = await readdir(server.servers.buildDirectory(directory))
30 24
@@ -35,28 +29,28 @@ async function assertNotExists (server: PeerTubeServer, directory: string, subst
35 29
36async function assertCountAreOkay (servers: PeerTubeServer[]) { 30async function assertCountAreOkay (servers: PeerTubeServer[]) {
37 for (const server of servers) { 31 for (const server of servers) {
38 const videosCount = await countFiles(server, 'web-videos') 32 const videosCount = await server.servers.countFiles('web-videos')
39 expect(videosCount).to.equal(9) // 2 videos with 4 resolutions + private directory 33 expect(videosCount).to.equal(9) // 2 videos with 4 resolutions + private directory
40 34
41 const privateVideosCount = await countFiles(server, 'web-videos/private') 35 const privateVideosCount = await server.servers.countFiles('web-videos/private')
42 expect(privateVideosCount).to.equal(4) 36 expect(privateVideosCount).to.equal(4)
43 37
44 const torrentsCount = await countFiles(server, 'torrents') 38 const torrentsCount = await server.servers.countFiles('torrents')
45 expect(torrentsCount).to.equal(24) 39 expect(torrentsCount).to.equal(24)
46 40
47 const previewsCount = await countFiles(server, 'previews') 41 const previewsCount = await server.servers.countFiles('previews')
48 expect(previewsCount).to.equal(3) 42 expect(previewsCount).to.equal(3)
49 43
50 const thumbnailsCount = await countFiles(server, 'thumbnails') 44 const thumbnailsCount = await server.servers.countFiles('thumbnails')
51 expect(thumbnailsCount).to.equal(5) // 3 local videos, 1 local playlist, 2 remotes videos (lazy downloaded) and 1 remote playlist 45 expect(thumbnailsCount).to.equal(5) // 3 local videos, 1 local playlist, 2 remotes videos (lazy downloaded) and 1 remote playlist
52 46
53 const avatarsCount = await countFiles(server, 'avatars') 47 const avatarsCount = await server.servers.countFiles('avatars')
54 expect(avatarsCount).to.equal(4) 48 expect(avatarsCount).to.equal(4)
55 49
56 const hlsRootCount = await countFiles(server, join('streaming-playlists', 'hls')) 50 const hlsRootCount = await server.servers.countFiles(join('streaming-playlists', 'hls'))
57 expect(hlsRootCount).to.equal(3) // 2 videos + private directory 51 expect(hlsRootCount).to.equal(3) // 2 videos + private directory
58 52
59 const hlsPrivateRootCount = await countFiles(server, join('streaming-playlists', 'hls', 'private')) 53 const hlsPrivateRootCount = await server.servers.countFiles(join('streaming-playlists', 'hls', 'private'))
60 expect(hlsPrivateRootCount).to.equal(1) 54 expect(hlsPrivateRootCount).to.equal(1)
61 } 55 }
62} 56}
diff --git a/server/tests/shared/videos.ts b/server/tests/shared/videos.ts
index e09bd60b5..3f59c329f 100644
--- a/server/tests/shared/videos.ts
+++ b/server/tests/shared/videos.ts
@@ -277,7 +277,7 @@ function checkUploadVideoParam (
277) { 277) {
278 return mode === 'legacy' 278 return mode === 'legacy'
279 ? server.videos.buildLegacyUpload({ token, attributes, expectedStatus }) 279 ? server.videos.buildLegacyUpload({ token, attributes, expectedStatus })
280 : server.videos.buildResumeUpload({ token, attributes, expectedStatus }) 280 : server.videos.buildResumeUpload({ token, attributes, expectedStatus, path: '/api/v1/videos/upload-resumable' })
281} 281}
282 282
283// serverNumber starts from 1 283// serverNumber starts from 1
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 9c1be9bd1..4729c4534 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -86,13 +86,15 @@ declare module 'express' {
86 // Our custom UploadXFile object using our custom metadata 86 // Our custom UploadXFile object using our custom metadata
87 export type CustomUploadXFile <T extends Metadata> = UploadXFile & { metadata: T } 87 export type CustomUploadXFile <T extends Metadata> = UploadXFile & { metadata: T }
88 88
89 export type EnhancedUploadXFile = CustomUploadXFile<UploadXFileMetadata> & { 89 export type EnhancedUploadXFile = CustomUploadXFile<Metadata> & {
90 duration: number 90 duration: number
91 path: string 91 path: string
92 filename: string 92 filename: string
93 originalname: string 93 originalname: string
94 } 94 }
95 95
96 export type UploadNewVideoUploadXFile = EnhancedUploadXFile & CustomUploadXFile<UploadXFileMetadata>
97
96 // Extends Response with added functions and potential variables passed by middlewares 98 // Extends Response with added functions and potential variables passed by middlewares
97 interface Response { 99 interface Response {
98 fail: (options: { 100 fail: (options: {
@@ -139,7 +141,8 @@ declare module 'express' {
139 141
140 videoFile?: MVideoFile 142 videoFile?: MVideoFile
141 143
142 videoFileResumable?: EnhancedUploadXFile 144 uploadVideoFileResumable?: UploadNewVideoUploadXFile
145 updateVideoFileResumable?: EnhancedUploadXFile
143 146
144 videoImport?: MVideoImportDefault 147 videoImport?: MVideoImportDefault
145 148