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