Add video category support
[oweals/peertube.git] / server / controllers / api / videos.js
1 'use strict'
2
3 const express = require('express')
4 const fs = require('fs')
5 const multer = require('multer')
6 const path = require('path')
7 const waterfall = require('async/waterfall')
8
9 const constants = require('../../initializers/constants')
10 const db = require('../../initializers/database')
11 const logger = require('../../helpers/logger')
12 const friends = require('../../lib/friends')
13 const middlewares = require('../../middlewares')
14 const admin = middlewares.admin
15 const oAuth = middlewares.oauth
16 const pagination = middlewares.pagination
17 const validators = middlewares.validators
18 const validatorsPagination = validators.pagination
19 const validatorsSort = validators.sort
20 const validatorsVideos = validators.videos
21 const search = middlewares.search
22 const sort = middlewares.sort
23 const databaseUtils = require('../../helpers/database-utils')
24 const utils = require('../../helpers/utils')
25
26 const router = express.Router()
27
28 // multer configuration
29 const storage = multer.diskStorage({
30   destination: function (req, file, cb) {
31     cb(null, constants.CONFIG.STORAGE.VIDEOS_DIR)
32   },
33
34   filename: function (req, file, cb) {
35     let extension = ''
36     if (file.mimetype === 'video/webm') extension = 'webm'
37     else if (file.mimetype === 'video/mp4') extension = 'mp4'
38     else if (file.mimetype === 'video/ogg') extension = 'ogv'
39     utils.generateRandomString(16, function (err, randomString) {
40       const fieldname = err ? undefined : randomString
41       cb(null, fieldname + '.' + extension)
42     })
43   }
44 })
45
46 const reqFiles = multer({ storage: storage }).fields([{ name: 'videofile', maxCount: 1 }])
47
48 router.get('/categories', listVideoCategories)
49
50 router.get('/abuse',
51   oAuth.authenticate,
52   admin.ensureIsAdmin,
53   validatorsPagination.pagination,
54   validatorsSort.videoAbusesSort,
55   sort.setVideoAbusesSort,
56   pagination.setPagination,
57   listVideoAbuses
58 )
59 router.post('/:id/abuse',
60   oAuth.authenticate,
61   validatorsVideos.videoAbuseReport,
62   reportVideoAbuseRetryWrapper
63 )
64
65 router.put('/:id/rate',
66   oAuth.authenticate,
67   validatorsVideos.videoRate,
68   rateVideoRetryWrapper
69 )
70
71 router.get('/',
72   validatorsPagination.pagination,
73   validatorsSort.videosSort,
74   sort.setVideosSort,
75   pagination.setPagination,
76   listVideos
77 )
78 router.put('/:id',
79   oAuth.authenticate,
80   reqFiles,
81   validatorsVideos.videosUpdate,
82   updateVideoRetryWrapper
83 )
84 router.post('/',
85   oAuth.authenticate,
86   reqFiles,
87   validatorsVideos.videosAdd,
88   addVideoRetryWrapper
89 )
90 router.get('/:id',
91   validatorsVideos.videosGet,
92   getVideo
93 )
94 router.delete('/:id',
95   oAuth.authenticate,
96   validatorsVideos.videosRemove,
97   removeVideo
98 )
99 router.get('/search/:value',
100   validatorsVideos.videosSearch,
101   validatorsPagination.pagination,
102   validatorsSort.videosSort,
103   sort.setVideosSort,
104   pagination.setPagination,
105   search.setVideosSearch,
106   searchVideos
107 )
108
109 // ---------------------------------------------------------------------------
110
111 module.exports = router
112
113 // ---------------------------------------------------------------------------
114
115 function listVideoCategories (req, res, next) {
116   res.json(constants.VIDEO_CATEGORIES)
117 }
118
119 function rateVideoRetryWrapper (req, res, next) {
120   const options = {
121     arguments: [ req, res ],
122     errorMessage: 'Cannot update the user video rate.'
123   }
124
125   databaseUtils.retryTransactionWrapper(rateVideo, options, function (err) {
126     if (err) return next(err)
127
128     return res.type('json').status(204).end()
129   })
130 }
131
132 function rateVideo (req, res, finalCallback) {
133   const rateType = req.body.rating
134   const videoInstance = res.locals.video
135   const userInstance = res.locals.oauth.token.User
136
137   waterfall([
138     databaseUtils.startSerializableTransaction,
139
140     function findPreviousRate (t, callback) {
141       db.UserVideoRate.load(userInstance.id, videoInstance.id, t, function (err, previousRate) {
142         return callback(err, t, previousRate)
143       })
144     },
145
146     function insertUserRateIntoDB (t, previousRate, callback) {
147       const options = { transaction: t }
148
149       let likesToIncrement = 0
150       let dislikesToIncrement = 0
151
152       if (rateType === constants.VIDEO_RATE_TYPES.LIKE) likesToIncrement++
153       else if (rateType === constants.VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement++
154
155       // There was a previous rate, update it
156       if (previousRate) {
157         // We will remove the previous rate, so we will need to remove it from the video attribute
158         if (previousRate.type === constants.VIDEO_RATE_TYPES.LIKE) likesToIncrement--
159         else if (previousRate.type === constants.VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement--
160
161         previousRate.type = rateType
162
163         previousRate.save(options).asCallback(function (err) {
164           return callback(err, t, likesToIncrement, dislikesToIncrement)
165         })
166       } else { // There was not a previous rate, insert a new one
167         const query = {
168           userId: userInstance.id,
169           videoId: videoInstance.id,
170           type: rateType
171         }
172
173         db.UserVideoRate.create(query, options).asCallback(function (err) {
174           return callback(err, t, likesToIncrement, dislikesToIncrement)
175         })
176       }
177     },
178
179     function updateVideoAttributeDB (t, likesToIncrement, dislikesToIncrement, callback) {
180       const options = { transaction: t }
181       const incrementQuery = {
182         likes: likesToIncrement,
183         dislikes: dislikesToIncrement
184       }
185
186       // Even if we do not own the video we increment the attributes
187       // It is usefull for the user to have a feedback
188       videoInstance.increment(incrementQuery, options).asCallback(function (err) {
189         return callback(err, t, likesToIncrement, dislikesToIncrement)
190       })
191     },
192
193     function sendEventsToFriendsIfNeeded (t, likesToIncrement, dislikesToIncrement, callback) {
194       // No need for an event type, we own the video
195       if (videoInstance.isOwned()) return callback(null, t, likesToIncrement, dislikesToIncrement)
196
197       const eventsParams = []
198
199       if (likesToIncrement !== 0) {
200         eventsParams.push({
201           videoId: videoInstance.id,
202           type: constants.REQUEST_VIDEO_EVENT_TYPES.LIKES,
203           count: likesToIncrement
204         })
205       }
206
207       if (dislikesToIncrement !== 0) {
208         eventsParams.push({
209           videoId: videoInstance.id,
210           type: constants.REQUEST_VIDEO_EVENT_TYPES.DISLIKES,
211           count: dislikesToIncrement
212         })
213       }
214
215       friends.addEventsToRemoteVideo(eventsParams, t, function (err) {
216         return callback(err, t, likesToIncrement, dislikesToIncrement)
217       })
218     },
219
220     function sendQaduToFriendsIfNeeded (t, likesToIncrement, dislikesToIncrement, callback) {
221       // We do not own the video, there is no need to send a quick and dirty update to friends
222       // Our rate was already sent by the addEvent function
223       if (videoInstance.isOwned() === false) return callback(null, t)
224
225       const qadusParams = []
226
227       if (likesToIncrement !== 0) {
228         qadusParams.push({
229           videoId: videoInstance.id,
230           type: constants.REQUEST_VIDEO_QADU_TYPES.LIKES
231         })
232       }
233
234       if (dislikesToIncrement !== 0) {
235         qadusParams.push({
236           videoId: videoInstance.id,
237           type: constants.REQUEST_VIDEO_QADU_TYPES.DISLIKES
238         })
239       }
240
241       friends.quickAndDirtyUpdatesVideoToFriends(qadusParams, t, function (err) {
242         return callback(err, t)
243       })
244     },
245
246     databaseUtils.commitTransaction
247
248   ], function (err, t) {
249     if (err) {
250       // This is just a debug because we will retry the insert
251       logger.debug('Cannot add the user video rate.', { error: err })
252       return databaseUtils.rollbackTransaction(err, t, finalCallback)
253     }
254
255     logger.info('User video rate for video %s of user %s updated.', videoInstance.name, userInstance.username)
256     return finalCallback(null)
257   })
258 }
259
260 // Wrapper to video add that retry the function if there is a database error
261 // We need this because we run the transaction in SERIALIZABLE isolation that can fail
262 function addVideoRetryWrapper (req, res, next) {
263   const options = {
264     arguments: [ req, res, req.files.videofile[0] ],
265     errorMessage: 'Cannot insert the video with many retries.'
266   }
267
268   databaseUtils.retryTransactionWrapper(addVideo, options, function (err) {
269     if (err) return next(err)
270
271     // TODO : include Location of the new video -> 201
272     return res.type('json').status(204).end()
273   })
274 }
275
276 function addVideo (req, res, videoFile, finalCallback) {
277   const videoInfos = req.body
278
279   waterfall([
280
281     databaseUtils.startSerializableTransaction,
282
283     function findOrCreateAuthor (t, callback) {
284       const user = res.locals.oauth.token.User
285
286       const name = user.username
287       // null because it is OUR pod
288       const podId = null
289       const userId = user.id
290
291       db.Author.findOrCreateAuthor(name, podId, userId, t, function (err, authorInstance) {
292         return callback(err, t, authorInstance)
293       })
294     },
295
296     function findOrCreateTags (t, author, callback) {
297       const tags = videoInfos.tags
298
299       db.Tag.findOrCreateTags(tags, t, function (err, tagInstances) {
300         return callback(err, t, author, tagInstances)
301       })
302     },
303
304     function createVideoObject (t, author, tagInstances, callback) {
305       const videoData = {
306         name: videoInfos.name,
307         remoteId: null,
308         extname: path.extname(videoFile.filename),
309         category: videoInfos.category,
310         description: videoInfos.description,
311         duration: videoFile.duration,
312         authorId: author.id
313       }
314
315       const video = db.Video.build(videoData)
316
317       return callback(null, t, author, tagInstances, video)
318     },
319
320      // Set the videoname the same as the id
321     function renameVideoFile (t, author, tagInstances, video, callback) {
322       const videoDir = constants.CONFIG.STORAGE.VIDEOS_DIR
323       const source = path.join(videoDir, videoFile.filename)
324       const destination = path.join(videoDir, video.getVideoFilename())
325
326       fs.rename(source, destination, function (err) {
327         if (err) return callback(err)
328
329         // This is important in case if there is another attempt
330         videoFile.filename = video.getVideoFilename()
331         return callback(null, t, author, tagInstances, video)
332       })
333     },
334
335     function insertVideoIntoDB (t, author, tagInstances, video, callback) {
336       const options = { transaction: t }
337
338       // Add tags association
339       video.save(options).asCallback(function (err, videoCreated) {
340         if (err) return callback(err)
341
342         // Do not forget to add Author informations to the created video
343         videoCreated.Author = author
344
345         return callback(err, t, tagInstances, videoCreated)
346       })
347     },
348
349     function associateTagsToVideo (t, tagInstances, video, callback) {
350       const options = { transaction: t }
351
352       video.setTags(tagInstances, options).asCallback(function (err) {
353         video.Tags = tagInstances
354
355         return callback(err, t, video)
356       })
357     },
358
359     function sendToFriends (t, video, callback) {
360       video.toAddRemoteJSON(function (err, remoteVideo) {
361         if (err) return callback(err)
362
363         // Now we'll add the video's meta data to our friends
364         friends.addVideoToFriends(remoteVideo, t, function (err) {
365           return callback(err, t)
366         })
367       })
368     },
369
370     databaseUtils.commitTransaction
371
372   ], function andFinally (err, t) {
373     if (err) {
374       // This is just a debug because we will retry the insert
375       logger.debug('Cannot insert the video.', { error: err })
376       return databaseUtils.rollbackTransaction(err, t, finalCallback)
377     }
378
379     logger.info('Video with name %s created.', videoInfos.name)
380     return finalCallback(null)
381   })
382 }
383
384 function updateVideoRetryWrapper (req, res, next) {
385   const options = {
386     arguments: [ req, res ],
387     errorMessage: 'Cannot update the video with many retries.'
388   }
389
390   databaseUtils.retryTransactionWrapper(updateVideo, options, function (err) {
391     if (err) return next(err)
392
393     // TODO : include Location of the new video -> 201
394     return res.type('json').status(204).end()
395   })
396 }
397
398 function updateVideo (req, res, finalCallback) {
399   const videoInstance = res.locals.video
400   const videoFieldsSave = videoInstance.toJSON()
401   const videoInfosToUpdate = req.body
402
403   waterfall([
404
405     databaseUtils.startSerializableTransaction,
406
407     function findOrCreateTags (t, callback) {
408       if (videoInfosToUpdate.tags) {
409         db.Tag.findOrCreateTags(videoInfosToUpdate.tags, t, function (err, tagInstances) {
410           return callback(err, t, tagInstances)
411         })
412       } else {
413         return callback(null, t, null)
414       }
415     },
416
417     function updateVideoIntoDB (t, tagInstances, callback) {
418       const options = {
419         transaction: t
420       }
421
422       if (videoInfosToUpdate.name) videoInstance.set('name', videoInfosToUpdate.name)
423       if (videoInfosToUpdate.category) videoInstance.set('category', videoInfosToUpdate.category)
424       if (videoInfosToUpdate.description) videoInstance.set('description', videoInfosToUpdate.description)
425
426       videoInstance.save(options).asCallback(function (err) {
427         return callback(err, t, tagInstances)
428       })
429     },
430
431     function associateTagsToVideo (t, tagInstances, callback) {
432       if (tagInstances) {
433         const options = { transaction: t }
434
435         videoInstance.setTags(tagInstances, options).asCallback(function (err) {
436           videoInstance.Tags = tagInstances
437
438           return callback(err, t)
439         })
440       } else {
441         return callback(null, t)
442       }
443     },
444
445     function sendToFriends (t, callback) {
446       const json = videoInstance.toUpdateRemoteJSON()
447
448       // Now we'll update the video's meta data to our friends
449       friends.updateVideoToFriends(json, t, function (err) {
450         return callback(err, t)
451       })
452     },
453
454     databaseUtils.commitTransaction
455
456   ], function andFinally (err, t) {
457     if (err) {
458       logger.debug('Cannot update the video.', { error: err })
459
460       // Force fields we want to update
461       // If the transaction is retried, sequelize will think the object has not changed
462       // So it will skip the SQL request, even if the last one was ROLLBACKed!
463       Object.keys(videoFieldsSave).forEach(function (key) {
464         const value = videoFieldsSave[key]
465         videoInstance.set(key, value)
466       })
467
468       return databaseUtils.rollbackTransaction(err, t, finalCallback)
469     }
470
471     logger.info('Video with name %s updated.', videoInfosToUpdate.name)
472     return finalCallback(null)
473   })
474 }
475
476 function getVideo (req, res, next) {
477   const videoInstance = res.locals.video
478
479   if (videoInstance.isOwned()) {
480     // The increment is done directly in the database, not using the instance value
481     videoInstance.increment('views').asCallback(function (err) {
482       if (err) {
483         logger.error('Cannot add view to video %d.', videoInstance.id)
484         return
485       }
486
487       // FIXME: make a real view system
488       // For example, only add a view when a user watch a video during 30s etc
489       const qaduParams = {
490         videoId: videoInstance.id,
491         type: constants.REQUEST_VIDEO_QADU_TYPES.VIEWS
492       }
493       friends.quickAndDirtyUpdateVideoToFriends(qaduParams)
494     })
495   } else {
496     // Just send the event to our friends
497     const eventParams = {
498       videoId: videoInstance.id,
499       type: constants.REQUEST_VIDEO_EVENT_TYPES.VIEWS
500     }
501     friends.addEventToRemoteVideo(eventParams)
502   }
503
504   // Do not wait the view system
505   res.json(videoInstance.toFormatedJSON())
506 }
507
508 function listVideos (req, res, next) {
509   db.Video.listForApi(req.query.start, req.query.count, req.query.sort, function (err, videosList, videosTotal) {
510     if (err) return next(err)
511
512     res.json(utils.getFormatedObjects(videosList, videosTotal))
513   })
514 }
515
516 function removeVideo (req, res, next) {
517   const videoInstance = res.locals.video
518
519   videoInstance.destroy().asCallback(function (err) {
520     if (err) {
521       logger.error('Errors when removed the video.', { error: err })
522       return next(err)
523     }
524
525     return res.type('json').status(204).end()
526   })
527 }
528
529 function searchVideos (req, res, next) {
530   db.Video.searchAndPopulateAuthorAndPodAndTags(
531     req.params.value, req.query.field, req.query.start, req.query.count, req.query.sort,
532     function (err, videosList, videosTotal) {
533       if (err) return next(err)
534
535       res.json(utils.getFormatedObjects(videosList, videosTotal))
536     }
537   )
538 }
539
540 function listVideoAbuses (req, res, next) {
541   db.VideoAbuse.listForApi(req.query.start, req.query.count, req.query.sort, function (err, abusesList, abusesTotal) {
542     if (err) return next(err)
543
544     res.json(utils.getFormatedObjects(abusesList, abusesTotal))
545   })
546 }
547
548 function reportVideoAbuseRetryWrapper (req, res, next) {
549   const options = {
550     arguments: [ req, res ],
551     errorMessage: 'Cannot report abuse to the video with many retries.'
552   }
553
554   databaseUtils.retryTransactionWrapper(reportVideoAbuse, options, function (err) {
555     if (err) return next(err)
556
557     return res.type('json').status(204).end()
558   })
559 }
560
561 function reportVideoAbuse (req, res, finalCallback) {
562   const videoInstance = res.locals.video
563   const reporterUsername = res.locals.oauth.token.User.username
564
565   const abuse = {
566     reporterUsername,
567     reason: req.body.reason,
568     videoId: videoInstance.id,
569     reporterPodId: null // This is our pod that reported this abuse
570   }
571
572   waterfall([
573
574     databaseUtils.startSerializableTransaction,
575
576     function createAbuse (t, callback) {
577       db.VideoAbuse.create(abuse).asCallback(function (err, abuse) {
578         return callback(err, t, abuse)
579       })
580     },
581
582     function sendToFriendsIfNeeded (t, abuse, callback) {
583       // We send the information to the destination pod
584       if (videoInstance.isOwned() === false) {
585         const reportData = {
586           reporterUsername,
587           reportReason: abuse.reason,
588           videoRemoteId: videoInstance.remoteId
589         }
590
591         friends.reportAbuseVideoToFriend(reportData, videoInstance)
592       }
593
594       return callback(null, t)
595     },
596
597     databaseUtils.commitTransaction
598
599   ], function andFinally (err, t) {
600     if (err) {
601       logger.debug('Cannot update the video.', { error: err })
602       return databaseUtils.rollbackTransaction(err, t, finalCallback)
603     }
604
605     logger.info('Abuse report for video %s created.', videoInstance.name)
606     return finalCallback(null)
607   })
608 }