1b9d68073186fac447e81137710aa45737e46a26
[oweals/peertube.git] / server / models / video / video.ts
1 import * as Bluebird from 'bluebird'
2 import { map, maxBy, truncate } from 'lodash'
3 import * as magnetUtil from 'magnet-uri'
4 import * as parseTorrent from 'parse-torrent'
5 import { join } from 'path'
6 import * as Sequelize from 'sequelize'
7 import {
8   AfterDestroy,
9   AllowNull,
10   BeforeDestroy,
11   BelongsTo,
12   BelongsToMany,
13   Column,
14   CreatedAt,
15   DataType,
16   Default,
17   ForeignKey,
18   HasMany,
19   IFindOptions,
20   Is,
21   IsInt,
22   IsUUID,
23   Min,
24   Model,
25   Scopes,
26   Table,
27   UpdatedAt
28 } from 'sequelize-typescript'
29 import { VideoPrivacy, VideoResolution } from '../../../shared'
30 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
31 import { Video, VideoDetails } from '../../../shared/models/videos'
32 import { activityPubCollection } from '../../helpers/activitypub'
33 import { createTorrentPromise, renamePromise, statPromise, unlinkPromise, writeFilePromise } from '../../helpers/core-utils'
34 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
35 import { isBooleanValid } from '../../helpers/custom-validators/misc'
36 import {
37   isVideoCategoryValid,
38   isVideoDescriptionValid,
39   isVideoDurationValid,
40   isVideoLanguageValid,
41   isVideoLicenceValid,
42   isVideoNameValid,
43   isVideoPrivacyValid, isVideoSupportValid
44 } from '../../helpers/custom-validators/videos'
45 import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
46 import { logger } from '../../helpers/logger'
47 import { getServerActor } from '../../helpers/utils'
48 import {
49   API_VERSION,
50   CONFIG,
51   CONSTRAINTS_FIELDS,
52   PREVIEWS_SIZE,
53   REMOTE_SCHEME,
54   STATIC_PATHS,
55   THUMBNAILS_SIZE,
56   VIDEO_CATEGORIES,
57   VIDEO_LANGUAGES,
58   VIDEO_LICENCES,
59   VIDEO_PRIVACIES
60 } from '../../initializers'
61 import {
62   getVideoCommentsActivityPubUrl,
63   getVideoDislikesActivityPubUrl,
64   getVideoLikesActivityPubUrl,
65   getVideoSharesActivityPubUrl
66 } from '../../lib/activitypub'
67 import { sendDeleteVideo } from '../../lib/activitypub/send'
68 import { AccountModel } from '../account/account'
69 import { AccountVideoRateModel } from '../account/account-video-rate'
70 import { ActorModel } from '../activitypub/actor'
71 import { AvatarModel } from '../avatar/avatar'
72 import { ServerModel } from '../server/server'
73 import { getSort, throwIfNotValid } from '../utils'
74 import { TagModel } from './tag'
75 import { VideoAbuseModel } from './video-abuse'
76 import { VideoChannelModel } from './video-channel'
77 import { VideoCommentModel } from './video-comment'
78 import { VideoFileModel } from './video-file'
79 import { VideoShareModel } from './video-share'
80 import { VideoTagModel } from './video-tag'
81
82 enum ScopeNames {
83   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
84   WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
85   WITH_TAGS = 'WITH_TAGS',
86   WITH_FILES = 'WITH_FILES',
87   WITH_SHARES = 'WITH_SHARES',
88   WITH_RATES = 'WITH_RATES',
89   WITH_COMMENTS = 'WITH_COMMENTS'
90 }
91
92 @Scopes({
93   [ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number) => ({
94     where: {
95       id: {
96         [Sequelize.Op.notIn]: Sequelize.literal(
97           '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
98         ),
99         [ Sequelize.Op.in ]: Sequelize.literal(
100           '(' +
101             'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
102             'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
103             'WHERE "actorFollow"."actorId" = ' + parseInt(actorId.toString(), 10) +
104             ' UNION ' +
105             'SELECT "video"."id" AS "id" FROM "video" ' +
106             'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
107             'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
108             'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
109             'LEFT JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
110             'WHERE "actor"."serverId" IS NULL OR "actorFollow"."actorId" = ' + parseInt(actorId.toString(), 10) +
111           ')'
112         )
113       },
114       privacy: VideoPrivacy.PUBLIC
115     },
116     include: [
117       {
118         attributes: [ 'name', 'description' ],
119         model: VideoChannelModel.unscoped(),
120         required: true,
121         include: [
122           {
123             attributes: [ 'name' ],
124             model: AccountModel.unscoped(),
125             required: true,
126             include: [
127               {
128                 attributes: [ 'serverId' ],
129                 model: ActorModel.unscoped(),
130                 required: true,
131                 include: [
132                   {
133                     attributes: [ 'host' ],
134                     model: ServerModel.unscoped()
135                   }
136                 ]
137               }
138             ]
139           }
140         ]
141       }
142     ]
143   }),
144   [ScopeNames.WITH_ACCOUNT_DETAILS]: {
145     include: [
146       {
147         model: () => VideoChannelModel.unscoped(),
148         required: true,
149         include: [
150           {
151             attributes: {
152               exclude: [ 'privateKey', 'publicKey' ]
153             },
154             model: () => ActorModel.unscoped(),
155             required: true,
156             include: [
157               {
158                 attributes: [ 'host' ],
159                 model: () => ServerModel.unscoped(),
160                 required: false
161               }
162             ]
163           },
164           {
165             model: () => AccountModel.unscoped(),
166             required: true,
167             include: [
168               {
169                 model: () => ActorModel.unscoped(),
170                 attributes: {
171                   exclude: [ 'privateKey', 'publicKey' ]
172                 },
173                 required: true,
174                 include: [
175                   {
176                     attributes: [ 'host' ],
177                     model: () => ServerModel.unscoped(),
178                     required: false
179                   },
180                   {
181                     model: () => AvatarModel.unscoped(),
182                     required: false
183                   }
184                 ]
185               }
186             ]
187           }
188         ]
189       }
190     ]
191   },
192   [ScopeNames.WITH_TAGS]: {
193     include: [ () => TagModel ]
194   },
195   [ScopeNames.WITH_FILES]: {
196     include: [
197       {
198         model: () => VideoFileModel,
199         required: true
200       }
201     ]
202   },
203   [ScopeNames.WITH_SHARES]: {
204     include: [
205       {
206         model: () => VideoShareModel,
207         include: [ () => ActorModel ]
208       }
209     ]
210   },
211   [ScopeNames.WITH_RATES]: {
212     include: [
213       {
214         model: () => AccountVideoRateModel,
215         include: [ () => AccountModel ]
216       }
217     ]
218   },
219   [ScopeNames.WITH_COMMENTS]: {
220     include: [
221       {
222         model: () => VideoCommentModel
223       }
224     ]
225   }
226 })
227 @Table({
228   tableName: 'video',
229   indexes: [
230     {
231       fields: [ 'name' ]
232     },
233     {
234       fields: [ 'createdAt' ]
235     },
236     {
237       fields: [ 'duration' ]
238     },
239     {
240       fields: [ 'views' ]
241     },
242     {
243       fields: [ 'likes' ]
244     },
245     {
246       fields: [ 'uuid' ]
247     },
248     {
249       fields: [ 'channelId' ]
250     },
251     {
252       fields: [ 'id', 'privacy' ]
253     },
254     {
255       fields: [ 'url'],
256       unique: true
257     }
258   ]
259 })
260 export class VideoModel extends Model<VideoModel> {
261
262   @AllowNull(false)
263   @Default(DataType.UUIDV4)
264   @IsUUID(4)
265   @Column(DataType.UUID)
266   uuid: string
267
268   @AllowNull(false)
269   @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
270   @Column
271   name: string
272
273   @AllowNull(true)
274   @Default(null)
275   @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category'))
276   @Column
277   category: number
278
279   @AllowNull(true)
280   @Default(null)
281   @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence'))
282   @Column
283   licence: number
284
285   @AllowNull(true)
286   @Default(null)
287   @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language'))
288   @Column
289   language: number
290
291   @AllowNull(false)
292   @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
293   @Column
294   privacy: number
295
296   @AllowNull(false)
297   @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
298   @Column
299   nsfw: boolean
300
301   @AllowNull(true)
302   @Default(null)
303   @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description'))
304   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
305   description: string
306
307   @AllowNull(true)
308   @Default(null)
309   @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support'))
310   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
311   support: string
312
313   @AllowNull(false)
314   @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
315   @Column
316   duration: number
317
318   @AllowNull(false)
319   @Default(0)
320   @IsInt
321   @Min(0)
322   @Column
323   views: number
324
325   @AllowNull(false)
326   @Default(0)
327   @IsInt
328   @Min(0)
329   @Column
330   likes: number
331
332   @AllowNull(false)
333   @Default(0)
334   @IsInt
335   @Min(0)
336   @Column
337   dislikes: number
338
339   @AllowNull(false)
340   @Column
341   remote: boolean
342
343   @AllowNull(false)
344   @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
345   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
346   url: string
347
348   @AllowNull(false)
349   @Column
350   commentsEnabled: boolean
351
352   @CreatedAt
353   createdAt: Date
354
355   @UpdatedAt
356   updatedAt: Date
357
358   @ForeignKey(() => VideoChannelModel)
359   @Column
360   channelId: number
361
362   @BelongsTo(() => VideoChannelModel, {
363     foreignKey: {
364       allowNull: true
365     },
366     onDelete: 'cascade'
367   })
368   VideoChannel: VideoChannelModel
369
370   @BelongsToMany(() => TagModel, {
371     foreignKey: 'videoId',
372     through: () => VideoTagModel,
373     onDelete: 'CASCADE'
374   })
375   Tags: TagModel[]
376
377   @HasMany(() => VideoAbuseModel, {
378     foreignKey: {
379       name: 'videoId',
380       allowNull: false
381     },
382     onDelete: 'cascade'
383   })
384   VideoAbuses: VideoAbuseModel[]
385
386   @HasMany(() => VideoFileModel, {
387     foreignKey: {
388       name: 'videoId',
389       allowNull: false
390     },
391     onDelete: 'cascade'
392   })
393   VideoFiles: VideoFileModel[]
394
395   @HasMany(() => VideoShareModel, {
396     foreignKey: {
397       name: 'videoId',
398       allowNull: false
399     },
400     onDelete: 'cascade'
401   })
402   VideoShares: VideoShareModel[]
403
404   @HasMany(() => AccountVideoRateModel, {
405     foreignKey: {
406       name: 'videoId',
407       allowNull: false
408     },
409     onDelete: 'cascade'
410   })
411   AccountVideoRates: AccountVideoRateModel[]
412
413   @HasMany(() => VideoCommentModel, {
414     foreignKey: {
415       name: 'videoId',
416       allowNull: false
417     },
418     onDelete: 'cascade',
419     hooks: true
420   })
421   VideoComments: VideoCommentModel[]
422
423   @BeforeDestroy
424   static async sendDelete (instance: VideoModel, options) {
425     if (instance.isOwned()) {
426       if (!instance.VideoChannel) {
427         instance.VideoChannel = await instance.$get('VideoChannel', {
428           include: [
429             {
430               model: AccountModel,
431               include: [ ActorModel ]
432             }
433           ],
434           transaction: options.transaction
435         }) as VideoChannelModel
436       }
437
438       logger.debug('Sending delete of video %s.', instance.url)
439
440       return sendDeleteVideo(instance, options.transaction)
441     }
442
443     return undefined
444   }
445
446   @AfterDestroy
447   static async removeFilesAndSendDelete (instance: VideoModel) {
448     const tasks: Promise<any>[] = []
449
450     tasks.push(instance.removeThumbnail())
451
452     if (instance.isOwned()) {
453       if (!Array.isArray(instance.VideoFiles)) {
454         instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
455       }
456
457       tasks.push(instance.removePreview())
458
459       // Remove physical files and torrents
460       instance.VideoFiles.forEach(file => {
461         tasks.push(instance.removeFile(file))
462         tasks.push(instance.removeTorrent(file))
463       })
464     }
465
466     return Promise.all(tasks)
467       .catch(err => {
468         logger.error('Some errors when removing files of video %s in after destroy hook.', instance.uuid, err)
469       })
470   }
471
472   static list () {
473     return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
474   }
475
476   static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
477     function getRawQuery (select: string) {
478       const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
479         'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
480         'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
481         'WHERE "Account"."actorId" = ' + actorId
482       const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
483         'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
484         'WHERE "VideoShare"."actorId" = ' + actorId
485
486       return `(${queryVideo}) UNION (${queryVideoShare})`
487     }
488
489     const rawQuery = getRawQuery('"Video"."id"')
490     const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
491
492     const query = {
493       distinct: true,
494       offset: start,
495       limit: count,
496       order: getSort('createdAt', [ 'Tags', 'name', 'ASC' ]),
497       where: {
498         id: {
499           [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
500         },
501         [Sequelize.Op.or]: [
502           { privacy: VideoPrivacy.PUBLIC },
503           { privacy: VideoPrivacy.UNLISTED }
504         ]
505       },
506       include: [
507         {
508           attributes: [ 'id', 'url' ],
509           model: VideoShareModel.unscoped(),
510           required: false,
511           where: {
512             [Sequelize.Op.and]: [
513               {
514                 id: {
515                   [Sequelize.Op.not]: null
516                 }
517               },
518               {
519                 actorId
520               }
521             ]
522           },
523           include: [
524             {
525               attributes: [ 'id', 'url' ],
526               model: ActorModel.unscoped()
527             }
528           ]
529         },
530         {
531           model: VideoChannelModel.unscoped(),
532           required: true,
533           include: [
534             {
535               attributes: [ 'name' ],
536               model: AccountModel.unscoped(),
537               required: true,
538               include: [
539                 {
540                   attributes: [ 'id', 'url' ],
541                   model: ActorModel.unscoped(),
542                   required: true
543                 }
544               ]
545             },
546             {
547               attributes: [ 'id', 'url' ],
548               model: ActorModel.unscoped(),
549               required: true
550             }
551           ]
552         },
553         {
554           attributes: [ 'type' ],
555           model: AccountVideoRateModel,
556           required: false,
557           include: [
558             {
559               attributes: [ 'id' ],
560               model: AccountModel.unscoped(),
561               include: [
562                 {
563                   attributes: [ 'url' ],
564                   model: ActorModel.unscoped(),
565                   include: [
566                     {
567                       attributes: [ 'host' ],
568                       model: ServerModel,
569                       required: false
570                     }
571                   ]
572                 }
573               ]
574             }
575           ]
576         },
577         {
578           attributes: [ 'url' ],
579           model: VideoCommentModel,
580           required: false
581         },
582         VideoFileModel,
583         TagModel
584       ]
585     }
586
587     return Bluebird.all([
588       // FIXME: typing issue
589       VideoModel.findAll(query as any),
590       VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
591     ]).then(([ rows, totals ]) => {
592       // totals: totalVideos + totalVideoShares
593       let totalVideos = 0
594       let totalVideoShares = 0
595       if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
596       if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
597
598       const total = totalVideos + totalVideoShares
599       return {
600         data: rows,
601         total: total
602       }
603     })
604   }
605
606   static listUserVideosForApi (userId: number, start: number, count: number, sort: string) {
607     const query = {
608       offset: start,
609       limit: count,
610       order: getSort(sort),
611       include: [
612         {
613           model: VideoChannelModel,
614           required: true,
615           include: [
616             {
617               model: AccountModel,
618               where: {
619                 userId
620               },
621               required: true
622             }
623           ]
624         }
625       ]
626     }
627
628     return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
629       return {
630         data: rows,
631         total: count
632       }
633     })
634   }
635
636   static async listForApi (start: number, count: number, sort: string) {
637     const query = {
638       offset: start,
639       limit: count,
640       order: getSort(sort)
641     }
642
643     const serverActor = await getServerActor()
644
645     return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id ] })
646       .findAndCountAll(query)
647       .then(({ rows, count }) => {
648         return {
649           data: rows,
650           total: count
651         }
652       })
653   }
654
655   static async searchAndPopulateAccountAndServerAndTags (value: string, start: number, count: number, sort: string) {
656     const query: IFindOptions<VideoModel> = {
657       offset: start,
658       limit: count,
659       order: getSort(sort),
660       where: {
661         name: {
662           [Sequelize.Op.iLike]: '%' + value + '%'
663         }
664       }
665     }
666
667     const serverActor = await getServerActor()
668
669     return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id ] })
670       .findAndCountAll(query).then(({ rows, count }) => {
671         return {
672           data: rows,
673           total: count
674         }
675       })
676   }
677
678   static load (id: number) {
679     return VideoModel.findById(id)
680   }
681
682   static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
683     const query: IFindOptions<VideoModel> = {
684       where: {
685         url
686       }
687     }
688
689     if (t !== undefined) query.transaction = t
690
691     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
692   }
693
694   static loadByUUIDOrURLAndPopulateAccount (uuid: string, url: string, t?: Sequelize.Transaction) {
695     const query: IFindOptions<VideoModel> = {
696       where: {
697         [Sequelize.Op.or]: [
698           { uuid },
699           { url }
700         ]
701       }
702     }
703
704     if (t !== undefined) query.transaction = t
705
706     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
707   }
708
709   static loadAndPopulateAccountAndServerAndTags (id: number) {
710     const options = {
711       order: [ [ 'Tags', 'name', 'ASC' ] ]
712     }
713
714     return VideoModel
715       .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
716       .findById(id, options)
717   }
718
719   static loadByUUID (uuid: string) {
720     const options = {
721       where: {
722         uuid
723       }
724     }
725
726     return VideoModel
727       .scope([ ScopeNames.WITH_FILES ])
728       .findOne(options)
729   }
730
731   static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) {
732     const options = {
733       order: [ [ 'Tags', 'name', 'ASC' ] ],
734       where: {
735         uuid
736       }
737     }
738
739     return VideoModel
740       .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
741       .findOne(options)
742   }
743
744   static loadAndPopulateAll (id: number) {
745     const options = {
746       order: [ [ 'Tags', 'name', 'ASC' ] ],
747       where: {
748         id
749       }
750     }
751
752     return VideoModel
753       .scope([
754         ScopeNames.WITH_RATES,
755         ScopeNames.WITH_SHARES,
756         ScopeNames.WITH_TAGS,
757         ScopeNames.WITH_FILES,
758         ScopeNames.WITH_ACCOUNT_DETAILS,
759         ScopeNames.WITH_COMMENTS
760       ])
761       .findOne(options)
762   }
763
764   static async getStats () {
765     const totalLocalVideos = await VideoModel.count({
766       where: {
767         remote: false
768       }
769     })
770     const totalVideos = await VideoModel.count()
771
772     let totalLocalVideoViews = await VideoModel.sum('views', {
773       where: {
774         remote: false
775       }
776     })
777     // Sequelize could return null...
778     if (!totalLocalVideoViews) totalLocalVideoViews = 0
779
780     return {
781       totalLocalVideos,
782       totalLocalVideoViews,
783       totalVideos
784     }
785   }
786
787   getOriginalFile () {
788     if (Array.isArray(this.VideoFiles) === false) return undefined
789
790     // The original file is the file that have the higher resolution
791     return maxBy(this.VideoFiles, file => file.resolution)
792   }
793
794   getVideoFilename (videoFile: VideoFileModel) {
795     return this.uuid + '-' + videoFile.resolution + videoFile.extname
796   }
797
798   getThumbnailName () {
799     // We always have a copy of the thumbnail
800     const extension = '.jpg'
801     return this.uuid + extension
802   }
803
804   getPreviewName () {
805     const extension = '.jpg'
806     return this.uuid + extension
807   }
808
809   getTorrentFileName (videoFile: VideoFileModel) {
810     const extension = '.torrent'
811     return this.uuid + '-' + videoFile.resolution + extension
812   }
813
814   isOwned () {
815     return this.remote === false
816   }
817
818   createPreview (videoFile: VideoFileModel) {
819     return generateImageFromVideoFile(
820       this.getVideoFilePath(videoFile),
821       CONFIG.STORAGE.PREVIEWS_DIR,
822       this.getPreviewName(),
823       PREVIEWS_SIZE
824     )
825   }
826
827   createThumbnail (videoFile: VideoFileModel) {
828     return generateImageFromVideoFile(
829       this.getVideoFilePath(videoFile),
830       CONFIG.STORAGE.THUMBNAILS_DIR,
831       this.getThumbnailName(),
832       THUMBNAILS_SIZE
833     )
834   }
835
836   getVideoFilePath (videoFile: VideoFileModel) {
837     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
838   }
839
840   createTorrentAndSetInfoHash = async function (videoFile: VideoFileModel) {
841     const options = {
842       announceList: [
843         [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ],
844         [ CONFIG.WEBSERVER.URL + '/tracker/announce' ]
845       ],
846       urlList: [
847         CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
848       ]
849     }
850
851     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
852
853     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
854     logger.info('Creating torrent %s.', filePath)
855
856     await writeFilePromise(filePath, torrent)
857
858     const parsedTorrent = parseTorrent(torrent)
859     videoFile.infoHash = parsedTorrent.infoHash
860   }
861
862   getEmbedPath () {
863     return '/videos/embed/' + this.uuid
864   }
865
866   getThumbnailPath () {
867     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
868   }
869
870   getPreviewPath () {
871     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
872   }
873
874   toFormattedJSON (): Video {
875     let serverHost
876
877     if (this.VideoChannel.Account.Actor.Server) {
878       serverHost = this.VideoChannel.Account.Actor.Server.host
879     } else {
880       // It means it's our video
881       serverHost = CONFIG.WEBSERVER.HOST
882     }
883
884     return {
885       id: this.id,
886       uuid: this.uuid,
887       name: this.name,
888       category: this.category,
889       categoryLabel: this.getCategoryLabel(),
890       licence: this.licence,
891       licenceLabel: this.getLicenceLabel(),
892       language: this.language,
893       languageLabel: this.getLanguageLabel(),
894       nsfw: this.nsfw,
895       description: this.getTruncatedDescription(),
896       serverHost,
897       isLocal: this.isOwned(),
898       accountName: this.VideoChannel.Account.name,
899       duration: this.duration,
900       views: this.views,
901       likes: this.likes,
902       dislikes: this.dislikes,
903       thumbnailPath: this.getThumbnailPath(),
904       previewPath: this.getPreviewPath(),
905       embedPath: this.getEmbedPath(),
906       createdAt: this.createdAt,
907       updatedAt: this.updatedAt
908     }
909   }
910
911   toFormattedDetailsJSON (): VideoDetails {
912     const formattedJson = this.toFormattedJSON()
913
914     // Maybe our server is not up to date and there are new privacy settings since our version
915     let privacyLabel = VIDEO_PRIVACIES[this.privacy]
916     if (!privacyLabel) privacyLabel = 'Unknown'
917
918     const detailsJson = {
919       privacyLabel,
920       privacy: this.privacy,
921       support: this.support,
922       descriptionPath: this.getDescriptionPath(),
923       channel: this.VideoChannel.toFormattedJSON(),
924       account: this.VideoChannel.Account.toFormattedJSON(),
925       tags: map<TagModel, string>(this.Tags, 'name'),
926       commentsEnabled: this.commentsEnabled,
927       files: []
928     }
929
930     // Format and sort video files
931     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
932     detailsJson.files = this.VideoFiles
933       .map(videoFile => {
934         let resolutionLabel = videoFile.resolution + 'p'
935
936         return {
937           resolution: videoFile.resolution,
938           resolutionLabel,
939           magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
940           size: videoFile.size,
941           torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
942           fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
943         }
944       })
945       .sort((a, b) => {
946         if (a.resolution < b.resolution) return 1
947         if (a.resolution === b.resolution) return 0
948         return -1
949       })
950
951     return Object.assign(formattedJson, detailsJson)
952   }
953
954   toActivityPubObject (): VideoTorrentObject {
955     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
956     if (!this.Tags) this.Tags = []
957
958     const tag = this.Tags.map(t => ({
959       type: 'Hashtag' as 'Hashtag',
960       name: t.name
961     }))
962
963     let language
964     if (this.language) {
965       language = {
966         identifier: this.language + '',
967         name: this.getLanguageLabel()
968       }
969     }
970
971     let category
972     if (this.category) {
973       category = {
974         identifier: this.category + '',
975         name: this.getCategoryLabel()
976       }
977     }
978
979     let licence
980     if (this.licence) {
981       licence = {
982         identifier: this.licence + '',
983         name: this.getLicenceLabel()
984       }
985     }
986
987     let likesObject
988     let dislikesObject
989
990     if (Array.isArray(this.AccountVideoRates)) {
991       const res = this.toRatesActivityPubObjects()
992       likesObject = res.likesObject
993       dislikesObject = res.dislikesObject
994     }
995
996     let sharesObject
997     if (Array.isArray(this.VideoShares)) {
998       sharesObject = this.toAnnouncesActivityPubObject()
999     }
1000
1001     let commentsObject
1002     if (Array.isArray(this.VideoComments)) {
1003       commentsObject = this.toCommentsActivityPubObject()
1004     }
1005
1006     const url = []
1007     for (const file of this.VideoFiles) {
1008       url.push({
1009         type: 'Link',
1010         mimeType: 'video/' + file.extname.replace('.', ''),
1011         href: this.getVideoFileUrl(file, baseUrlHttp),
1012         width: file.resolution,
1013         size: file.size
1014       })
1015
1016       url.push({
1017         type: 'Link',
1018         mimeType: 'application/x-bittorrent',
1019         href: this.getTorrentUrl(file, baseUrlHttp),
1020         width: file.resolution
1021       })
1022
1023       url.push({
1024         type: 'Link',
1025         mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
1026         href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1027         width: file.resolution
1028       })
1029     }
1030
1031     // Add video url too
1032     url.push({
1033       type: 'Link',
1034       mimeType: 'text/html',
1035       href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1036     })
1037
1038     return {
1039       type: 'Video' as 'Video',
1040       id: this.url,
1041       name: this.name,
1042       duration: this.getActivityStreamDuration(),
1043       uuid: this.uuid,
1044       tag,
1045       category,
1046       licence,
1047       language,
1048       views: this.views,
1049       sensitive: this.nsfw,
1050       commentsEnabled: this.commentsEnabled,
1051       published: this.createdAt.toISOString(),
1052       updated: this.updatedAt.toISOString(),
1053       mediaType: 'text/markdown',
1054       content: this.getTruncatedDescription(),
1055       support: this.support,
1056       icon: {
1057         type: 'Image',
1058         url: this.getThumbnailUrl(baseUrlHttp),
1059         mediaType: 'image/jpeg',
1060         width: THUMBNAILS_SIZE.width,
1061         height: THUMBNAILS_SIZE.height
1062       },
1063       url,
1064       likes: likesObject,
1065       dislikes: dislikesObject,
1066       shares: sharesObject,
1067       comments: commentsObject,
1068       attributedTo: [
1069         {
1070           type: 'Group',
1071           id: this.VideoChannel.Actor.url
1072         },
1073         {
1074           type: 'Person',
1075           id: this.VideoChannel.Account.Actor.url
1076         }
1077       ]
1078     }
1079   }
1080
1081   toAnnouncesActivityPubObject () {
1082     const shares: string[] = []
1083
1084     for (const videoShare of this.VideoShares) {
1085       shares.push(videoShare.url)
1086     }
1087
1088     return activityPubCollection(getVideoSharesActivityPubUrl(this), shares)
1089   }
1090
1091   toCommentsActivityPubObject () {
1092     const comments: string[] = []
1093
1094     for (const videoComment of this.VideoComments) {
1095       comments.push(videoComment.url)
1096     }
1097
1098     return activityPubCollection(getVideoCommentsActivityPubUrl(this), comments)
1099   }
1100
1101   toRatesActivityPubObjects () {
1102     const likes: string[] = []
1103     const dislikes: string[] = []
1104
1105     for (const rate of this.AccountVideoRates) {
1106       if (rate.type === 'like') {
1107         likes.push(rate.Account.Actor.url)
1108       } else if (rate.type === 'dislike') {
1109         dislikes.push(rate.Account.Actor.url)
1110       }
1111     }
1112
1113     const likesObject = activityPubCollection(getVideoLikesActivityPubUrl(this), likes)
1114     const dislikesObject = activityPubCollection(getVideoDislikesActivityPubUrl(this), dislikes)
1115
1116     return { likesObject, dislikesObject }
1117   }
1118
1119   getTruncatedDescription () {
1120     if (!this.description) return null
1121
1122     const options = {
1123       length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1124     }
1125
1126     return truncate(this.description, options)
1127   }
1128
1129   optimizeOriginalVideofile = async function () {
1130     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1131     const newExtname = '.mp4'
1132     const inputVideoFile = this.getOriginalFile()
1133     const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1134     const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1135
1136     const transcodeOptions = {
1137       inputPath: videoInputPath,
1138       outputPath: videoOutputPath
1139     }
1140
1141     // Could be very long!
1142     await transcode(transcodeOptions)
1143
1144     try {
1145       await unlinkPromise(videoInputPath)
1146
1147       // Important to do this before getVideoFilename() to take in account the new file extension
1148       inputVideoFile.set('extname', newExtname)
1149
1150       await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
1151       const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
1152
1153       inputVideoFile.set('size', stats.size)
1154
1155       await this.createTorrentAndSetInfoHash(inputVideoFile)
1156       await inputVideoFile.save()
1157
1158     } catch (err) {
1159       // Auto destruction...
1160       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
1161
1162       throw err
1163     }
1164   }
1165
1166   transcodeOriginalVideofile = async function (resolution: VideoResolution, isPortraitMode: boolean) {
1167     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1168     const extname = '.mp4'
1169
1170     // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1171     const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1172
1173     const newVideoFile = new VideoFileModel({
1174       resolution,
1175       extname,
1176       size: 0,
1177       videoId: this.id
1178     })
1179     const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1180
1181     const transcodeOptions = {
1182       inputPath: videoInputPath,
1183       outputPath: videoOutputPath,
1184       resolution,
1185       isPortraitMode
1186     }
1187
1188     await transcode(transcodeOptions)
1189
1190     const stats = await statPromise(videoOutputPath)
1191
1192     newVideoFile.set('size', stats.size)
1193
1194     await this.createTorrentAndSetInfoHash(newVideoFile)
1195
1196     await newVideoFile.save()
1197
1198     this.VideoFiles.push(newVideoFile)
1199   }
1200
1201   getOriginalFileResolution () {
1202     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1203
1204     return getVideoFileResolution(originalFilePath)
1205   }
1206
1207   getDescriptionPath () {
1208     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1209   }
1210
1211   getCategoryLabel () {
1212     let categoryLabel = VIDEO_CATEGORIES[this.category]
1213     if (!categoryLabel) categoryLabel = 'Misc'
1214
1215     return categoryLabel
1216   }
1217
1218   getLicenceLabel () {
1219     let licenceLabel = VIDEO_LICENCES[this.licence]
1220     if (!licenceLabel) licenceLabel = 'Unknown'
1221
1222     return licenceLabel
1223   }
1224
1225   getLanguageLabel () {
1226     let languageLabel = VIDEO_LANGUAGES[this.language]
1227     if (!languageLabel) languageLabel = 'Unknown'
1228
1229     return languageLabel
1230   }
1231
1232   removeThumbnail () {
1233     const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1234     return unlinkPromise(thumbnailPath)
1235   }
1236
1237   removePreview () {
1238     // Same name than video thumbnail
1239     return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1240   }
1241
1242   removeFile (videoFile: VideoFileModel) {
1243     const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1244     return unlinkPromise(filePath)
1245   }
1246
1247   removeTorrent (videoFile: VideoFileModel) {
1248     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1249     return unlinkPromise(torrentPath)
1250   }
1251
1252   getActivityStreamDuration () {
1253     // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1254     return 'PT' + this.duration + 'S'
1255   }
1256
1257   private getBaseUrls () {
1258     let baseUrlHttp
1259     let baseUrlWs
1260
1261     if (this.isOwned()) {
1262       baseUrlHttp = CONFIG.WEBSERVER.URL
1263       baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1264     } else {
1265       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1266       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1267     }
1268
1269     return { baseUrlHttp, baseUrlWs }
1270   }
1271
1272   private getThumbnailUrl (baseUrlHttp: string) {
1273     return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1274   }
1275
1276   private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1277     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1278   }
1279
1280   private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1281     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1282   }
1283
1284   private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1285     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1286     const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1287     const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1288
1289     const magnetHash = {
1290       xs,
1291       announce,
1292       urlList,
1293       infoHash: videoFile.infoHash,
1294       name: this.name
1295     }
1296
1297     return magnetUtil.encode(magnetHash)
1298   }
1299 }