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