Fix log in after destroy hook
[oweals/peertube.git] / server / models / video / video.ts
1 import * as safeBuffer from 'safe-buffer'
2 const Buffer = safeBuffer.Buffer
3 import * as magnetUtil from 'magnet-uri'
4 import { map } from 'lodash'
5 import * as parseTorrent from 'parse-torrent'
6 import { join } from 'path'
7 import * as Sequelize from 'sequelize'
8 import * as Promise from 'bluebird'
9 import { maxBy } from 'lodash'
10
11 import { TagInstance } from './tag-interface'
12 import {
13   logger,
14   isVideoNameValid,
15   isVideoCategoryValid,
16   isVideoLicenceValid,
17   isVideoLanguageValid,
18   isVideoNSFWValid,
19   isVideoDescriptionValid,
20   isVideoDurationValid,
21   readFileBufferPromise,
22   unlinkPromise,
23   renamePromise,
24   writeFilePromise,
25   createTorrentPromise,
26   statPromise,
27   generateImageFromVideoFile,
28   transcode,
29   getVideoFileHeight
30 } from '../../helpers'
31 import {
32   CONFIG,
33   REMOTE_SCHEME,
34   STATIC_PATHS,
35   VIDEO_CATEGORIES,
36   VIDEO_LICENCES,
37   VIDEO_LANGUAGES,
38   THUMBNAILS_SIZE
39 } from '../../initializers'
40 import { removeVideoToFriends } from '../../lib'
41 import { VideoResolution } from '../../../shared'
42 import { VideoFileInstance, VideoFileModel } from './video-file-interface'
43
44 import { addMethodsToModel, getSort } from '../utils'
45 import {
46   VideoInstance,
47   VideoAttributes,
48
49   VideoMethods
50 } from './video-interface'
51 import { PREVIEWS_SIZE } from '../../initializers/constants'
52
53 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
54 let getOriginalFile: VideoMethods.GetOriginalFile
55 let getVideoFilename: VideoMethods.GetVideoFilename
56 let getThumbnailName: VideoMethods.GetThumbnailName
57 let getThumbnailPath: VideoMethods.GetThumbnailPath
58 let getPreviewName: VideoMethods.GetPreviewName
59 let getPreviewPath: VideoMethods.GetPreviewPath
60 let getTorrentFileName: VideoMethods.GetTorrentFileName
61 let isOwned: VideoMethods.IsOwned
62 let toFormattedJSON: VideoMethods.ToFormattedJSON
63 let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
64 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
65 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
66 let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
67 let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
68 let createPreview: VideoMethods.CreatePreview
69 let createThumbnail: VideoMethods.CreateThumbnail
70 let getVideoFilePath: VideoMethods.GetVideoFilePath
71 let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
72 let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
73 let getEmbedPath: VideoMethods.GetEmbedPath
74
75 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
76 let list: VideoMethods.List
77 let listForApi: VideoMethods.ListForApi
78 let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
79 let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
80 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
81 let load: VideoMethods.Load
82 let loadByUUID: VideoMethods.LoadByUUID
83 let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
84 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
85 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
86 let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
87 let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
88 let removeThumbnail: VideoMethods.RemoveThumbnail
89 let removePreview: VideoMethods.RemovePreview
90 let removeFile: VideoMethods.RemoveFile
91 let removeTorrent: VideoMethods.RemoveTorrent
92
93 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
94   Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
95     {
96       uuid: {
97         type: DataTypes.UUID,
98         defaultValue: DataTypes.UUIDV4,
99         allowNull: false,
100         validate: {
101           isUUID: 4
102         }
103       },
104       name: {
105         type: DataTypes.STRING,
106         allowNull: false,
107         validate: {
108           nameValid: value => {
109             const res = isVideoNameValid(value)
110             if (res === false) throw new Error('Video name is not valid.')
111           }
112         }
113       },
114       category: {
115         type: DataTypes.INTEGER,
116         allowNull: false,
117         validate: {
118           categoryValid: value => {
119             const res = isVideoCategoryValid(value)
120             if (res === false) throw new Error('Video category is not valid.')
121           }
122         }
123       },
124       licence: {
125         type: DataTypes.INTEGER,
126         allowNull: false,
127         defaultValue: null,
128         validate: {
129           licenceValid: value => {
130             const res = isVideoLicenceValid(value)
131             if (res === false) throw new Error('Video licence is not valid.')
132           }
133         }
134       },
135       language: {
136         type: DataTypes.INTEGER,
137         allowNull: true,
138         validate: {
139           languageValid: value => {
140             const res = isVideoLanguageValid(value)
141             if (res === false) throw new Error('Video language is not valid.')
142           }
143         }
144       },
145       nsfw: {
146         type: DataTypes.BOOLEAN,
147         allowNull: false,
148         validate: {
149           nsfwValid: value => {
150             const res = isVideoNSFWValid(value)
151             if (res === false) throw new Error('Video nsfw attribute is not valid.')
152           }
153         }
154       },
155       description: {
156         type: DataTypes.STRING,
157         allowNull: false,
158         validate: {
159           descriptionValid: value => {
160             const res = isVideoDescriptionValid(value)
161             if (res === false) throw new Error('Video description is not valid.')
162           }
163         }
164       },
165       duration: {
166         type: DataTypes.INTEGER,
167         allowNull: false,
168         validate: {
169           durationValid: value => {
170             const res = isVideoDurationValid(value)
171             if (res === false) throw new Error('Video duration is not valid.')
172           }
173         }
174       },
175       views: {
176         type: DataTypes.INTEGER,
177         allowNull: false,
178         defaultValue: 0,
179         validate: {
180           min: 0,
181           isInt: true
182         }
183       },
184       likes: {
185         type: DataTypes.INTEGER,
186         allowNull: false,
187         defaultValue: 0,
188         validate: {
189           min: 0,
190           isInt: true
191         }
192       },
193       dislikes: {
194         type: DataTypes.INTEGER,
195         allowNull: false,
196         defaultValue: 0,
197         validate: {
198           min: 0,
199           isInt: true
200         }
201       },
202       remote: {
203         type: DataTypes.BOOLEAN,
204         allowNull: false,
205         defaultValue: false
206       }
207     },
208     {
209       indexes: [
210         {
211           fields: [ 'name' ]
212         },
213         {
214           fields: [ 'createdAt' ]
215         },
216         {
217           fields: [ 'duration' ]
218         },
219         {
220           fields: [ 'views' ]
221         },
222         {
223           fields: [ 'likes' ]
224         },
225         {
226           fields: [ 'uuid' ]
227         },
228         {
229           fields: [ 'channelId' ]
230         }
231       ],
232       hooks: {
233         afterDestroy
234       }
235     }
236   )
237
238   const classMethods = [
239     associate,
240
241     generateThumbnailFromData,
242     list,
243     listForApi,
244     listOwnedAndPopulateAuthorAndTags,
245     listOwnedByAuthor,
246     load,
247     loadAndPopulateAuthor,
248     loadAndPopulateAuthorAndPodAndTags,
249     loadByHostAndUUID,
250     loadByUUID,
251     loadLocalVideoByUUID,
252     loadByUUIDAndPopulateAuthorAndPodAndTags,
253     searchAndPopulateAuthorAndPodAndTags
254   ]
255   const instanceMethods = [
256     createPreview,
257     createThumbnail,
258     createTorrentAndSetInfoHash,
259     getPreviewName,
260     getPreviewPath,
261     getThumbnailName,
262     getThumbnailPath,
263     getTorrentFileName,
264     getVideoFilename,
265     getVideoFilePath,
266     getOriginalFile,
267     isOwned,
268     removeFile,
269     removePreview,
270     removeThumbnail,
271     removeTorrent,
272     toAddRemoteJSON,
273     toFormattedJSON,
274     toFormattedDetailsJSON,
275     toUpdateRemoteJSON,
276     optimizeOriginalVideofile,
277     transcodeOriginalVideofile,
278     getOriginalFileHeight,
279     getEmbedPath
280   ]
281   addMethodsToModel(Video, classMethods, instanceMethods)
282
283   return Video
284 }
285
286 // ------------------------------ METHODS ------------------------------
287
288 function associate (models) {
289   Video.belongsTo(models.VideoChannel, {
290     foreignKey: {
291       name: 'channelId',
292       allowNull: false
293     },
294     onDelete: 'cascade'
295   })
296
297   Video.belongsToMany(models.Tag, {
298     foreignKey: 'videoId',
299     through: models.VideoTag,
300     onDelete: 'cascade'
301   })
302
303   Video.hasMany(models.VideoAbuse, {
304     foreignKey: {
305       name: 'videoId',
306       allowNull: false
307     },
308     onDelete: 'cascade'
309   })
310
311   Video.hasMany(models.VideoFile, {
312     foreignKey: {
313       name: 'videoId',
314       allowNull: false
315     },
316     onDelete: 'cascade'
317   })
318 }
319
320 function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
321   const tasks = []
322
323   tasks.push(
324     video.removeThumbnail()
325   )
326
327   if (video.isOwned()) {
328     const removeVideoToFriendsParams = {
329       uuid: video.uuid
330     }
331
332     tasks.push(
333       video.removePreview(),
334       removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
335     )
336
337     // Remove physical files and torrents
338     video.VideoFiles.forEach(file => {
339       tasks.push(video.removeFile(file))
340       tasks.push(video.removeTorrent(file))
341     })
342   }
343
344   return Promise.all(tasks)
345     .catch(err => {
346       logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
347     })
348 }
349
350 getOriginalFile = function (this: VideoInstance) {
351   if (Array.isArray(this.VideoFiles) === false) return undefined
352
353   // The original file is the file that have the higher resolution
354   return maxBy(this.VideoFiles, file => file.resolution)
355 }
356
357 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
358   return this.uuid + '-' + videoFile.resolution + videoFile.extname
359 }
360
361 getThumbnailName = function (this: VideoInstance) {
362   // We always have a copy of the thumbnail
363   const extension = '.jpg'
364   return this.uuid + extension
365 }
366
367 getPreviewName = function (this: VideoInstance) {
368   const extension = '.jpg'
369   return this.uuid + extension
370 }
371
372 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
373   const extension = '.torrent'
374   return this.uuid + '-' + videoFile.resolution + extension
375 }
376
377 isOwned = function (this: VideoInstance) {
378   return this.remote === false
379 }
380
381 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
382   const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
383
384   return generateImageFromVideoFile(
385     this.getVideoFilePath(videoFile),
386     CONFIG.STORAGE.PREVIEWS_DIR,
387     this.getPreviewName(),
388     imageSize
389   )
390 }
391
392 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
393   const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
394
395   return generateImageFromVideoFile(
396     this.getVideoFilePath(videoFile),
397     CONFIG.STORAGE.THUMBNAILS_DIR,
398     this.getThumbnailName(),
399     imageSize
400   )
401 }
402
403 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
404   return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
405 }
406
407 createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
408   const options = {
409     announceList: [
410       [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
411     ],
412     urlList: [
413       CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
414     ]
415   }
416
417   return createTorrentPromise(this.getVideoFilePath(videoFile), options)
418     .then(torrent => {
419       const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
420       logger.info('Creating torrent %s.', filePath)
421
422       return writeFilePromise(filePath, torrent).then(() => torrent)
423     })
424     .then(torrent => {
425       const parsedTorrent = parseTorrent(torrent)
426
427       videoFile.infoHash = parsedTorrent.infoHash
428     })
429 }
430
431 getEmbedPath = function (this: VideoInstance) {
432   return '/videos/embed/' + this.uuid
433 }
434
435 getThumbnailPath = function (this: VideoInstance) {
436   return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
437 }
438
439 getPreviewPath = function (this: VideoInstance) {
440   return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
441 }
442
443 toFormattedJSON = function (this: VideoInstance) {
444   let podHost
445
446   if (this.VideoChannel.Author.Pod) {
447     podHost = this.VideoChannel.Author.Pod.host
448   } else {
449     // It means it's our video
450     podHost = CONFIG.WEBSERVER.HOST
451   }
452
453   // Maybe our pod is not up to date and there are new categories since our version
454   let categoryLabel = VIDEO_CATEGORIES[this.category]
455   if (!categoryLabel) categoryLabel = 'Misc'
456
457   // Maybe our pod is not up to date and there are new licences since our version
458   let licenceLabel = VIDEO_LICENCES[this.licence]
459   if (!licenceLabel) licenceLabel = 'Unknown'
460
461   // Language is an optional attribute
462   let languageLabel = VIDEO_LANGUAGES[this.language]
463   if (!languageLabel) languageLabel = 'Unknown'
464
465   const json = {
466     id: this.id,
467     uuid: this.uuid,
468     name: this.name,
469     category: this.category,
470     categoryLabel,
471     licence: this.licence,
472     licenceLabel,
473     language: this.language,
474     languageLabel,
475     nsfw: this.nsfw,
476     description: this.description,
477     podHost,
478     isLocal: this.isOwned(),
479     author: this.VideoChannel.Author.name,
480     duration: this.duration,
481     views: this.views,
482     likes: this.likes,
483     dislikes: this.dislikes,
484     tags: map<TagInstance, string>(this.Tags, 'name'),
485     thumbnailPath: this.getThumbnailPath(),
486     previewPath: this.getPreviewPath(),
487     embedPath: this.getEmbedPath(),
488     createdAt: this.createdAt,
489     updatedAt: this.updatedAt
490   }
491
492   return json
493 }
494
495 toFormattedDetailsJSON = function (this: VideoInstance) {
496   let podHost
497
498   if (this.VideoChannel.Author.Pod) {
499     podHost = this.VideoChannel.Author.Pod.host
500   } else {
501     // It means it's our video
502     podHost = CONFIG.WEBSERVER.HOST
503   }
504
505   // Maybe our pod is not up to date and there are new categories since our version
506   let categoryLabel = VIDEO_CATEGORIES[this.category]
507   if (!categoryLabel) categoryLabel = 'Misc'
508
509   // Maybe our pod is not up to date and there are new licences since our version
510   let licenceLabel = VIDEO_LICENCES[this.licence]
511   if (!licenceLabel) licenceLabel = 'Unknown'
512
513   // Language is an optional attribute
514   let languageLabel = VIDEO_LANGUAGES[this.language]
515   if (!languageLabel) languageLabel = 'Unknown'
516
517   const json = {
518     id: this.id,
519     uuid: this.uuid,
520     name: this.name,
521     category: this.category,
522     categoryLabel,
523     licence: this.licence,
524     licenceLabel,
525     language: this.language,
526     languageLabel,
527     nsfw: this.nsfw,
528     description: this.description,
529     podHost,
530     isLocal: this.isOwned(),
531     author: this.VideoChannel.Author.name,
532     duration: this.duration,
533     views: this.views,
534     likes: this.likes,
535     dislikes: this.dislikes,
536     tags: map<TagInstance, string>(this.Tags, 'name'),
537     thumbnailPath: this.getThumbnailPath(),
538     previewPath: this.getPreviewPath(),
539     embedPath: this.getEmbedPath(),
540     createdAt: this.createdAt,
541     updatedAt: this.updatedAt,
542     channel: this.VideoChannel.toFormattedJSON(),
543     files: []
544   }
545
546   // Format and sort video files
547   const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
548   json.files = this.VideoFiles
549                    .map(videoFile => {
550                      let resolutionLabel = videoFile.resolution + 'p'
551
552                      const videoFileJson = {
553                        resolution: videoFile.resolution,
554                        resolutionLabel,
555                        magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
556                        size: videoFile.size,
557                        torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
558                        fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
559                      }
560
561                      return videoFileJson
562                    })
563                    .sort((a, b) => {
564                      if (a.resolution < b.resolution) return 1
565                      if (a.resolution === b.resolution) return 0
566                      return -1
567                    })
568
569   return json
570 }
571
572 toAddRemoteJSON = function (this: VideoInstance) {
573   // Get thumbnail data to send to the other pod
574   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
575
576   return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
577     const remoteVideo = {
578       uuid: this.uuid,
579       name: this.name,
580       category: this.category,
581       licence: this.licence,
582       language: this.language,
583       nsfw: this.nsfw,
584       description: this.description,
585       channelUUID: this.VideoChannel.uuid,
586       duration: this.duration,
587       thumbnailData: thumbnailData.toString('binary'),
588       tags: map<TagInstance, string>(this.Tags, 'name'),
589       createdAt: this.createdAt,
590       updatedAt: this.updatedAt,
591       views: this.views,
592       likes: this.likes,
593       dislikes: this.dislikes,
594       files: []
595     }
596
597     this.VideoFiles.forEach(videoFile => {
598       remoteVideo.files.push({
599         infoHash: videoFile.infoHash,
600         resolution: videoFile.resolution,
601         extname: videoFile.extname,
602         size: videoFile.size
603       })
604     })
605
606     return remoteVideo
607   })
608 }
609
610 toUpdateRemoteJSON = function (this: VideoInstance) {
611   const json = {
612     uuid: this.uuid,
613     name: this.name,
614     category: this.category,
615     licence: this.licence,
616     language: this.language,
617     nsfw: this.nsfw,
618     description: this.description,
619     duration: this.duration,
620     tags: map<TagInstance, string>(this.Tags, 'name'),
621     createdAt: this.createdAt,
622     updatedAt: this.updatedAt,
623     views: this.views,
624     likes: this.likes,
625     dislikes: this.dislikes,
626     files: []
627   }
628
629   this.VideoFiles.forEach(videoFile => {
630     json.files.push({
631       infoHash: videoFile.infoHash,
632       resolution: videoFile.resolution,
633       extname: videoFile.extname,
634       size: videoFile.size
635     })
636   })
637
638   return json
639 }
640
641 optimizeOriginalVideofile = function (this: VideoInstance) {
642   const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
643   const newExtname = '.mp4'
644   const inputVideoFile = this.getOriginalFile()
645   const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
646   const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
647
648   const transcodeOptions = {
649     inputPath: videoInputPath,
650     outputPath: videoOutputPath
651   }
652
653   return transcode(transcodeOptions)
654     .then(() => {
655       return unlinkPromise(videoInputPath)
656     })
657     .then(() => {
658       // Important to do this before getVideoFilename() to take in account the new file extension
659       inputVideoFile.set('extname', newExtname)
660
661       return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
662     })
663     .then(() => {
664       return statPromise(this.getVideoFilePath(inputVideoFile))
665     })
666     .then(stats => {
667       return inputVideoFile.set('size', stats.size)
668     })
669     .then(() => {
670       return this.createTorrentAndSetInfoHash(inputVideoFile)
671     })
672     .then(() => {
673       return inputVideoFile.save()
674     })
675     .then(() => {
676       return undefined
677     })
678     .catch(err => {
679       // Auto destruction...
680       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
681
682       throw err
683     })
684 }
685
686 transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
687   const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
688   const extname = '.mp4'
689
690   // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
691   const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
692
693   const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
694     resolution,
695     extname,
696     size: 0,
697     videoId: this.id
698   })
699   const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
700
701   const transcodeOptions = {
702     inputPath: videoInputPath,
703     outputPath: videoOutputPath,
704     resolution
705   }
706   return transcode(transcodeOptions)
707     .then(() => {
708       return statPromise(videoOutputPath)
709     })
710     .then(stats => {
711       newVideoFile.set('size', stats.size)
712
713       return undefined
714     })
715     .then(() => {
716       return this.createTorrentAndSetInfoHash(newVideoFile)
717     })
718     .then(() => {
719       return newVideoFile.save()
720     })
721     .then(() => {
722       return this.VideoFiles.push(newVideoFile)
723     })
724     .then(() => undefined)
725 }
726
727 getOriginalFileHeight = function (this: VideoInstance) {
728   const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
729
730   return getVideoFileHeight(originalFilePath)
731 }
732
733 removeThumbnail = function (this: VideoInstance) {
734   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
735   return unlinkPromise(thumbnailPath)
736 }
737
738 removePreview = function (this: VideoInstance) {
739   // Same name than video thumbnail
740   return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
741 }
742
743 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
744   const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
745   return unlinkPromise(filePath)
746 }
747
748 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
749   const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
750   return unlinkPromise(torrentPath)
751 }
752
753 // ------------------------------ STATICS ------------------------------
754
755 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
756   // Creating the thumbnail for a remote video
757
758   const thumbnailName = video.getThumbnailName()
759   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
760   return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
761     return thumbnailName
762   })
763 }
764
765 list = function () {
766   const query = {
767     include: [ Video['sequelize'].models.VideoFile ]
768   }
769
770   return Video.findAll(query)
771 }
772
773 listForApi = function (start: number, count: number, sort: string) {
774   // Exclude blacklisted videos from the list
775   const query = {
776     distinct: true,
777     offset: start,
778     limit: count,
779     order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
780     include: [
781       {
782         model: Video['sequelize'].models.VideoChannel,
783         include: [
784           {
785             model: Video['sequelize'].models.Author,
786             include: [
787               {
788                 model: Video['sequelize'].models.Pod,
789                 required: false
790               }
791             ]
792           }
793         ]
794       },
795       Video['sequelize'].models.Tag,
796       Video['sequelize'].models.VideoFile
797     ],
798     where: createBaseVideosWhere()
799   }
800
801   return Video.findAndCountAll(query).then(({ rows, count }) => {
802     return {
803       data: rows,
804       total: count
805     }
806   })
807 }
808
809 loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
810   const query: Sequelize.FindOptions<VideoAttributes> = {
811     where: {
812       uuid
813     },
814     include: [
815       {
816         model: Video['sequelize'].models.VideoFile
817       },
818       {
819         model: Video['sequelize'].models.VideoChannel,
820         include: [
821           {
822             model: Video['sequelize'].models.Author,
823             include: [
824               {
825                 model: Video['sequelize'].models.Pod,
826                 required: true,
827                 where: {
828                   host: fromHost
829                 }
830               }
831             ]
832           }
833         ]
834       }
835     ]
836   }
837
838   if (t !== undefined) query.transaction = t
839
840   return Video.findOne(query)
841 }
842
843 listOwnedAndPopulateAuthorAndTags = function () {
844   const query = {
845     where: {
846       remote: false
847     },
848     include: [
849       Video['sequelize'].models.VideoFile,
850       {
851         model: Video['sequelize'].models.VideoChannel,
852         include: [ Video['sequelize'].models.Author ]
853       },
854       Video['sequelize'].models.Tag
855     ]
856   }
857
858   return Video.findAll(query)
859 }
860
861 listOwnedByAuthor = function (author: string) {
862   const query = {
863     where: {
864       remote: false
865     },
866     include: [
867       {
868         model: Video['sequelize'].models.VideoFile
869       },
870       {
871         model: Video['sequelize'].models.VideoChannel,
872         include: [
873           {
874             model: Video['sequelize'].models.Author,
875             where: {
876               name: author
877             }
878           }
879         ]
880       }
881     ]
882   }
883
884   return Video.findAll(query)
885 }
886
887 load = function (id: number) {
888   return Video.findById(id)
889 }
890
891 loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
892   const query: Sequelize.FindOptions<VideoAttributes> = {
893     where: {
894       uuid
895     },
896     include: [ Video['sequelize'].models.VideoFile ]
897   }
898
899   if (t !== undefined) query.transaction = t
900
901   return Video.findOne(query)
902 }
903
904 loadLocalVideoByUUID = function (uuid: string, t?: Sequelize.Transaction) {
905   const query: Sequelize.FindOptions<VideoAttributes> = {
906     where: {
907       uuid,
908       remote: false
909     },
910     include: [ Video['sequelize'].models.VideoFile ]
911   }
912
913   if (t !== undefined) query.transaction = t
914
915   return Video.findOne(query)
916 }
917
918 loadAndPopulateAuthor = function (id: number) {
919   const options = {
920     include: [
921       Video['sequelize'].models.VideoFile,
922       {
923         model: Video['sequelize'].models.VideoChannel,
924         include: [ Video['sequelize'].models.Author ]
925       }
926     ]
927   }
928
929   return Video.findById(id, options)
930 }
931
932 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
933   const options = {
934     include: [
935       {
936         model: Video['sequelize'].models.VideoChannel,
937         include: [
938           {
939             model: Video['sequelize'].models.Author,
940             include: [ { model: Video['sequelize'].models.Pod, required: false } ]
941           }
942         ]
943       },
944       Video['sequelize'].models.Tag,
945       Video['sequelize'].models.VideoFile
946     ]
947   }
948
949   return Video.findById(id, options)
950 }
951
952 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
953   const options = {
954     where: {
955       uuid
956     },
957     include: [
958       {
959         model: Video['sequelize'].models.VideoChannel,
960         include: [
961           {
962             model: Video['sequelize'].models.Author,
963             include: [ { model: Video['sequelize'].models.Pod, required: false } ]
964           }
965         ]
966       },
967       Video['sequelize'].models.Tag,
968       Video['sequelize'].models.VideoFile
969     ]
970   }
971
972   return Video.findOne(options)
973 }
974
975 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
976   const podInclude: Sequelize.IncludeOptions = {
977     model: Video['sequelize'].models.Pod,
978     required: false
979   }
980
981   const authorInclude: Sequelize.IncludeOptions = {
982     model: Video['sequelize'].models.Author,
983     include: [ podInclude ]
984   }
985
986   const videoChannelInclude: Sequelize.IncludeOptions = {
987     model: Video['sequelize'].models.VideoChannel,
988     include: [ authorInclude ],
989     required: true
990   }
991
992   const tagInclude: Sequelize.IncludeOptions = {
993     model: Video['sequelize'].models.Tag
994   }
995
996   const videoFileInclude: Sequelize.IncludeOptions = {
997     model: Video['sequelize'].models.VideoFile
998   }
999
1000   const query: Sequelize.FindOptions<VideoAttributes> = {
1001     distinct: true,
1002     where: createBaseVideosWhere(),
1003     offset: start,
1004     limit: count,
1005     order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
1006   }
1007
1008   // Make an exact search with the magnet
1009   if (field === 'magnetUri') {
1010     videoFileInclude.where = {
1011       infoHash: magnetUtil.decode(value).infoHash
1012     }
1013   } else if (field === 'tags') {
1014     const escapedValue = Video['sequelize'].escape('%' + value + '%')
1015     query.where['id'].$in = Video['sequelize'].literal(
1016       `(SELECT "VideoTags"."videoId"
1017         FROM "Tags"
1018         INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
1019         WHERE name ILIKE ${escapedValue}
1020        )`
1021     )
1022   } else if (field === 'host') {
1023     // FIXME: Include our pod? (not stored in the database)
1024     podInclude.where = {
1025       host: {
1026         $iLike: '%' + value + '%'
1027       }
1028     }
1029     podInclude.required = true
1030   } else if (field === 'author') {
1031     authorInclude.where = {
1032       name: {
1033         $iLike: '%' + value + '%'
1034       }
1035     }
1036   } else {
1037     query.where[field] = {
1038       $iLike: '%' + value + '%'
1039     }
1040   }
1041
1042   query.include = [
1043     videoChannelInclude, tagInclude, videoFileInclude
1044   ]
1045
1046   return Video.findAndCountAll(query).then(({ rows, count }) => {
1047     return {
1048       data: rows,
1049       total: count
1050     }
1051   })
1052 }
1053
1054 // ---------------------------------------------------------------------------
1055
1056 function createBaseVideosWhere () {
1057   return {
1058     id: {
1059       $notIn: Video['sequelize'].literal(
1060         '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1061       )
1062     }
1063   }
1064 }
1065
1066 function getBaseUrls (video: VideoInstance) {
1067   let baseUrlHttp
1068   let baseUrlWs
1069
1070   if (video.isOwned()) {
1071     baseUrlHttp = CONFIG.WEBSERVER.URL
1072     baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1073   } else {
1074     baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Author.Pod.host
1075     baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Author.Pod.host
1076   }
1077
1078   return { baseUrlHttp, baseUrlWs }
1079 }
1080
1081 function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1082   return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1083 }
1084
1085 function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1086   return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1087 }
1088
1089 function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1090   const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1091   const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1092   const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1093
1094   const magnetHash = {
1095     xs,
1096     announce,
1097     urlList,
1098     infoHash: videoFile.infoHash,
1099     name: video.name
1100   }
1101
1102   return magnetUtil.encode(magnetHash)
1103 }