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