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