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