diff options
author | Chocobozzz <me@florianbigard.com> | 2023-07-19 16:02:49 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2023-07-21 17:38:13 +0200 |
commit | 12dc3a942a13c7f1489822dae052da197ef15905 (patch) | |
tree | 7b87b6be692af0b62ebac17e720c80244fd8a7ec | |
parent | c6867725fb8e3dfbc2018a37ed5a963103587cb6 (diff) | |
download | PeerTube-12dc3a942a13c7f1489822dae052da197ef15905.tar.gz PeerTube-12dc3a942a13c7f1489822dae052da197ef15905.tar.zst PeerTube-12dc3a942a13c7f1489822dae052da197ef15905.zip |
Implement replace file in server side
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 | ||
598 | video_file: | ||
599 | update: | ||
600 | # Add ability for users to replace the video file of an existing video | ||
601 | enabled: false | ||
602 | |||
598 | import: | 603 | import: |
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 | ||
608 | video_file: | ||
609 | update: | ||
610 | # Add ability for users to replace the video file of an existing video | ||
611 | enabled: false | ||
612 | |||
608 | import: | 613 | import: |
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' | |||
39 | import { videoImportsRouter } from './import' | 38 | import { videoImportsRouter } from './import' |
40 | import { liveRouter } from './live' | 39 | import { liveRouter } from './live' |
41 | import { ownershipVideoRouter } from './ownership' | 40 | import { ownershipVideoRouter } from './ownership' |
41 | import { videoPasswordRouter } from './passwords' | ||
42 | import { rateVideoRouter } from './rate' | 42 | import { rateVideoRouter } from './rate' |
43 | import { videoSourceRouter } from './source' | ||
43 | import { statsRouter } from './stats' | 44 | import { statsRouter } from './stats' |
44 | import { storyboardRouter } from './storyboard' | 45 | import { storyboardRouter } from './storyboard' |
45 | import { studioRouter } from './studio' | 46 | import { studioRouter } from './studio' |
@@ -48,7 +49,6 @@ import { transcodingRouter } from './transcoding' | |||
48 | import { updateRouter } from './update' | 49 | import { updateRouter } from './update' |
49 | import { uploadRouter } from './upload' | 50 | import { uploadRouter } from './upload' |
50 | import { viewRouter } from './view' | 51 | import { viewRouter } from './view' |
51 | import { videoPasswordRouter } from './passwords' | ||
52 | 52 | ||
53 | const auditLogger = auditLoggerFactory('videos') | 53 | const auditLogger = auditLoggerFactory('videos') |
54 | const videosRouter = express.Router() | 54 | const videosRouter = express.Router() |
@@ -72,6 +72,7 @@ videosRouter.use('/', transcodingRouter) | |||
72 | videosRouter.use('/', tokenRouter) | 72 | videosRouter.use('/', tokenRouter) |
73 | videosRouter.use('/', videoPasswordRouter) | 73 | videosRouter.use('/', videoPasswordRouter) |
74 | videosRouter.use('/', storyboardRouter) | 74 | videosRouter.use('/', storyboardRouter) |
75 | videosRouter.use('/', videoSourceRouter) | ||
75 | 76 | ||
76 | videosRouter.get('/categories', | 77 | videosRouter.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 | ||
111 | videosRouter.get('/:id/source', | ||
112 | openapiOperationDoc({ operationId: 'getVideoSource' }), | ||
113 | authenticate, | ||
114 | asyncMiddleware(videoSourceGetValidator), | ||
115 | getVideoSource | ||
116 | ) | ||
117 | |||
118 | videosRouter.get('/:id', | 112 | videosRouter.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 | ||
180 | function getVideoSource (req: express.Request, res: express.Response) { | ||
181 | return res.json(res.locals.videoSource.toFormattedJSON()) | ||
182 | } | ||
183 | |||
184 | async function listVideos (req: express.Request, res: express.Response) { | 174 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { move } from 'fs-extra' | ||
3 | import { sequelizeTypescript } from '@server/initializers/database' | ||
4 | import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue' | ||
5 | import { Hooks } from '@server/lib/plugins/hooks' | ||
6 | import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail' | ||
7 | import { uploadx } from '@server/lib/uploadx' | ||
8 | import { buildMoveToObjectStorageJob } from '@server/lib/video' | ||
9 | import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' | ||
10 | import { buildNewFile } from '@server/lib/video-file' | ||
11 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
12 | import { buildNextVideoState } from '@server/lib/video-state' | ||
13 | import { openapiOperationDoc } from '@server/middlewares/doc' | ||
14 | import { VideoModel } from '@server/models/video/video' | ||
15 | import { VideoSourceModel } from '@server/models/video/video-source' | ||
16 | import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | ||
17 | import { HttpStatusCode, VideoState } from '@shared/models' | ||
18 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
19 | import { | ||
20 | asyncMiddleware, | ||
21 | authenticate, | ||
22 | replaceVideoSourceResumableInitValidator, | ||
23 | replaceVideoSourceResumableValidator, | ||
24 | videoSourceGetLatestValidator | ||
25 | } from '../../../middlewares' | ||
26 | |||
27 | const lTags = loggerTagsFactory('api', 'video') | ||
28 | |||
29 | const videoSourceRouter = express.Router() | ||
30 | |||
31 | videoSourceRouter.get('/:id/source', | ||
32 | openapiOperationDoc({ operationId: 'getVideoSource' }), | ||
33 | authenticate, | ||
34 | asyncMiddleware(videoSourceGetLatestValidator), | ||
35 | getVideoLatestSource | ||
36 | ) | ||
37 | |||
38 | videoSourceRouter.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 | |||
44 | videoSourceRouter.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 | |||
49 | videoSourceRouter.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 | |||
58 | export { | ||
59 | videoSourceRouter | ||
60 | } | ||
61 | |||
62 | // --------------------------------------------------------------------------- | ||
63 | |||
64 | function getVideoLatestSource (req: express.Request, res: express.Response) { | ||
65 | return res.json(res.locals.videoSource.toFormattedJSON()) | ||
66 | } | ||
67 | |||
68 | async 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 | |||
144 | async 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 | |||
192 | async 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' | |||
11 | import { VideoPathManager } from '@server/lib/video-path-manager' | 11 | import { VideoPathManager } from '@server/lib/video-path-manager' |
12 | import { buildNextVideoState } from '@server/lib/video-state' | 12 | import { buildNextVideoState } from '@server/lib/video-state' |
13 | import { openapiOperationDoc } from '@server/middlewares/doc' | 13 | import { openapiOperationDoc } from '@server/middlewares/doc' |
14 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
14 | import { VideoSourceModel } from '@server/models/video/video-source' | 15 | import { VideoSourceModel } from '@server/models/video/video-source' |
15 | import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' | 16 | import { MVideoFile, MVideoFullLight } from '@server/types/models' |
16 | import { uuidToShort } from '@shared/extra-utils' | 17 | import { uuidToShort } from '@shared/extra-utils' |
17 | import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models' | 18 | import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models' |
18 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | 19 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' |
@@ -33,7 +34,6 @@ import { | |||
33 | } from '../../../middlewares' | 34 | } from '../../../middlewares' |
34 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | 35 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' |
35 | import { VideoModel } from '../../../models/video/video' | 36 | import { VideoModel } from '../../../models/video/video' |
36 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
37 | 37 | ||
38 | const lTags = loggerTagsFactory('api', 'video') | 38 | const lTags = loggerTagsFactory('api', 'video') |
39 | const auditLogger = auditLoggerFactory('videos') | 39 | const auditLogger = auditLoggerFactory('videos') |
@@ -109,7 +109,7 @@ async function addVideoLegacy (req: express.Request, res: express.Response) { | |||
109 | } | 109 | } |
110 | 110 | ||
111 | async function addVideoResumable (req: express.Request, res: express.Response) { | 111 | async 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 | ||
226 | async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile, user: MUserId) { | 227 | async 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 | ||
30 | const LAST_MIGRATION_VERSION = 795 | 30 | const 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async 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 | |||
31 | function down (options) { | ||
32 | throw new Error('Not implemented.') | ||
33 | } | ||
34 | |||
35 | export { | ||
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' | |||
7 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | 7 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' |
8 | import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live' | 8 | import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live' |
9 | import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths' | 9 | import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths' |
10 | import { generateLocalVideoMiniature } from '@server/lib/thumbnail' | 10 | import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail' |
11 | import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding' | 11 | import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding' |
12 | import { VideoPathManager } from '@server/lib/video-path-manager' | 12 | import { VideoPathManager } from '@server/lib/video-path-manager' |
13 | import { moveToNextState } from '@server/lib/video-state' | 13 | import { 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 |
39 | function isLocalVideoAccepted (object: { | 39 | function 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: { | |||
201 | export { | 201 | export { |
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 | |||
4 | import { CONFIG } from '../initializers/config' | 4 | import { CONFIG } from '../initializers/config' |
5 | import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants' | 5 | import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants' |
6 | import { ThumbnailModel } from '../models/video/thumbnail' | 6 | import { ThumbnailModel } from '../models/video/thumbnail' |
7 | import { MVideoFile, MVideoThumbnail, MVideoUUID } from '../types/models' | 7 | import { MVideoFile, MVideoThumbnail, MVideoUUID, MVideoWithAllFiles } from '../types/models' |
8 | import { MThumbnail } from '../types/models/video/thumbnail' | 8 | import { MThumbnail } from '../types/models/video/thumbnail' |
9 | import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist' | 9 | import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist' |
10 | import { VideoPathManager } from './video-path-manager' | 10 | import { VideoPathManager } from './video-path-manager' |
@@ -187,8 +187,31 @@ function updateRemoteVideoThumbnail (options: { | |||
187 | 187 | ||
188 | // --------------------------------------------------------------------------- | 188 | // --------------------------------------------------------------------------- |
189 | 189 | ||
190 | async 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 | |||
190 | export { | 212 | export { |
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 @@ | |||
1 | export * from './video-blacklist' | 1 | export * from './video-blacklist' |
2 | export * from './video-captions' | 2 | export * from './video-captions' |
3 | export * from './video-channel-sync' | ||
3 | export * from './video-channels' | 4 | export * from './video-channels' |
4 | export * from './video-comments' | 5 | export * from './video-comments' |
5 | export * from './video-files' | 6 | export * from './video-files' |
6 | export * from './video-imports' | 7 | export * from './video-imports' |
7 | export * from './video-live' | 8 | export * from './video-live' |
8 | export * from './video-ownership-changes' | 9 | export * from './video-ownership-changes' |
9 | export * from './video-view' | 10 | export * from './video-passwords' |
10 | export * from './video-rates' | 11 | export * from './video-rates' |
11 | export * from './video-shares' | 12 | export * from './video-shares' |
12 | export * from './video-source' | 13 | export * from './video-source' |
@@ -14,6 +15,5 @@ export * from './video-stats' | |||
14 | export * from './video-studio' | 15 | export * from './video-studio' |
15 | export * from './video-token' | 16 | export * from './video-token' |
16 | export * from './video-transcoding' | 17 | export * from './video-transcoding' |
18 | export * from './video-view' | ||
17 | export * from './videos' | 19 | export * from './videos' |
18 | export * from './video-channel-sync' | ||
19 | export * 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 @@ | |||
1 | export * from './upload' | ||
2 | export * 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 @@ | |||
1 | import express from 'express' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { getVideoStreamDuration } from '@shared/ffmpeg' | ||
4 | import { HttpStatusCode } from '@shared/models' | ||
5 | |||
6 | export 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 | |||
32 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { isVideoFileMimeTypeValid, isVideoFileSizeValid } from '@server/helpers/custom-validators/videos' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' | ||
5 | import { isLocalVideoFileAccepted } from '@server/lib/moderation' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { MUserAccountId, MVideo } from '@server/types/models' | ||
8 | import { HttpStatusCode, ServerErrorCode, ServerFilterHookName, VideoState } from '@shared/models' | ||
9 | import { checkUserQuota } from '../../shared' | ||
10 | |||
11 | export 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 | |||
42 | export 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 | |||
74 | export 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { body, header } from 'express-validator' | ||
3 | import { getResumableUploadPath } from '@server/helpers/upload' | ||
2 | import { getVideoWithAttributes } from '@server/helpers/video' | 4 | import { getVideoWithAttributes } from '@server/helpers/video' |
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { uploadx } from '@server/lib/uploadx' | ||
3 | import { VideoSourceModel } from '@server/models/video/video-source' | 7 | import { VideoSourceModel } from '@server/models/video/video-source' |
4 | import { MVideoFullLight } from '@server/types/models' | 8 | import { MVideoFullLight } from '@server/types/models' |
5 | import { HttpStatusCode, UserRight } from '@shared/models' | 9 | import { HttpStatusCode, UserRight } from '@shared/models' |
10 | import { Metadata as UploadXMetadata } from '@uploadx/core' | ||
11 | import { logger } from '../../../helpers/logger' | ||
6 | import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared' | 12 | import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared' |
13 | import { addDurationToVideoFileIfNeeded, checkVideoFileCanBeEdited, commonVideoFileChecks, isVideoFileAccepted } from './shared' | ||
7 | 14 | ||
8 | const videoSourceGetValidator = [ | 15 | export 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 | |||
40 | export 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 | |||
64 | export 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 | ||
32 | export { | 102 | // --------------------------------------------------------------------------- |
33 | videoSourceGetValidator | 103 | // Private |
104 | // --------------------------------------------------------------------------- | ||
105 | |||
106 | async 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' | |||
11 | import { CONFIG } from '@server/initializers/config' | 11 | import { CONFIG } from '@server/initializers/config' |
12 | import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio' | 12 | import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio' |
13 | import { isAudioFile } from '@shared/ffmpeg' | 13 | import { isAudioFile } from '@shared/ffmpeg' |
14 | import { HttpStatusCode, UserRight, VideoState, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models' | 14 | import { HttpStatusCode, UserRight, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models' |
15 | import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared' | 15 | import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared' |
16 | import { checkVideoFileCanBeEdited } from './shared' | ||
16 | 17 | ||
17 | const videoStudioAddEditionValidator = [ | 18 | const 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' | |||
2 | import { body, header, param, query, ValidationChain } from 'express-validator' | 2 | import { body, header, param, query, ValidationChain } from 'express-validator' |
3 | import { isTestInstance } from '@server/helpers/core-utils' | 3 | import { isTestInstance } from '@server/helpers/core-utils' |
4 | import { getResumableUploadPath } from '@server/helpers/upload' | 4 | import { getResumableUploadPath } from '@server/helpers/upload' |
5 | import { uploadx } from '@server/lib/uploadx' | ||
6 | import { Redis } from '@server/lib/redis' | 5 | import { Redis } from '@server/lib/redis' |
6 | import { uploadx } from '@server/lib/uploadx' | ||
7 | import { getServerActor } from '@server/models/application/application' | 7 | import { getServerActor } from '@server/models/application/application' |
8 | import { ExpressPromiseHandler } from '@server/types/express-handler' | 8 | import { ExpressPromiseHandler } from '@server/types/express-handler' |
9 | import { MUserAccountId, MVideoFullLight } from '@server/types/models' | 9 | import { MUserAccountId, MVideoFullLight } from '@server/types/models' |
10 | import { arrayify, getAllPrivacies } from '@shared/core-utils' | 10 | import { arrayify, getAllPrivacies } from '@shared/core-utils' |
11 | import { getVideoStreamDuration } from '@shared/ffmpeg' | ||
12 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models' | 11 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models' |
13 | import { | 12 | import { |
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' | |||
44 | import { getVideoWithAttributes } from '../../../helpers/video' | 41 | import { getVideoWithAttributes } from '../../../helpers/video' |
45 | import { CONFIG } from '../../../initializers/config' | 42 | import { CONFIG } from '../../../initializers/config' |
46 | import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' | 43 | import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' |
47 | import { isLocalVideoAccepted } from '../../../lib/moderation' | ||
48 | import { Hooks } from '../../../lib/plugins/hooks' | ||
49 | import { VideoModel } from '../../../models/video/video' | 44 | import { VideoModel } from '../../../models/video/video' |
50 | import { | 45 | import { |
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' |
56 | import { addDurationToVideoFileIfNeeded, commonVideoFileChecks, isVideoFileAccepted } from './shared' | ||
62 | 57 | ||
63 | const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ | 58 | const 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 | ||
607 | async function commonVideoChecksPass (parameters: { | 579 | async 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 | |||
643 | export 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 | |||
672 | async 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 @@ | |||
1 | import { Op } from 'sequelize' | 1 | import { Transaction } from 'sequelize' |
2 | import { | 2 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' |
3 | AllowNull, | 3 | import { 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' | ||
12 | import { AttributesOnly } from '@shared/typescript-utils' | 4 | import { AttributesOnly } from '@shared/typescript-utils' |
5 | import { getSort } from '../shared' | ||
13 | import { VideoModel } from './video' | 6 | import { 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 @@ | |||
1 | import { HttpStatusCode } from '@shared/models' | 1 | import { HttpStatusCode } from '@shared/models' |
2 | import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' | 2 | import { |
3 | cleanupTests, | ||
4 | createSingleServer, | ||
5 | PeerTubeServer, | ||
6 | setAccessTokensToServers, | ||
7 | setDefaultVideoChannel, | ||
8 | waitJobs | ||
9 | } from '@shared/server-commands' | ||
3 | 10 | ||
4 | describe('Test video sources API validator', function () { | 11 | describe('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' | |||
13 | import './video-nsfw' | 13 | import './video-nsfw' |
14 | import './video-playlists' | 14 | import './video-playlists' |
15 | import './video-playlist-thumbnails' | 15 | import './video-playlist-thumbnails' |
16 | import './video-source' | ||
16 | import './video-privacy' | 17 | import './video-privacy' |
17 | import './video-schedule-update' | 18 | import './video-schedule-update' |
18 | import './videos-common-filters' | 19 | import './videos-common-filters' |
19 | import './videos-history' | 20 | import './videos-history' |
20 | import './videos-overview' | 21 | import './videos-overview' |
21 | import './video-source' | ||
22 | import './video-static-file-privacy' | 22 | import './video-static-file-privacy' |
23 | import './video-storyboard' | 23 | import './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 | ||
13 | describe('Test resumable upload', function () { | 13 | describe('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 @@ | |||
1 | import { expect } from 'chai' | 1 | import { expect } from 'chai' |
2 | import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' | 2 | import { expectStartWith } from '@server/tests/shared' |
3 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
4 | import { areMockObjectStorageTestsDisabled, getAllFiles } from '@shared/core-utils' | ||
5 | import { HttpStatusCode } from '@shared/models' | ||
6 | import { | ||
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 | ||
4 | describe('Test video source', () => { | 20 | describe('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 | ||
22 | async function countFiles (server: PeerTubeServer, directory: string) { | ||
23 | const files = await readdir(server.servers.buildDirectory(directory)) | ||
24 | |||
25 | return files.length | ||
26 | } | ||
27 | |||
28 | async function assertNotExists (server: PeerTubeServer, directory: string, substring: string) { | 22 | async 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 | ||
36 | async function assertCountAreOkay (servers: PeerTubeServer[]) { | 30 | async 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 @@ | |||
1 | export interface VideoSource { | 1 | export 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 @@ | |||
1 | import { exec } from 'child_process' | 1 | import { exec } from 'child_process' |
2 | import { copy, ensureDir, readFile, remove } from 'fs-extra' | 2 | import { copy, ensureDir, readFile, readdir, remove } from 'fs-extra' |
3 | import { basename, join } from 'path' | 3 | import { basename, join } from 'path' |
4 | import { isGithubCI, root, wait } from '@shared/core-utils' | 4 | import { isGithubCI, root, wait } from '@shared/core-utils' |
5 | import { getFileSize } from '@shared/extra-utils' | 5 | import { 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 | ||
34 | export class VideosCommand extends AbstractCommand { | 34 | export 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: |