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