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