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