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