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