Begin moving video channel to actor
[oweals/peertube.git] / server / controllers / api / videos / index.ts
1 import * as express from 'express'
2 import * as multer from 'multer'
3 import { extname, join } from 'path'
4 import { VideoCreate, VideoPrivacy, VideoUpdate } from '../../../../shared'
5 import {
6   generateRandomString,
7   getFormattedObjects,
8   getVideoFileHeight,
9   logger,
10   renamePromise,
11   resetSequelizeInstance,
12   retryTransactionWrapper
13 } from '../../../helpers'
14 import { getServerActor } from '../../../helpers/utils'
15 import {
16   CONFIG,
17   sequelizeTypescript,
18   VIDEO_CATEGORIES,
19   VIDEO_LANGUAGES,
20   VIDEO_LICENCES,
21   VIDEO_MIMETYPE_EXT,
22   VIDEO_PRIVACIES
23 } from '../../../initializers'
24 import { fetchRemoteVideoDescription, getVideoActivityPubUrl, shareVideoByServer } from '../../../lib/activitypub'
25 import { sendCreateVideo, sendCreateViewToOrigin, sendCreateViewToVideoFollowers, sendUpdateVideo } from '../../../lib/activitypub/send'
26 import { transcodingJobScheduler } from '../../../lib/jobs/transcoding-job-scheduler'
27 import {
28   asyncMiddleware,
29   authenticate,
30   paginationValidator,
31   setPagination,
32   setVideosSort,
33   videosAddValidator,
34   videosGetValidator,
35   videosRemoveValidator,
36   videosSearchValidator,
37   videosSortValidator,
38   videosUpdateValidator
39 } from '../../../middlewares'
40 import { TagModel } from '../../../models/video/tag'
41 import { VideoModel } from '../../../models/video/video'
42 import { VideoFileModel } from '../../../models/video/video-file'
43 import { abuseVideoRouter } from './abuse'
44 import { blacklistRouter } from './blacklist'
45 import { videoChannelRouter } from './channel'
46 import { rateVideoRouter } from './rate'
47
48 const videosRouter = express.Router()
49
50 // multer configuration
51 const storage = multer.diskStorage({
52   destination: (req, file, cb) => {
53     cb(null, CONFIG.STORAGE.VIDEOS_DIR)
54   },
55
56   filename: async (req, file, cb) => {
57     const extension = VIDEO_MIMETYPE_EXT[file.mimetype]
58     let randomString = ''
59
60     try {
61       randomString = await generateRandomString(16)
62     } catch (err) {
63       logger.error('Cannot generate random string for file name.', err)
64       randomString = 'fake-random-string'
65     }
66
67     cb(null, randomString + extension)
68   }
69 })
70
71 const reqFiles = multer({ storage: storage }).fields([{ name: 'videofile', maxCount: 1 }])
72
73 videosRouter.use('/', abuseVideoRouter)
74 videosRouter.use('/', blacklistRouter)
75 videosRouter.use('/', rateVideoRouter)
76 videosRouter.use('/', videoChannelRouter)
77
78 videosRouter.get('/categories', listVideoCategories)
79 videosRouter.get('/licences', listVideoLicences)
80 videosRouter.get('/languages', listVideoLanguages)
81 videosRouter.get('/privacies', listVideoPrivacies)
82
83 videosRouter.get('/',
84   paginationValidator,
85   videosSortValidator,
86   setVideosSort,
87   setPagination,
88   asyncMiddleware(listVideos)
89 )
90 videosRouter.get('/search',
91   videosSearchValidator,
92   paginationValidator,
93   videosSortValidator,
94   setVideosSort,
95   setPagination,
96   asyncMiddleware(searchVideos)
97 )
98 videosRouter.put('/:id',
99   authenticate,
100   asyncMiddleware(videosUpdateValidator),
101   asyncMiddleware(updateVideoRetryWrapper)
102 )
103 videosRouter.post('/upload',
104   authenticate,
105   reqFiles,
106   asyncMiddleware(videosAddValidator),
107   asyncMiddleware(addVideoRetryWrapper)
108 )
109
110 videosRouter.get('/:id/description',
111   asyncMiddleware(videosGetValidator),
112   asyncMiddleware(getVideoDescription)
113 )
114 videosRouter.get('/:id',
115   asyncMiddleware(videosGetValidator),
116   getVideo
117 )
118 videosRouter.post('/:id/views',
119   asyncMiddleware(videosGetValidator),
120   asyncMiddleware(viewVideo)
121 )
122
123 videosRouter.delete('/:id',
124   authenticate,
125   asyncMiddleware(videosRemoveValidator),
126   asyncMiddleware(removeVideoRetryWrapper)
127 )
128
129 // ---------------------------------------------------------------------------
130
131 export {
132   videosRouter
133 }
134
135 // ---------------------------------------------------------------------------
136
137 function listVideoCategories (req: express.Request, res: express.Response) {
138   res.json(VIDEO_CATEGORIES)
139 }
140
141 function listVideoLicences (req: express.Request, res: express.Response) {
142   res.json(VIDEO_LICENCES)
143 }
144
145 function listVideoLanguages (req: express.Request, res: express.Response) {
146   res.json(VIDEO_LANGUAGES)
147 }
148
149 function listVideoPrivacies (req: express.Request, res: express.Response) {
150   res.json(VIDEO_PRIVACIES)
151 }
152
153 // Wrapper to video add that retry the function if there is a database error
154 // We need this because we run the transaction in SERIALIZABLE isolation that can fail
155 async function addVideoRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) {
156   const options = {
157     arguments: [ req, res, req.files['videofile'][0] ],
158     errorMessage: 'Cannot insert the video with many retries.'
159   }
160
161   const video = await retryTransactionWrapper(addVideo, options)
162
163   res.json({
164     video: {
165       id: video.id,
166       uuid: video.uuid
167     }
168   }).end()
169 }
170
171 async function addVideo (req: express.Request, res: express.Response, videoPhysicalFile: Express.Multer.File) {
172   const videoInfo: VideoCreate = req.body
173
174   // Prepare data so we don't block the transaction
175   const videoData = {
176     name: videoInfo.name,
177     remote: false,
178     extname: extname(videoPhysicalFile.filename),
179     category: videoInfo.category,
180     licence: videoInfo.licence,
181     language: videoInfo.language,
182     nsfw: videoInfo.nsfw,
183     description: videoInfo.description,
184     privacy: videoInfo.privacy,
185     duration: videoPhysicalFile['duration'], // duration was added by a previous middleware
186     channelId: res.locals.videoChannel.id
187   }
188   const video = new VideoModel(videoData)
189   video.url = getVideoActivityPubUrl(video)
190
191   const videoFilePath = join(CONFIG.STORAGE.VIDEOS_DIR, videoPhysicalFile.filename)
192   const videoFileHeight = await getVideoFileHeight(videoFilePath)
193
194   const videoFileData = {
195     extname: extname(videoPhysicalFile.filename),
196     resolution: videoFileHeight,
197     size: videoPhysicalFile.size
198   }
199   const videoFile = new VideoFileModel(videoFileData)
200   const videoDir = CONFIG.STORAGE.VIDEOS_DIR
201   const source = join(videoDir, videoPhysicalFile.filename)
202   const destination = join(videoDir, video.getVideoFilename(videoFile))
203
204   await renamePromise(source, destination)
205   // This is important in case if there is another attempt in the retry process
206   videoPhysicalFile.filename = video.getVideoFilename(videoFile)
207
208   const tasks = []
209
210   tasks.push(
211     video.createTorrentAndSetInfoHash(videoFile),
212     video.createThumbnail(videoFile),
213     video.createPreview(videoFile)
214   )
215   await Promise.all(tasks)
216
217   return sequelizeTypescript.transaction(async t => {
218     const sequelizeOptions = { transaction: t }
219
220     if (CONFIG.TRANSCODING.ENABLED === true) {
221       // Put uuid because we don't have id auto incremented for now
222       const dataInput = {
223         videoUUID: video.uuid
224       }
225
226       await transcodingJobScheduler.createJob(t, 'videoFileOptimizer', dataInput)
227     }
228
229     const videoCreated = await video.save(sequelizeOptions)
230     // Do not forget to add video channel information to the created video
231     videoCreated.VideoChannel = res.locals.videoChannel
232
233     videoFile.videoId = video.id
234     await videoFile.save(sequelizeOptions)
235
236     video.VideoFiles = [ videoFile ]
237
238     if (videoInfo.tags) {
239       const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
240
241       await video.$set('Tags', tagInstances, sequelizeOptions)
242       video.Tags = tagInstances
243     }
244
245     // Let transcoding job send the video to friends because the video file extension might change
246     if (CONFIG.TRANSCODING.ENABLED === true) return videoCreated
247     // Don't send video to remote servers, it is private
248     if (video.privacy === VideoPrivacy.PRIVATE) return videoCreated
249
250     await sendCreateVideo(video, t)
251     // TODO: share by video channel
252     await shareVideoByServer(video, t)
253
254     logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
255
256     return videoCreated
257   })
258 }
259
260 async function updateVideoRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) {
261   const options = {
262     arguments: [ req, res ],
263     errorMessage: 'Cannot update the video with many retries.'
264   }
265
266   await retryTransactionWrapper(updateVideo, options)
267
268   return res.type('json').status(204).end()
269 }
270
271 async function updateVideo (req: express.Request, res: express.Response) {
272   const videoInstance: VideoModel = res.locals.video
273   const videoFieldsSave = videoInstance.toJSON()
274   const videoInfoToUpdate: VideoUpdate = req.body
275   const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE
276
277   try {
278     await sequelizeTypescript.transaction(async t => {
279       const sequelizeOptions = {
280         transaction: t
281       }
282
283       if (videoInfoToUpdate.name !== undefined) videoInstance.set('name', videoInfoToUpdate.name)
284       if (videoInfoToUpdate.category !== undefined) videoInstance.set('category', videoInfoToUpdate.category)
285       if (videoInfoToUpdate.licence !== undefined) videoInstance.set('licence', videoInfoToUpdate.licence)
286       if (videoInfoToUpdate.language !== undefined) videoInstance.set('language', videoInfoToUpdate.language)
287       if (videoInfoToUpdate.nsfw !== undefined) videoInstance.set('nsfw', videoInfoToUpdate.nsfw)
288       if (videoInfoToUpdate.privacy !== undefined) videoInstance.set('privacy', parseInt(videoInfoToUpdate.privacy.toString(), 10))
289       if (videoInfoToUpdate.description !== undefined) videoInstance.set('description', videoInfoToUpdate.description)
290
291       const videoInstanceUpdated = await videoInstance.save(sequelizeOptions)
292
293       if (videoInfoToUpdate.tags) {
294         const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t)
295
296         await videoInstance.$set('Tags', tagInstances, sequelizeOptions)
297         videoInstance.Tags = tagInstances
298       }
299
300       // Now we'll update the video's meta data to our friends
301       if (wasPrivateVideo === false) {
302         await sendUpdateVideo(videoInstanceUpdated, t)
303       }
304
305       // Video is not private anymore, send a create action to remote servers
306       if (wasPrivateVideo === true && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE) {
307         await sendCreateVideo(videoInstanceUpdated, t)
308         // TODO: Send by video channel
309         await shareVideoByServer(videoInstanceUpdated, t)
310       }
311     })
312
313     logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid)
314   } catch (err) {
315     // Force fields we want to update
316     // If the transaction is retried, sequelize will think the object has not changed
317     // So it will skip the SQL request, even if the last one was ROLLBACKed!
318     resetSequelizeInstance(videoInstance, videoFieldsSave)
319
320     throw err
321   }
322 }
323
324 function getVideo (req: express.Request, res: express.Response) {
325   const videoInstance = res.locals.video
326
327   return res.json(videoInstance.toFormattedDetailsJSON())
328 }
329
330 async function viewVideo (req: express.Request, res: express.Response) {
331   const videoInstance = res.locals.video
332
333   await videoInstance.increment('views')
334   const serverAccount = await getServerActor()
335
336   if (videoInstance.isOwned()) {
337     await sendCreateViewToVideoFollowers(serverAccount, videoInstance, undefined)
338   } else {
339     await sendCreateViewToOrigin(serverAccount, videoInstance, undefined)
340   }
341
342   return res.status(204).end()
343 }
344
345 async function getVideoDescription (req: express.Request, res: express.Response) {
346   const videoInstance = res.locals.video
347   let description = ''
348
349   if (videoInstance.isOwned()) {
350     description = videoInstance.description
351   } else {
352     description = await fetchRemoteVideoDescription(videoInstance)
353   }
354
355   return res.json({ description })
356 }
357
358 async function listVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
359   const resultList = await VideoModel.listForApi(req.query.start, req.query.count, req.query.sort)
360
361   return res.json(getFormattedObjects(resultList.data, resultList.total))
362 }
363
364 async function removeVideoRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) {
365   const options = {
366     arguments: [ req, res ],
367     errorMessage: 'Cannot remove the video with many retries.'
368   }
369
370   await retryTransactionWrapper(removeVideo, options)
371
372   return res.type('json').status(204).end()
373 }
374
375 async function removeVideo (req: express.Request, res: express.Response) {
376   const videoInstance: VideoModel = res.locals.video
377
378   await sequelizeTypescript.transaction(async t => {
379     await videoInstance.destroy({ transaction: t })
380   })
381
382   logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid)
383 }
384
385 async function searchVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
386   const resultList = await VideoModel.searchAndPopulateAccountAndServerAndTags(
387     req.query.search,
388     req.query.start,
389     req.query.count,
390     req.query.sort
391   )
392
393   return res.json(getFormattedObjects(resultList.data, resultList.total))
394 }