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