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