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