aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-07-19 16:02:49 +0200
committerChocobozzz <me@florianbigard.com>2023-07-21 17:38:13 +0200
commit12dc3a942a13c7f1489822dae052da197ef15905 (patch)
tree7b87b6be692af0b62ebac17e720c80244fd8a7ec
parentc6867725fb8e3dfbc2018a37ed5a963103587cb6 (diff)
downloadPeerTube-12dc3a942a13c7f1489822dae052da197ef15905.tar.gz
PeerTube-12dc3a942a13c7f1489822dae052da197ef15905.tar.zst
PeerTube-12dc3a942a13c7f1489822dae052da197ef15905.zip
Implement replace file in server side
-rw-r--r--config/default.yaml5
-rw-r--r--config/production.yaml.example5
-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
-rw-r--r--shared/models/activitypub/objects/video-object.ts2
-rw-r--r--shared/models/plugins/server/server-hook.model.ts4
-rw-r--r--shared/models/server/custom-config.model.ts6
-rw-r--r--shared/models/server/server-config.model.ts6
-rw-r--r--shared/models/videos/video-source.ts1
-rw-r--r--shared/models/videos/video.model.ts2
-rw-r--r--shared/server-commands/server/config-command.ts27
-rw-r--r--shared/server-commands/server/servers-command.ts8
-rw-r--r--shared/server-commands/videos/videos-command.ts71
-rw-r--r--support/doc/api/openapi.yaml276
55 files changed, 1546 insertions, 324 deletions
diff --git a/config/default.yaml b/config/default.yaml
index e590ab300..10d3f79e7 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -595,6 +595,11 @@ video_studio:
595 remote_runners: 595 remote_runners:
596 enabled: false 596 enabled: false
597 597
598video_file:
599 update:
600 # Add ability for users to replace the video file of an existing video
601 enabled: false
602
598import: 603import:
599 # Add ability for your users to import remote videos (from YouTube, torrent...) 604 # Add ability for your users to import remote videos (from YouTube, torrent...)
600 videos: 605 videos:
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 884300ddb..a829b46f9 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -605,6 +605,11 @@ video_studio:
605 remote_runners: 605 remote_runners:
606 enabled: false 606 enabled: false
607 607
608video_file:
609 update:
610 # Add ability for users to replace the video file of an existing video
611 enabled: false
612
608import: 613import:
609 # Add ability for your users to import remote videos (from YouTube, torrent...) 614 # Add ability for your users to import remote videos (from YouTube, torrent...)
610 videos: 615 videos:
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
diff --git a/shared/models/activitypub/objects/video-object.ts b/shared/models/activitypub/objects/video-object.ts
index a252a2df0..409504f0f 100644
--- a/shared/models/activitypub/objects/video-object.ts
+++ b/shared/models/activitypub/objects/video-object.ts
@@ -31,9 +31,11 @@ export interface VideoObject {
31 downloadEnabled: boolean 31 downloadEnabled: boolean
32 waitTranscoding: boolean 32 waitTranscoding: boolean
33 state: VideoState 33 state: VideoState
34
34 published: string 35 published: string
35 originallyPublishedAt: string 36 originallyPublishedAt: string
36 updated: string 37 updated: string
38 uploadDate: string
37 39
38 mediaType: 'text/markdown' 40 mediaType: 'text/markdown'
39 content: string 41 content: string
diff --git a/shared/models/plugins/server/server-hook.model.ts b/shared/models/plugins/server/server-hook.model.ts
index 0ec62222d..cf387ffd7 100644
--- a/shared/models/plugins/server/server-hook.model.ts
+++ b/shared/models/plugins/server/server-hook.model.ts
@@ -64,6 +64,7 @@ export const serverFilterHookObject = {
64 'filter:api.video.pre-import-torrent.accept.result': true, 64 'filter:api.video.pre-import-torrent.accept.result': true,
65 'filter:api.video.post-import-url.accept.result': true, 65 'filter:api.video.post-import-url.accept.result': true,
66 'filter:api.video.post-import-torrent.accept.result': true, 66 'filter:api.video.post-import-torrent.accept.result': true,
67 'filter:api.video.update-file.accept.result': true,
67 // Filter the result of the accept comment (thread or reply) functions 68 // Filter the result of the accept comment (thread or reply) functions
68 // If the functions return false then the user cannot post its comment 69 // If the functions return false then the user cannot post its comment
69 'filter:api.video-thread.create.accept.result': true, 70 'filter:api.video-thread.create.accept.result': true,
@@ -155,6 +156,9 @@ export const serverActionHookObject = {
155 // Fired when a local video is viewed 156 // Fired when a local video is viewed
156 'action:api.video.viewed': true, 157 'action:api.video.viewed': true,
157 158
159 // Fired when a local video file has been replaced by a new one
160 'action:api.video.file-updated': true,
161
158 // Fired when a video channel is created 162 // Fired when a video channel is created
159 'action:api.video-channel.created': true, 163 'action:api.video-channel.created': true,
160 // Fired when a video channel is updated 164 // Fired when a video channel is updated
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index 9aa66f2b8..0dbb46fa8 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -175,6 +175,12 @@ export interface CustomConfig {
175 } 175 }
176 } 176 }
177 177
178 videoFile: {
179 update: {
180 enabled: boolean
181 }
182 }
183
178 import: { 184 import: {
179 videos: { 185 videos: {
180 concurrency: number 186 concurrency: number
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index 288cf84cd..3f61e93b5 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -192,6 +192,12 @@ export interface ServerConfig {
192 } 192 }
193 } 193 }
194 194
195 videoFile: {
196 update: {
197 enabled: boolean
198 }
199 }
200
195 import: { 201 import: {
196 videos: { 202 videos: {
197 http: { 203 http: {
diff --git a/shared/models/videos/video-source.ts b/shared/models/videos/video-source.ts
index 57e54fc7f..bf4ad2453 100644
--- a/shared/models/videos/video-source.ts
+++ b/shared/models/videos/video-source.ts
@@ -1,3 +1,4 @@
1export interface VideoSource { 1export interface VideoSource {
2 filename: string 2 filename: string
3 createdAt: string | Date
3} 4}
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts
index 9004efb35..7e5930067 100644
--- a/shared/models/videos/video.model.ts
+++ b/shared/models/videos/video.model.ts
@@ -94,4 +94,6 @@ export interface VideoDetails extends Video {
94 94
95 files: VideoFile[] 95 files: VideoFile[]
96 streamingPlaylists: VideoStreamingPlaylist[] 96 streamingPlaylists: VideoStreamingPlaylist[]
97
98 inputFileUpdatedAt: string | Date
97} 99}
diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts
index 7f1e9d977..3521b2d69 100644
--- a/shared/server-commands/server/config-command.ts
+++ b/shared/server-commands/server/config-command.ts
@@ -74,6 +74,28 @@ export class ConfigCommand extends AbstractCommand {
74 74
75 // --------------------------------------------------------------------------- 75 // ---------------------------------------------------------------------------
76 76
77 disableFileUpdate () {
78 return this.setFileUpdateEnabled(false)
79 }
80
81 enableFileUpdate () {
82 return this.setFileUpdateEnabled(true)
83 }
84
85 private setFileUpdateEnabled (enabled: boolean) {
86 return this.updateExistingSubConfig({
87 newConfig: {
88 videoFile: {
89 update: {
90 enabled
91 }
92 }
93 }
94 })
95 }
96
97 // ---------------------------------------------------------------------------
98
77 enableChannelSync () { 99 enableChannelSync () {
78 return this.setChannelSyncEnabled(true) 100 return this.setChannelSyncEnabled(true)
79 } 101 }
@@ -466,6 +488,11 @@ export class ConfigCommand extends AbstractCommand {
466 enabled: false 488 enabled: false
467 } 489 }
468 }, 490 },
491 videoFile: {
492 update: {
493 enabled: false
494 }
495 },
469 import: { 496 import: {
470 videos: { 497 videos: {
471 concurrency: 3, 498 concurrency: 3,
diff --git a/shared/server-commands/server/servers-command.ts b/shared/server-commands/server/servers-command.ts
index c91c2b008..54e586a18 100644
--- a/shared/server-commands/server/servers-command.ts
+++ b/shared/server-commands/server/servers-command.ts
@@ -1,5 +1,5 @@
1import { exec } from 'child_process' 1import { exec } from 'child_process'
2import { copy, ensureDir, readFile, remove } from 'fs-extra' 2import { copy, ensureDir, readFile, readdir, remove } from 'fs-extra'
3import { basename, join } from 'path' 3import { basename, join } from 'path'
4import { isGithubCI, root, wait } from '@shared/core-utils' 4import { isGithubCI, root, wait } from '@shared/core-utils'
5import { getFileSize } from '@shared/extra-utils' 5import { getFileSize } from '@shared/extra-utils'
@@ -77,6 +77,12 @@ export class ServersCommand extends AbstractCommand {
77 return join(root(), 'test' + this.server.internalServerNumber, directory) 77 return join(root(), 'test' + this.server.internalServerNumber, directory)
78 } 78 }
79 79
80 async countFiles (directory: string) {
81 const files = await readdir(this.buildDirectory(directory))
82
83 return files.length
84 }
85
80 buildWebVideoFilePath (fileUrl: string) { 86 buildWebVideoFilePath (fileUrl: string) {
81 return this.buildDirectory(join('web-videos', basename(fileUrl))) 87 return this.buildDirectory(join('web-videos', basename(fileUrl)))
82 } 88 }
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts
index 9602fa7da..6c38fa7ef 100644
--- a/shared/server-commands/videos/videos-command.ts
+++ b/shared/server-commands/videos/videos-command.ts
@@ -32,6 +32,7 @@ export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile
32} 32}
33 33
34export class VideosCommand extends AbstractCommand { 34export class VideosCommand extends AbstractCommand {
35
35 getCategories (options: OverrideCommandOptions = {}) { 36 getCategories (options: OverrideCommandOptions = {}) {
36 const path = '/api/v1/videos/categories' 37 const path = '/api/v1/videos/categories'
37 38
@@ -424,7 +425,7 @@ export class VideosCommand extends AbstractCommand {
424 425
425 const created = mode === 'legacy' 426 const created = mode === 'legacy'
426 ? await this.buildLegacyUpload({ ...options, attributes }) 427 ? await this.buildLegacyUpload({ ...options, attributes })
427 : await this.buildResumeUpload({ ...options, attributes }) 428 : await this.buildResumeUpload({ ...options, path: '/api/v1/videos/upload-resumable', attributes })
428 429
429 // Wait torrent generation 430 // Wait torrent generation
430 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 }) 431 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
@@ -458,9 +459,10 @@ export class VideosCommand extends AbstractCommand {
458 } 459 }
459 460
460 async buildResumeUpload (options: OverrideCommandOptions & { 461 async buildResumeUpload (options: OverrideCommandOptions & {
461 attributes: VideoEdit 462 path: string
463 attributes: { fixture?: string } & { [id: string]: any }
462 }): Promise<VideoCreateResult> { 464 }): Promise<VideoCreateResult> {
463 const { attributes, expectedStatus } = options 465 const { path, attributes, expectedStatus } = options
464 466
465 let size = 0 467 let size = 0
466 let videoFilePath: string 468 let videoFilePath: string
@@ -478,7 +480,15 @@ export class VideosCommand extends AbstractCommand {
478 } 480 }
479 481
480 // Do not check status automatically, we'll check it manually 482 // Do not check status automatically, we'll check it manually
481 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype }) 483 const initializeSessionRes = await this.prepareResumableUpload({
484 ...options,
485
486 path,
487 expectedStatus: null,
488 attributes,
489 size,
490 mimetype
491 })
482 const initStatus = initializeSessionRes.status 492 const initStatus = initializeSessionRes.status
483 493
484 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) { 494 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
@@ -487,10 +497,23 @@ export class VideosCommand extends AbstractCommand {
487 497
488 const pathUploadId = locationHeader.split('?')[1] 498 const pathUploadId = locationHeader.split('?')[1]
489 499
490 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size }) 500 const result = await this.sendResumableChunks({
501 ...options,
502
503 path,
504 pathUploadId,
505 videoFilePath,
506 size
507 })
491 508
492 if (result.statusCode === HttpStatusCode.OK_200) { 509 if (result.statusCode === HttpStatusCode.OK_200) {
493 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId }) 510 await this.endResumableUpload({
511 ...options,
512
513 expectedStatus: HttpStatusCode.NO_CONTENT_204,
514 path,
515 pathUploadId
516 })
494 } 517 }
495 518
496 return result.body?.video || result.body as any 519 return result.body?.video || result.body as any
@@ -506,18 +529,19 @@ export class VideosCommand extends AbstractCommand {
506 } 529 }
507 530
508 async prepareResumableUpload (options: OverrideCommandOptions & { 531 async prepareResumableUpload (options: OverrideCommandOptions & {
509 attributes: VideoEdit 532 path: string
533 attributes: { fixture?: string } & { [id: string]: any }
510 size: number 534 size: number
511 mimetype: string 535 mimetype: string
512 536
513 originalName?: string 537 originalName?: string
514 lastModified?: number 538 lastModified?: number
515 }) { 539 }) {
516 const { attributes, originalName, lastModified, size, mimetype } = options 540 const { path, attributes, originalName, lastModified, size, mimetype } = options
517 541
518 const path = '/api/v1/videos/upload-resumable' 542 const attaches = this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ]))
519 543
520 return this.postUploadRequest({ 544 const uploadOptions = {
521 ...options, 545 ...options,
522 546
523 path, 547 path,
@@ -538,11 +562,16 @@ export class VideosCommand extends AbstractCommand {
538 implicitToken: true, 562 implicitToken: true,
539 563
540 defaultExpectedStatus: null 564 defaultExpectedStatus: null
541 }) 565 }
566
567 if (Object.keys(attaches).length === 0) return this.postBodyRequest(uploadOptions)
568
569 return this.postUploadRequest(uploadOptions)
542 } 570 }
543 571
544 sendResumableChunks (options: OverrideCommandOptions & { 572 sendResumableChunks (options: OverrideCommandOptions & {
545 pathUploadId: string 573 pathUploadId: string
574 path: string
546 videoFilePath: string 575 videoFilePath: string
547 size: number 576 size: number
548 contentLength?: number 577 contentLength?: number
@@ -550,6 +579,7 @@ export class VideosCommand extends AbstractCommand {
550 digestBuilder?: (chunk: any) => string 579 digestBuilder?: (chunk: any) => string
551 }) { 580 }) {
552 const { 581 const {
582 path,
553 pathUploadId, 583 pathUploadId,
554 videoFilePath, 584 videoFilePath,
555 size, 585 size,
@@ -559,7 +589,6 @@ export class VideosCommand extends AbstractCommand {
559 expectedStatus = HttpStatusCode.OK_200 589 expectedStatus = HttpStatusCode.OK_200
560 } = options 590 } = options
561 591
562 const path = '/api/v1/videos/upload-resumable'
563 let start = 0 592 let start = 0
564 593
565 const token = this.buildCommonRequestToken({ ...options, implicitToken: true }) 594 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
@@ -610,12 +639,13 @@ export class VideosCommand extends AbstractCommand {
610 } 639 }
611 640
612 endResumableUpload (options: OverrideCommandOptions & { 641 endResumableUpload (options: OverrideCommandOptions & {
642 path: string
613 pathUploadId: string 643 pathUploadId: string
614 }) { 644 }) {
615 return this.deleteRequest({ 645 return this.deleteRequest({
616 ...options, 646 ...options,
617 647
618 path: '/api/v1/videos/upload-resumable', 648 path: options.path,
619 rawQuery: options.pathUploadId, 649 rawQuery: options.pathUploadId,
620 implicitToken: true, 650 implicitToken: true,
621 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 651 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
@@ -657,6 +687,21 @@ export class VideosCommand extends AbstractCommand {
657 687
658 // --------------------------------------------------------------------------- 688 // ---------------------------------------------------------------------------
659 689
690 replaceSourceFile (options: OverrideCommandOptions & {
691 videoId: number | string
692 fixture: string
693 }) {
694 return this.buildResumeUpload({
695 ...options,
696
697 path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable',
698 attributes: { fixture: options.fixture },
699 expectedStatus: HttpStatusCode.NO_CONTENT_204
700 })
701 }
702
703 // ---------------------------------------------------------------------------
704
660 removeHLSPlaylist (options: OverrideCommandOptions & { 705 removeHLSPlaylist (options: OverrideCommandOptions & {
661 videoId: number | string 706 videoId: number | string
662 }) { 707 }) {
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 90aaebd26..654bd7461 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -2641,22 +2641,6 @@ paths:
2641 example: | 2641 example: |
2642 **[Want to help to translate this video?](https://weblate.framasoft.org/projects/what-is-peertube-video/)**\r\n\r\n**Take back the control of your videos! [#JoinPeertube](https://joinpeertube.org)** 2642 **[Want to help to translate this video?](https://weblate.framasoft.org/projects/what-is-peertube-video/)**\r\n\r\n**Take back the control of your videos! [#JoinPeertube](https://joinpeertube.org)**
2643 2643
2644 '/api/v1/videos/{id}/source':
2645 post:
2646 summary: Get video source file metadata
2647 operationId: getVideoSource
2648 tags:
2649 - Video
2650 parameters:
2651 - $ref: '#/components/parameters/idOrUUID'
2652 responses:
2653 '200':
2654 description: successful operation
2655 content:
2656 application/json:
2657 schema:
2658 $ref: '#/components/schemas/VideoSource'
2659
2660 '/api/v1/videos/{id}/views': 2644 '/api/v1/videos/{id}/views':
2661 post: 2645 post:
2662 summary: Notify user is watching a video 2646 summary: Notify user is watching a video
@@ -2871,21 +2855,8 @@ paths:
2871 - Video 2855 - Video
2872 - Video Upload 2856 - Video Upload
2873 parameters: 2857 parameters:
2874 - name: X-Upload-Content-Length 2858 - $ref: '#/components/parameters/resumableUploadInitContentLengthHeader'
2875 in: header 2859 - $ref: '#/components/parameters/resumableUploadInitContentTypeHeader'
2876 schema:
2877 type: number
2878 example: 2469036
2879 required: true
2880 description: Number of bytes that will be uploaded in subsequent requests. Set this value to the size of the file you are uploading.
2881 - name: X-Upload-Content-Type
2882 in: header
2883 schema:
2884 type: string
2885 format: mimetype
2886 example: video/mp4
2887 required: true
2888 description: MIME type of the file that you are uploading. Depending on your instance settings, acceptable values might vary.
2889 requestBody: 2860 requestBody:
2890 content: 2861 content:
2891 application/json: 2862 application/json:
@@ -2924,36 +2895,9 @@ paths:
2924 - Video 2895 - Video
2925 - Video Upload 2896 - Video Upload
2926 parameters: 2897 parameters:
2927 - name: upload_id 2898 - $ref: '#/components/parameters/resumableUploadId'
2928 in: query 2899 - $ref: '#/components/parameters/resumableUploadChunkContentRangeHeader'
2929 required: true 2900 - $ref: '#/components/parameters/resumableUploadChunkContentLengthHeader'
2930 description: |
2931 Created session id to proceed with. If you didn't send chunks in the last hour, it is
2932 not valid anymore and you need to initialize a new upload.
2933 schema:
2934 type: string
2935 - name: Content-Range
2936 in: header
2937 schema:
2938 type: string
2939 example: bytes 0-262143/2469036
2940 required: true
2941 description: |
2942 Specifies the bytes in the file that the request is uploading.
2943
2944 For example, a value of `bytes 0-262143/1000000` shows that the request is sending the first
2945 262144 bytes (256 x 1024) in a 2,469,036 byte file.
2946 - name: Content-Length
2947 in: header
2948 schema:
2949 type: number
2950 example: 262144
2951 required: true
2952 description: |
2953 Size of the chunk that the request is sending.
2954
2955 Remember that larger chunks are more efficient. PeerTube's web client uses chunks varying from
2956 1048576 bytes (~1MB) and increases or reduces size depending on connection health.
2957 requestBody: 2901 requestBody:
2958 content: 2902 content:
2959 application/octet-stream: 2903 application/octet-stream:
@@ -3009,14 +2953,7 @@ paths:
3009 - Video 2953 - Video
3010 - Video Upload 2954 - Video Upload
3011 parameters: 2955 parameters:
3012 - name: upload_id 2956 - $ref: '#/components/parameters/resumableUploadId'
3013 in: query
3014 required: true
3015 description: |
3016 Created session id to proceed with. If you didn't send chunks in the last 12 hours, it is
3017 not valid anymore and the upload session has already been deleted with its data ;-)
3018 schema:
3019 type: string
3020 - name: Content-Length 2957 - name: Content-Length
3021 in: header 2958 in: header
3022 required: true 2959 required: true
@@ -3286,6 +3223,140 @@ paths:
3286 schema: 3223 schema:
3287 $ref: '#/components/schemas/LiveVideoSessionResponse' 3224 $ref: '#/components/schemas/LiveVideoSessionResponse'
3288 3225
3226 '/api/v1/videos/{id}/source':
3227 get:
3228 summary: Get video source file metadata
3229 operationId: getVideoSource
3230 tags:
3231 - Video
3232 parameters:
3233 - $ref: '#/components/parameters/idOrUUID'
3234 responses:
3235 '200':
3236 description: successful operation
3237 content:
3238 application/json:
3239 schema:
3240 $ref: '#/components/schemas/VideoSource'
3241
3242 '/api/v1/videos/{id}/source/replace-resumable':
3243 post:
3244 summary: Initialize the resumable replacement of a video
3245 description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the replacement of a video
3246 operationId: replaceVideoSourceResumableInit
3247 security:
3248 - OAuth2: []
3249 tags:
3250 - Video
3251 - Video Upload
3252 parameters:
3253 - $ref: '#/components/parameters/resumableUploadInitContentLengthHeader'
3254 - $ref: '#/components/parameters/resumableUploadInitContentTypeHeader'
3255 requestBody:
3256 content:
3257 application/json:
3258 schema:
3259 $ref: '#/components/schemas/VideoReplaceSourceRequestResumable'
3260 responses:
3261 '200':
3262 description: file already exists, send a [`resume`](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) request instead
3263 '201':
3264 description: created
3265 headers:
3266 Location:
3267 schema:
3268 type: string
3269 format: url
3270 Content-Length:
3271 schema:
3272 type: number
3273 example: 0
3274 '413':
3275 x-summary: video file too large, due to quota, absolute max file size or concurrent partial upload limit
3276 description: |
3277 Disambiguate via `type`:
3278 - `max_file_size_reached` for the absolute file size limit
3279 - `quota_reached` for quota limits whether daily or global
3280 '415':
3281 description: video type unsupported
3282 put:
3283 summary: Send chunk for the resumable replacement of a video
3284 description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to continue, pause or resume the replacement of a video
3285 operationId: replaceVideoSourceResumable
3286 security:
3287 - OAuth2: []
3288 tags:
3289 - Video
3290 - Video Upload
3291 parameters:
3292 - $ref: '#/components/parameters/resumableUploadId'
3293 - $ref: '#/components/parameters/resumableUploadChunkContentRangeHeader'
3294 - $ref: '#/components/parameters/resumableUploadChunkContentLengthHeader'
3295 requestBody:
3296 content:
3297 application/octet-stream:
3298 schema:
3299 type: string
3300 format: binary
3301 responses:
3302 '204':
3303 description: 'last chunk received: successful operation'
3304 '308':
3305 description: resume incomplete
3306 headers:
3307 Range:
3308 schema:
3309 type: string
3310 example: bytes=0-262143
3311 Content-Length:
3312 schema:
3313 type: number
3314 example: 0
3315 '403':
3316 description: video didn't pass file replacement filter
3317 '404':
3318 description: replace upload not found
3319 '409':
3320 description: chunk doesn't match range
3321 '422':
3322 description: video unreadable
3323 '429':
3324 description: too many concurrent requests
3325 '503':
3326 description: upload is already being processed
3327 headers:
3328 'Retry-After':
3329 schema:
3330 type: number
3331 example: 300
3332 delete:
3333 summary: Cancel the resumable replacement of a video
3334 description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to cancel the replacement of a video
3335 operationId: replaceVideoSourceResumableCancel
3336 security:
3337 - OAuth2: []
3338 tags:
3339 - Video
3340 - Video Upload
3341 parameters:
3342 - $ref: '#/components/parameters/resumableUploadId'
3343 - name: Content-Length
3344 in: header
3345 required: true
3346 schema:
3347 type: number
3348 example: 0
3349 responses:
3350 '204':
3351 description: source file replacement cancelled
3352 headers:
3353 Content-Length:
3354 schema:
3355 type: number
3356 example: 0
3357 '404':
3358 description: source file replacement not found
3359
3289 /api/v1/users/me/abuses: 3360 /api/v1/users/me/abuses:
3290 get: 3361 get:
3291 summary: List my abuses 3362 summary: List my abuses
@@ -6640,6 +6711,58 @@ components:
6640 required: false 6711 required: false
6641 schema: 6712 schema:
6642 type: string 6713 type: string
6714 resumableUploadInitContentLengthHeader:
6715 name: X-Upload-Content-Length
6716 in: header
6717 schema:
6718 type: number
6719 example: 2469036
6720 required: true
6721 description: Number of bytes that will be uploaded in subsequent requests. Set this value to the size of the file you are uploading.
6722 resumableUploadInitContentTypeHeader:
6723 name: X-Upload-Content-Type
6724 in: header
6725 schema:
6726 type: string
6727 format: mimetype
6728 example: video/mp4
6729 required: true
6730 description: MIME type of the file that you are uploading. Depending on your instance settings, acceptable values might vary.
6731 resumableUploadChunkContentRangeHeader:
6732 name: Content-Range
6733 in: header
6734 schema:
6735 type: string
6736 example: bytes 0-262143/2469036
6737 required: true
6738 description: |
6739 Specifies the bytes in the file that the request is uploading.
6740
6741 For example, a value of `bytes 0-262143/1000000` shows that the request is sending the first
6742 262144 bytes (256 x 1024) in a 2,469,036 byte file.
6743 resumableUploadChunkContentLengthHeader:
6744 name: Content-Length
6745 in: header
6746 schema:
6747 type: number
6748 example: 262144
6749 required: true
6750 description: |
6751 Size of the chunk that the request is sending.
6752
6753 Remember that larger chunks are more efficient. PeerTube's web client uses chunks varying from
6754 1048576 bytes (~1MB) and increases or reduces size depending on connection health.
6755 resumableUploadId:
6756 name: upload_id
6757 in: query
6758 required: true
6759 description: |
6760 Created session id to proceed with. If you didn't send chunks in the last hour, it is
6761 not valid anymore and you need to initialize a new upload.
6762 schema:
6763 type: string
6764
6765
6643 securitySchemes: 6766 securitySchemes:
6644 OAuth2: 6767 OAuth2:
6645 description: | 6768 description: |
@@ -7209,6 +7332,11 @@ components:
7209 type: boolean 7332 type: boolean
7210 downloadEnabled: 7333 downloadEnabled:
7211 type: boolean 7334 type: boolean
7335 inputFileUpdatedAt:
7336 type: string
7337 format: date-time
7338 nullable: true
7339 description: Latest input file update. Null if the file has never been replaced since the original upload
7212 trackerUrls: 7340 trackerUrls:
7213 type: array 7341 type: array
7214 items: 7342 items:
@@ -7554,6 +7682,9 @@ components:
7554 properties: 7682 properties:
7555 filename: 7683 filename:
7556 type: string 7684 type: string
7685 createdAt:
7686 type: string
7687 format: date-time
7557 ActorImage: 7688 ActorImage:
7558 properties: 7689 properties:
7559 path: 7690 path:
@@ -8403,6 +8534,13 @@ components:
8403 $ref: '#/components/schemas/Video/properties/uuid' 8534 $ref: '#/components/schemas/Video/properties/uuid'
8404 shortUUID: 8535 shortUUID:
8405 $ref: '#/components/schemas/Video/properties/shortUUID' 8536 $ref: '#/components/schemas/Video/properties/shortUUID'
8537 VideoReplaceSourceRequestResumable:
8538 properties:
8539 filename:
8540 description: Video filename including extension
8541 type: string
8542 format: filename
8543 example: what_is_peertube.mp4
8406 CommentThreadResponse: 8544 CommentThreadResponse:
8407 properties: 8545 properties:
8408 total: 8546 total: