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