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