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