Server: Add postgresql indexes
[oweals/peertube.git] / server / models / video.js
1 'use strict'
2
3 const createTorrent = require('create-torrent')
4 const ffmpeg = require('fluent-ffmpeg')
5 const fs = require('fs')
6 const magnetUtil = require('magnet-uri')
7 const map = require('lodash/map')
8 const parallel = require('async/parallel')
9 const parseTorrent = require('parse-torrent')
10 const pathUtils = require('path')
11 const values = require('lodash/values')
12
13 const constants = require('../initializers/constants')
14 const logger = require('../helpers/logger')
15 const modelUtils = require('./utils')
16 const customVideosValidators = require('../helpers/custom-validators').videos
17
18 // ---------------------------------------------------------------------------
19
20 module.exports = function (sequelize, DataTypes) {
21   // TODO: add indexes on searchable columns
22   const Video = sequelize.define('Video',
23     {
24       id: {
25         type: DataTypes.UUID,
26         defaultValue: DataTypes.UUIDV4,
27         primaryKey: true,
28         validate: {
29           isUUID: 4
30         }
31       },
32       name: {
33         type: DataTypes.STRING,
34         allowNull: false,
35         validate: {
36           nameValid: function (value) {
37             const res = customVideosValidators.isVideoNameValid(value)
38             if (res === false) throw new Error('Video name is not valid.')
39           }
40         }
41       },
42       extname: {
43         type: DataTypes.ENUM(values(constants.CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
44         allowNull: false
45       },
46       remoteId: {
47         type: DataTypes.UUID,
48         allowNull: true,
49         validate: {
50           isUUID: 4
51         }
52       },
53       description: {
54         type: DataTypes.STRING,
55         allowNull: false,
56         validate: {
57           descriptionValid: function (value) {
58             const res = customVideosValidators.isVideoDescriptionValid(value)
59             if (res === false) throw new Error('Video description is not valid.')
60           }
61         }
62       },
63       infoHash: {
64         type: DataTypes.STRING,
65         allowNull: false,
66         validate: {
67           infoHashValid: function (value) {
68             const res = customVideosValidators.isVideoInfoHashValid(value)
69             if (res === false) throw new Error('Video info hash is not valid.')
70           }
71         }
72       },
73       duration: {
74         type: DataTypes.INTEGER,
75         allowNull: false,
76         validate: {
77           durationValid: function (value) {
78             const res = customVideosValidators.isVideoDurationValid(value)
79             if (res === false) throw new Error('Video duration is not valid.')
80           }
81         }
82       }
83     },
84     {
85       indexes: [
86         {
87           fields: [ 'authorId' ]
88         },
89         {
90           fields: [ 'remoteId' ]
91         },
92         {
93           fields: [ 'name' ]
94         },
95         {
96           fields: [ 'createdAt' ]
97         },
98         {
99           fields: [ 'duration' ]
100         },
101         {
102           fields: [ 'infoHash' ]
103         }
104       ],
105       classMethods: {
106         associate,
107
108         generateThumbnailFromBase64,
109         getDurationFromFile,
110         list,
111         listForApi,
112         listByHostAndRemoteId,
113         listOwnedAndPopulateAuthorAndTags,
114         listOwnedByAuthor,
115         load,
116         loadAndPopulateAuthor,
117         loadAndPopulateAuthorAndPodAndTags,
118         searchAndPopulateAuthorAndPodAndTags
119       },
120       instanceMethods: {
121         generateMagnetUri,
122         getVideoFilename,
123         getThumbnailName,
124         getPreviewName,
125         getTorrentName,
126         isOwned,
127         toFormatedJSON,
128         toRemoteJSON
129       },
130       hooks: {
131         beforeValidate,
132         beforeCreate,
133         afterDestroy
134       }
135     }
136   )
137
138   return Video
139 }
140
141 function beforeValidate (video, options, next) {
142   if (video.isOwned()) {
143     // 40 hexa length
144     video.infoHash = '0123456789abcdef0123456789abcdef01234567'
145   }
146
147   return next(null)
148 }
149
150 function beforeCreate (video, options, next) {
151   const tasks = []
152
153   if (video.isOwned()) {
154     const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
155
156     tasks.push(
157       // TODO: refractoring
158       function (callback) {
159         const options = {
160           announceList: [
161             [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
162           ],
163           urlList: [
164             constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getVideoFilename()
165           ]
166         }
167
168         createTorrent(videoPath, options, function (err, torrent) {
169           if (err) return callback(err)
170
171           fs.writeFile(constants.CONFIG.STORAGE.TORRENTS_DIR + video.getTorrentName(), torrent, function (err) {
172             if (err) return callback(err)
173
174             const parsedTorrent = parseTorrent(torrent)
175             video.set('infoHash', parsedTorrent.infoHash)
176             video.validate().asCallback(callback)
177           })
178         })
179       },
180       function (callback) {
181         createThumbnail(video, videoPath, callback)
182       },
183       function (callback) {
184         createPreview(video, videoPath, callback)
185       }
186     )
187
188     return parallel(tasks, next)
189   }
190
191   return next()
192 }
193
194 function afterDestroy (video, options, next) {
195   const tasks = []
196
197   tasks.push(
198     function (callback) {
199       removeThumbnail(video, callback)
200     }
201   )
202
203   if (video.isOwned()) {
204     tasks.push(
205       function (callback) {
206         removeFile(video, callback)
207       },
208       function (callback) {
209         removeTorrent(video, callback)
210       },
211       function (callback) {
212         removePreview(video, callback)
213       }
214     )
215   }
216
217   parallel(tasks, next)
218 }
219
220 // ------------------------------ METHODS ------------------------------
221
222 function associate (models) {
223   this.belongsTo(models.Author, {
224     foreignKey: {
225       name: 'authorId',
226       allowNull: false
227     },
228     onDelete: 'cascade'
229   })
230
231   this.belongsToMany(models.Tag, {
232     foreignKey: 'videoId',
233     through: models.VideoTag,
234     onDelete: 'cascade'
235   })
236 }
237
238 function generateMagnetUri () {
239   let baseUrlHttp, baseUrlWs
240
241   if (this.isOwned()) {
242     baseUrlHttp = constants.CONFIG.WEBSERVER.URL
243     baseUrlWs = constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT
244   } else {
245     baseUrlHttp = constants.REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
246     baseUrlWs = constants.REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
247   }
248
249   const xs = baseUrlHttp + constants.STATIC_PATHS.TORRENTS + this.getTorrentName()
250   const announce = baseUrlWs + '/tracker/socket'
251   const urlList = [ baseUrlHttp + constants.STATIC_PATHS.WEBSEED + this.getVideoFilename() ]
252
253   const magnetHash = {
254     xs,
255     announce,
256     urlList,
257     infoHash: this.infoHash,
258     name: this.name
259   }
260
261   return magnetUtil.encode(magnetHash)
262 }
263
264 function getVideoFilename () {
265   if (this.isOwned()) return this.id + this.extname
266
267   return this.remoteId + this.extname
268 }
269
270 function getThumbnailName () {
271   // We always have a copy of the thumbnail
272   return this.id + '.jpg'
273 }
274
275 function getPreviewName () {
276   const extension = '.jpg'
277
278   if (this.isOwned()) return this.id + extension
279
280   return this.remoteId + extension
281 }
282
283 function getTorrentName () {
284   const extension = '.torrent'
285
286   if (this.isOwned()) return this.id + extension
287
288   return this.remoteId + extension
289 }
290
291 function isOwned () {
292   return this.remoteId === null
293 }
294
295 function toFormatedJSON () {
296   let podHost
297
298   if (this.Author.Pod) {
299     podHost = this.Author.Pod.host
300   } else {
301     // It means it's our video
302     podHost = constants.CONFIG.WEBSERVER.HOST
303   }
304
305   const json = {
306     id: this.id,
307     name: this.name,
308     description: this.description,
309     podHost,
310     isLocal: this.isOwned(),
311     magnetUri: this.generateMagnetUri(),
312     author: this.Author.name,
313     duration: this.duration,
314     tags: map(this.Tags, 'name'),
315     thumbnailPath: constants.STATIC_PATHS.THUMBNAILS + '/' + this.getThumbnailName(),
316     createdAt: this.createdAt
317   }
318
319   return json
320 }
321
322 function toRemoteJSON (callback) {
323   const self = this
324
325   // Convert thumbnail to base64
326   const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
327   fs.readFile(thumbnailPath, function (err, thumbnailData) {
328     if (err) {
329       logger.error('Cannot read the thumbnail of the video')
330       return callback(err)
331     }
332
333     const remoteVideo = {
334       name: self.name,
335       description: self.description,
336       infoHash: self.infoHash,
337       remoteId: self.id,
338       author: self.Author.name,
339       duration: self.duration,
340       thumbnailBase64: new Buffer(thumbnailData).toString('base64'),
341       tags: map(self.Tags, 'name'),
342       createdAt: self.createdAt,
343       extname: self.extname
344     }
345
346     return callback(null, remoteVideo)
347   })
348 }
349
350 // ------------------------------ STATICS ------------------------------
351
352 function generateThumbnailFromBase64 (video, thumbnailData, callback) {
353   // Creating the thumbnail for a remote video
354
355   const thumbnailName = video.getThumbnailName()
356   const thumbnailPath = constants.CONFIG.STORAGE.THUMBNAILS_DIR + thumbnailName
357   fs.writeFile(thumbnailPath, thumbnailData, { encoding: 'base64' }, function (err) {
358     if (err) return callback(err)
359
360     return callback(null, thumbnailName)
361   })
362 }
363
364 function getDurationFromFile (videoPath, callback) {
365   ffmpeg.ffprobe(videoPath, function (err, metadata) {
366     if (err) return callback(err)
367
368     return callback(null, Math.floor(metadata.format.duration))
369   })
370 }
371
372 function list (callback) {
373   return this.find().asCallback()
374 }
375
376 function listForApi (start, count, sort, callback) {
377   const query = {
378     offset: start,
379     limit: count,
380     distinct: true, // For the count, a video can have many tags
381     order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ],
382     include: [
383       {
384         model: this.sequelize.models.Author,
385         include: [ { model: this.sequelize.models.Pod, required: false } ]
386       },
387
388       this.sequelize.models.Tag
389     ]
390   }
391
392   return this.findAndCountAll(query).asCallback(function (err, result) {
393     if (err) return callback(err)
394
395     return callback(null, result.rows, result.count)
396   })
397 }
398
399 function listByHostAndRemoteId (fromHost, remoteId, callback) {
400   const query = {
401     where: {
402       remoteId: remoteId
403     },
404     include: [
405       {
406         model: this.sequelize.models.Author,
407         include: [
408           {
409             model: this.sequelize.models.Pod,
410             required: true,
411             where: {
412               host: fromHost
413             }
414           }
415         ]
416       }
417     ]
418   }
419
420   return this.findAll(query).asCallback(callback)
421 }
422
423 function listOwnedAndPopulateAuthorAndTags (callback) {
424   // If remoteId is null this is *our* video
425   const query = {
426     where: {
427       remoteId: null
428     },
429     include: [ this.sequelize.models.Author, this.sequelize.models.Tag ]
430   }
431
432   return this.findAll(query).asCallback(callback)
433 }
434
435 function listOwnedByAuthor (author, callback) {
436   const query = {
437     where: {
438       remoteId: null
439     },
440     include: [
441       {
442         model: this.sequelize.models.Author,
443         where: {
444           name: author
445         }
446       }
447     ]
448   }
449
450   return this.findAll(query).asCallback(callback)
451 }
452
453 function load (id, callback) {
454   return this.findById(id).asCallback(callback)
455 }
456
457 function loadAndPopulateAuthor (id, callback) {
458   const options = {
459     include: [ this.sequelize.models.Author ]
460   }
461
462   return this.findById(id, options).asCallback(callback)
463 }
464
465 function loadAndPopulateAuthorAndPodAndTags (id, callback) {
466   const options = {
467     include: [
468       {
469         model: this.sequelize.models.Author,
470         include: [ { model: this.sequelize.models.Pod, required: false } ]
471       },
472       this.sequelize.models.Tag
473     ]
474   }
475
476   return this.findById(id, options).asCallback(callback)
477 }
478
479 function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) {
480   const podInclude = {
481     model: this.sequelize.models.Pod,
482     required: false
483   }
484
485   const authorInclude = {
486     model: this.sequelize.models.Author,
487     include: [
488       podInclude
489     ]
490   }
491
492   const tagInclude = {
493     model: this.sequelize.models.Tag
494   }
495
496   const query = {
497     where: {},
498     offset: start,
499     limit: count,
500     distinct: true, // For the count, a video can have many tags
501     order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ]
502   }
503
504   // Make an exact search with the magnet
505   if (field === 'magnetUri') {
506     const infoHash = magnetUtil.decode(value).infoHash
507     query.where.infoHash = infoHash
508   } else if (field === 'tags') {
509     const escapedValue = this.sequelize.escape('%' + value + '%')
510     query.where = {
511       id: {
512         $in: this.sequelize.literal(
513           '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')'
514         )
515       }
516     }
517   } else if (field === 'host') {
518     // FIXME: Include our pod? (not stored in the database)
519     podInclude.where = {
520       host: {
521         $like: '%' + value + '%'
522       }
523     }
524     podInclude.required = true
525   } else if (field === 'author') {
526     authorInclude.where = {
527       name: {
528         $like: '%' + value + '%'
529       }
530     }
531
532     // authorInclude.or = true
533   } else {
534     query.where[field] = {
535       $like: '%' + value + '%'
536     }
537   }
538
539   query.include = [
540     authorInclude, tagInclude
541   ]
542
543   if (tagInclude.where) {
544     // query.include.push([ this.sequelize.models.Tag ])
545   }
546
547   return this.findAndCountAll(query).asCallback(function (err, result) {
548     if (err) return callback(err)
549
550     return callback(null, result.rows, result.count)
551   })
552 }
553
554 // ---------------------------------------------------------------------------
555
556 function removeThumbnail (video, callback) {
557   fs.unlink(constants.CONFIG.STORAGE.THUMBNAILS_DIR + video.getThumbnailName(), callback)
558 }
559
560 function removeFile (video, callback) {
561   fs.unlink(constants.CONFIG.STORAGE.VIDEOS_DIR + video.getVideoFilename(), callback)
562 }
563
564 function removeTorrent (video, callback) {
565   fs.unlink(constants.CONFIG.STORAGE.TORRENTS_DIR + video.getTorrentName(), callback)
566 }
567
568 function removePreview (video, callback) {
569   // Same name than video thumnail
570   fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
571 }
572
573 function createPreview (video, videoPath, callback) {
574   generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback)
575 }
576
577 function createThumbnail (video, videoPath, callback) {
578   generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), constants.THUMBNAILS_SIZE, callback)
579 }
580
581 function generateImage (video, videoPath, folder, imageName, size, callback) {
582   const options = {
583     filename: imageName,
584     count: 1,
585     folder
586   }
587
588   if (!callback) {
589     callback = size
590   } else {
591     options.size = size
592   }
593
594   ffmpeg(videoPath)
595     .on('error', callback)
596     .on('end', function () {
597       callback(null, imageName)
598     })
599     .thumbnail(options)
600 }