47d3cad1de4791205a609242e214eae5801bc320
[oweals/peertube.git] / server / models / video / video.ts
1 import * as safeBuffer from 'safe-buffer'
2 const Buffer = safeBuffer.Buffer
3 import * as ffmpeg from 'fluent-ffmpeg'
4 import * as magnetUtil from 'magnet-uri'
5 import { map, values } from 'lodash'
6 import * as parseTorrent from 'parse-torrent'
7 import { join } from 'path'
8 import * as Sequelize from 'sequelize'
9 import * as Promise from 'bluebird'
10
11 import { database as db } from '../../initializers/database'
12 import { TagInstance } from './tag-interface'
13 import {
14   logger,
15   isVideoNameValid,
16   isVideoCategoryValid,
17   isVideoLicenceValid,
18   isVideoLanguageValid,
19   isVideoNSFWValid,
20   isVideoDescriptionValid,
21   isVideoInfoHashValid,
22   isVideoDurationValid,
23   readFileBufferPromise,
24   unlinkPromise,
25   renamePromise,
26   writeFilePromise,
27   createTorrentPromise
28 } from '../../helpers'
29 import {
30   CONSTRAINTS_FIELDS,
31   CONFIG,
32   REMOTE_SCHEME,
33   STATIC_PATHS,
34   VIDEO_CATEGORIES,
35   VIDEO_LICENCES,
36   VIDEO_LANGUAGES,
37   THUMBNAILS_SIZE
38 } from '../../initializers'
39 import { JobScheduler, removeVideoToFriends } from '../../lib'
40
41 import { addMethodsToModel, getSort } from '../utils'
42 import {
43   VideoInstance,
44   VideoAttributes,
45
46   VideoMethods
47 } from './video-interface'
48
49 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
50 let generateMagnetUri: VideoMethods.GenerateMagnetUri
51 let getVideoFilename: VideoMethods.GetVideoFilename
52 let getThumbnailName: VideoMethods.GetThumbnailName
53 let getPreviewName: VideoMethods.GetPreviewName
54 let getTorrentName: VideoMethods.GetTorrentName
55 let isOwned: VideoMethods.IsOwned
56 let toFormatedJSON: VideoMethods.ToFormatedJSON
57 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
58 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
59 let transcodeVideofile: VideoMethods.TranscodeVideofile
60
61 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
62 let getDurationFromFile: VideoMethods.GetDurationFromFile
63 let list: VideoMethods.List
64 let listForApi: VideoMethods.ListForApi
65 let loadByHostAndRemoteId: VideoMethods.LoadByHostAndRemoteId
66 let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
67 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
68 let load: VideoMethods.Load
69 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
70 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
71 let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
72
73 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
74   Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
75     {
76       id: {
77         type: DataTypes.UUID,
78         defaultValue: DataTypes.UUIDV4,
79         primaryKey: true,
80         validate: {
81           isUUID: 4
82         }
83       },
84       name: {
85         type: DataTypes.STRING,
86         allowNull: false,
87         validate: {
88           nameValid: function (value) {
89             const res = isVideoNameValid(value)
90             if (res === false) throw new Error('Video name is not valid.')
91           }
92         }
93       },
94       extname: {
95         type: DataTypes.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
96         allowNull: false
97       },
98       remoteId: {
99         type: DataTypes.UUID,
100         allowNull: true,
101         validate: {
102           isUUID: 4
103         }
104       },
105       category: {
106         type: DataTypes.INTEGER,
107         allowNull: false,
108         validate: {
109           categoryValid: function (value) {
110             const res = isVideoCategoryValid(value)
111             if (res === false) throw new Error('Video category is not valid.')
112           }
113         }
114       },
115       licence: {
116         type: DataTypes.INTEGER,
117         allowNull: false,
118         defaultValue: null,
119         validate: {
120           licenceValid: function (value) {
121             const res = isVideoLicenceValid(value)
122             if (res === false) throw new Error('Video licence is not valid.')
123           }
124         }
125       },
126       language: {
127         type: DataTypes.INTEGER,
128         allowNull: true,
129         validate: {
130           languageValid: function (value) {
131             const res = isVideoLanguageValid(value)
132             if (res === false) throw new Error('Video language is not valid.')
133           }
134         }
135       },
136       nsfw: {
137         type: DataTypes.BOOLEAN,
138         allowNull: false,
139         validate: {
140           nsfwValid: function (value) {
141             const res = isVideoNSFWValid(value)
142             if (res === false) throw new Error('Video nsfw attribute is not valid.')
143           }
144         }
145       },
146       description: {
147         type: DataTypes.STRING,
148         allowNull: false,
149         validate: {
150           descriptionValid: function (value) {
151             const res = isVideoDescriptionValid(value)
152             if (res === false) throw new Error('Video description is not valid.')
153           }
154         }
155       },
156       infoHash: {
157         type: DataTypes.STRING,
158         allowNull: false,
159         validate: {
160           infoHashValid: function (value) {
161             const res = isVideoInfoHashValid(value)
162             if (res === false) throw new Error('Video info hash is not valid.')
163           }
164         }
165       },
166       duration: {
167         type: DataTypes.INTEGER,
168         allowNull: false,
169         validate: {
170           durationValid: function (value) {
171             const res = isVideoDurationValid(value)
172             if (res === false) throw new Error('Video duration is not valid.')
173           }
174         }
175       },
176       views: {
177         type: DataTypes.INTEGER,
178         allowNull: false,
179         defaultValue: 0,
180         validate: {
181           min: 0,
182           isInt: true
183         }
184       },
185       likes: {
186         type: DataTypes.INTEGER,
187         allowNull: false,
188         defaultValue: 0,
189         validate: {
190           min: 0,
191           isInt: true
192         }
193       },
194       dislikes: {
195         type: DataTypes.INTEGER,
196         allowNull: false,
197         defaultValue: 0,
198         validate: {
199           min: 0,
200           isInt: true
201         }
202       }
203     },
204     {
205       indexes: [
206         {
207           fields: [ 'authorId' ]
208         },
209         {
210           fields: [ 'remoteId' ]
211         },
212         {
213           fields: [ 'name' ]
214         },
215         {
216           fields: [ 'createdAt' ]
217         },
218         {
219           fields: [ 'duration' ]
220         },
221         {
222           fields: [ 'infoHash' ]
223         },
224         {
225           fields: [ 'views' ]
226         },
227         {
228           fields: [ 'likes' ]
229         }
230       ],
231       hooks: {
232         beforeValidate,
233         beforeCreate,
234         afterDestroy
235       }
236     }
237   )
238
239   const classMethods = [
240     associate,
241
242     generateThumbnailFromData,
243     getDurationFromFile,
244     list,
245     listForApi,
246     listOwnedAndPopulateAuthorAndTags,
247     listOwnedByAuthor,
248     load,
249     loadByHostAndRemoteId,
250     loadAndPopulateAuthor,
251     loadAndPopulateAuthorAndPodAndTags,
252     searchAndPopulateAuthorAndPodAndTags,
253     removeFromBlacklist
254   ]
255   const instanceMethods = [
256     generateMagnetUri,
257     getVideoFilename,
258     getThumbnailName,
259     getPreviewName,
260     getTorrentName,
261     isOwned,
262     toFormatedJSON,
263     toAddRemoteJSON,
264     toUpdateRemoteJSON,
265     transcodeVideofile
266   ]
267   addMethodsToModel(Video, classMethods, instanceMethods)
268
269   return Video
270 }
271
272 function beforeValidate (video: VideoInstance) {
273   // Put a fake infoHash if it does not exists yet
274   if (video.isOwned() && !video.infoHash) {
275     // 40 hexa length
276     video.infoHash = '0123456789abcdef0123456789abcdef01234567'
277   }
278 }
279
280 function beforeCreate (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
281   if (video.isOwned()) {
282     const videoPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
283     const tasks = []
284
285     tasks.push(
286       createTorrentFromVideo(video, videoPath),
287       createThumbnail(video, videoPath),
288       createPreview(video, videoPath)
289     )
290
291     if (CONFIG.TRANSCODING.ENABLED === true) {
292       const dataInput = {
293         id: video.id
294       }
295
296       tasks.push(
297         JobScheduler.Instance.createJob(options.transaction, 'videoTranscoder', dataInput)
298       )
299     }
300
301     return Promise.all(tasks)
302   }
303
304   return Promise.resolve()
305 }
306
307 function afterDestroy (video: VideoInstance) {
308   const tasks = []
309
310   tasks.push(
311     removeThumbnail(video)
312   )
313
314   if (video.isOwned()) {
315     const removeVideoToFriendsParams = {
316       remoteId: video.id
317     }
318
319     tasks.push(
320       removeFile(video),
321       removeTorrent(video),
322       removePreview(video),
323       removeVideoToFriends(removeVideoToFriendsParams)
324     )
325   }
326
327   return Promise.all(tasks)
328 }
329
330 // ------------------------------ METHODS ------------------------------
331
332 function associate (models) {
333   Video.belongsTo(models.Author, {
334     foreignKey: {
335       name: 'authorId',
336       allowNull: false
337     },
338     onDelete: 'cascade'
339   })
340
341   Video.belongsToMany(models.Tag, {
342     foreignKey: 'videoId',
343     through: models.VideoTag,
344     onDelete: 'cascade'
345   })
346
347   Video.hasMany(models.VideoAbuse, {
348     foreignKey: {
349       name: 'videoId',
350       allowNull: false
351     },
352     onDelete: 'cascade'
353   })
354 }
355
356 generateMagnetUri = function (this: VideoInstance) {
357   let baseUrlHttp
358   let baseUrlWs
359
360   if (this.isOwned()) {
361     baseUrlHttp = CONFIG.WEBSERVER.URL
362     baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
363   } else {
364     baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
365     baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
366   }
367
368   const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentName()
369   const announce = [ baseUrlWs + '/tracker/socket' ]
370   const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename() ]
371
372   const magnetHash = {
373     xs,
374     announce,
375     urlList,
376     infoHash: this.infoHash,
377     name: this.name
378   }
379
380   return magnetUtil.encode(magnetHash)
381 }
382
383 getVideoFilename = function (this: VideoInstance) {
384   if (this.isOwned()) return this.id + this.extname
385
386   return this.remoteId + this.extname
387 }
388
389 getThumbnailName = function (this: VideoInstance) {
390   // We always have a copy of the thumbnail
391   return this.id + '.jpg'
392 }
393
394 getPreviewName = function (this: VideoInstance) {
395   const extension = '.jpg'
396
397   if (this.isOwned()) return this.id + extension
398
399   return this.remoteId + extension
400 }
401
402 getTorrentName = function (this: VideoInstance) {
403   const extension = '.torrent'
404
405   if (this.isOwned()) return this.id + extension
406
407   return this.remoteId + extension
408 }
409
410 isOwned = function (this: VideoInstance) {
411   return this.remoteId === null
412 }
413
414 toFormatedJSON = function (this: VideoInstance) {
415   let podHost
416
417   if (this.Author.Pod) {
418     podHost = this.Author.Pod.host
419   } else {
420     // It means it's our video
421     podHost = CONFIG.WEBSERVER.HOST
422   }
423
424   // Maybe our pod is not up to date and there are new categories since our version
425   let categoryLabel = VIDEO_CATEGORIES[this.category]
426   if (!categoryLabel) categoryLabel = 'Misc'
427
428   // Maybe our pod is not up to date and there are new licences since our version
429   let licenceLabel = VIDEO_LICENCES[this.licence]
430   if (!licenceLabel) licenceLabel = 'Unknown'
431
432   // Language is an optional attribute
433   let languageLabel = VIDEO_LANGUAGES[this.language]
434   if (!languageLabel) languageLabel = 'Unknown'
435
436   const json = {
437     id: this.id,
438     name: this.name,
439     category: this.category,
440     categoryLabel,
441     licence: this.licence,
442     licenceLabel,
443     language: this.language,
444     languageLabel,
445     nsfw: this.nsfw,
446     description: this.description,
447     podHost,
448     isLocal: this.isOwned(),
449     magnetUri: this.generateMagnetUri(),
450     author: this.Author.name,
451     duration: this.duration,
452     views: this.views,
453     likes: this.likes,
454     dislikes: this.dislikes,
455     tags: map<TagInstance, string>(this.Tags, 'name'),
456     thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
457     createdAt: this.createdAt,
458     updatedAt: this.updatedAt
459   }
460
461   return json
462 }
463
464 toAddRemoteJSON = function (this: VideoInstance) {
465   // Get thumbnail data to send to the other pod
466   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
467
468   return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
469     const remoteVideo = {
470       name: this.name,
471       category: this.category,
472       licence: this.licence,
473       language: this.language,
474       nsfw: this.nsfw,
475       description: this.description,
476       infoHash: this.infoHash,
477       remoteId: this.id,
478       author: this.Author.name,
479       duration: this.duration,
480       thumbnailData: thumbnailData.toString('binary'),
481       tags: map<TagInstance, string>(this.Tags, 'name'),
482       createdAt: this.createdAt,
483       updatedAt: this.updatedAt,
484       extname: this.extname,
485       views: this.views,
486       likes: this.likes,
487       dislikes: this.dislikes
488     }
489
490     return remoteVideo
491   })
492 }
493
494 toUpdateRemoteJSON = function (this: VideoInstance) {
495   const json = {
496     name: this.name,
497     category: this.category,
498     licence: this.licence,
499     language: this.language,
500     nsfw: this.nsfw,
501     description: this.description,
502     infoHash: this.infoHash,
503     remoteId: this.id,
504     author: this.Author.name,
505     duration: this.duration,
506     tags: map<TagInstance, string>(this.Tags, 'name'),
507     createdAt: this.createdAt,
508     updatedAt: this.updatedAt,
509     extname: this.extname,
510     views: this.views,
511     likes: this.likes,
512     dislikes: this.dislikes
513   }
514
515   return json
516 }
517
518 transcodeVideofile = function (this: VideoInstance) {
519   const video = this
520
521   const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
522   const newExtname = '.mp4'
523   const videoInputPath = join(videosDirectory, video.getVideoFilename())
524   const videoOutputPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
525
526   return new Promise<void>((res, rej) => {
527     ffmpeg(videoInputPath)
528       .output(videoOutputPath)
529       .videoCodec('libx264')
530       .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
531       .outputOption('-movflags faststart')
532       .on('error', rej)
533       .on('end', () => {
534
535         return unlinkPromise(videoInputPath)
536           .then(() => {
537             // Important to do this before getVideoFilename() to take in account the new file extension
538             video.set('extname', newExtname)
539
540             const newVideoPath = join(videosDirectory, video.getVideoFilename())
541             return renamePromise(videoOutputPath, newVideoPath)
542           })
543           .then(() => {
544             const newVideoPath = join(videosDirectory, video.getVideoFilename())
545             return createTorrentFromVideo(video, newVideoPath)
546           })
547           .then(() => {
548             return video.save()
549           })
550           .then(() => {
551             return res()
552           })
553           .catch(err => {
554             // Autodesctruction...
555             video.destroy().asCallback(function (err) {
556               if (err) logger.error('Cannot destruct video after transcoding failure.', err)
557             })
558
559             return rej(err)
560           })
561       })
562       .run()
563   })
564 }
565
566 // ------------------------------ STATICS ------------------------------
567
568 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
569   // Creating the thumbnail for a remote video
570
571   const thumbnailName = video.getThumbnailName()
572   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
573   return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
574     return thumbnailName
575   })
576 }
577
578 getDurationFromFile = function (videoPath: string) {
579   return new Promise<number>((res, rej) => {
580     ffmpeg.ffprobe(videoPath, function (err, metadata) {
581       if (err) return rej(err)
582
583       return res(Math.floor(metadata.format.duration))
584     })
585   })
586 }
587
588 list = function () {
589   return Video.findAll()
590 }
591
592 listForApi = function (start: number, count: number, sort: string) {
593   // Exclude Blakclisted videos from the list
594   const query = {
595     distinct: true,
596     offset: start,
597     limit: count,
598     order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
599     include: [
600       {
601         model: Video['sequelize'].models.Author,
602         include: [ { model: Video['sequelize'].models.Pod, required: false } ]
603       },
604
605       Video['sequelize'].models.Tag
606     ],
607     where: createBaseVideosWhere()
608   }
609
610   return Video.findAndCountAll(query).then(({ rows, count }) => {
611     return {
612       data: rows,
613       total: count
614     }
615   })
616 }
617
618 loadByHostAndRemoteId = function (fromHost: string, remoteId: string) {
619   const query = {
620     where: {
621       remoteId: remoteId
622     },
623     include: [
624       {
625         model: Video['sequelize'].models.Author,
626         include: [
627           {
628             model: Video['sequelize'].models.Pod,
629             required: true,
630             where: {
631               host: fromHost
632             }
633           }
634         ]
635       }
636     ]
637   }
638
639   return Video.findOne(query)
640 }
641
642 listOwnedAndPopulateAuthorAndTags = function () {
643   // If remoteId is null this is *our* video
644   const query = {
645     where: {
646       remoteId: null
647     },
648     include: [ Video['sequelize'].models.Author, Video['sequelize'].models.Tag ]
649   }
650
651   return Video.findAll(query)
652 }
653
654 listOwnedByAuthor = function (author: string) {
655   const query = {
656     where: {
657       remoteId: null
658     },
659     include: [
660       {
661         model: Video['sequelize'].models.Author,
662         where: {
663           name: author
664         }
665       }
666     ]
667   }
668
669   return Video.findAll(query)
670 }
671
672 load = function (id: string) {
673   return Video.findById(id)
674 }
675
676 loadAndPopulateAuthor = function (id: string) {
677   const options = {
678     include: [ Video['sequelize'].models.Author ]
679   }
680
681   return Video.findById(id, options)
682 }
683
684 loadAndPopulateAuthorAndPodAndTags = function (id: string) {
685   const options = {
686     include: [
687       {
688         model: Video['sequelize'].models.Author,
689         include: [ { model: Video['sequelize'].models.Pod, required: false } ]
690       },
691       Video['sequelize'].models.Tag
692     ]
693   }
694
695   return Video.findById(id, options)
696 }
697
698 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
699   const podInclude: any = {
700     model: Video['sequelize'].models.Pod,
701     required: false
702   }
703
704   const authorInclude: any = {
705     model: Video['sequelize'].models.Author,
706     include: [
707       podInclude
708     ]
709   }
710
711   const tagInclude: any = {
712     model: Video['sequelize'].models.Tag
713   }
714
715   const query: any = {
716     distinct: true,
717     where: createBaseVideosWhere(),
718     offset: start,
719     limit: count,
720     order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
721   }
722
723   // Make an exact search with the magnet
724   if (field === 'magnetUri') {
725     const infoHash = magnetUtil.decode(value).infoHash
726     query.where.infoHash = infoHash
727   } else if (field === 'tags') {
728     const escapedValue = Video['sequelize'].escape('%' + value + '%')
729     query.where.id.$in = Video['sequelize'].literal(
730       `(SELECT "VideoTags"."videoId"
731         FROM "Tags"
732         INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
733         WHERE name ILIKE ${escapedValue}
734        )`
735     )
736   } else if (field === 'host') {
737     // FIXME: Include our pod? (not stored in the database)
738     podInclude.where = {
739       host: {
740         $iLike: '%' + value + '%'
741       }
742     }
743     podInclude.required = true
744   } else if (field === 'author') {
745     authorInclude.where = {
746       name: {
747         $iLike: '%' + value + '%'
748       }
749     }
750
751     // authorInclude.or = true
752   } else {
753     query.where[field] = {
754       $iLike: '%' + value + '%'
755     }
756   }
757
758   query.include = [
759     authorInclude, tagInclude
760   ]
761
762   return Video.findAndCountAll(query).then(({ rows, count }) => {
763     return {
764       data: rows,
765       total: count
766     }
767   })
768 }
769
770 // ---------------------------------------------------------------------------
771
772 function createBaseVideosWhere () {
773   return {
774     id: {
775       $notIn: Video['sequelize'].literal(
776         '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
777       )
778     }
779   }
780 }
781
782 function removeThumbnail (video: VideoInstance) {
783   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
784   return unlinkPromise(thumbnailPath)
785 }
786
787 function removeFile (video: VideoInstance) {
788   const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
789   return unlinkPromise(filePath)
790 }
791
792 function removeTorrent (video: VideoInstance) {
793   const torrenPath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
794   return unlinkPromise(torrenPath)
795 }
796
797 function removePreview (video: VideoInstance) {
798   // Same name than video thumnail
799   return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName())
800 }
801
802 function createTorrentFromVideo (video: VideoInstance, videoPath: string) {
803   const options = {
804     announceList: [
805       [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
806     ],
807     urlList: [
808       CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + video.getVideoFilename()
809     ]
810   }
811
812   return createTorrentPromise(videoPath, options)
813     .then(torrent => {
814       const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
815       return writeFilePromise(filePath, torrent).then(() => torrent)
816     })
817     .then(torrent => {
818       const parsedTorrent = parseTorrent(torrent)
819       video.set('infoHash', parsedTorrent.infoHash)
820       return video.validate()
821     })
822 }
823
824 function createPreview (video: VideoInstance, videoPath: string) {
825   return generateImage(video, videoPath, CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), null)
826 }
827
828 function createThumbnail (video: VideoInstance, videoPath: string) {
829   return generateImage(video, videoPath, CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), THUMBNAILS_SIZE)
830 }
831
832 function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
833   const options: any = {
834     filename: imageName,
835     count: 1,
836     folder
837   }
838
839   if (size) {
840     options.size = size
841   }
842
843   return new Promise<string>((res, rej) => {
844     ffmpeg(videoPath)
845       .on('error', rej)
846       .on('end', function () {
847         return res(imageName)
848       })
849       .thumbnail(options)
850   })
851 }
852
853 function removeFromBlacklist (video: VideoInstance) {
854   // Find the blacklisted video
855   return db.BlacklistedVideo.loadByVideoId(video.id).then(video => {
856     // Not found the video, skip
857     if (!video) {
858       return null
859     }
860
861     // If we found the video, remove it from the blacklist
862     return video.destroy()
863   })
864 }