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