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