Fix resolution for portrait videos
[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   getOriginalFile () {
765     if (Array.isArray(this.VideoFiles) === false) return undefined
766
767     // The original file is the file that have the higher resolution
768     return maxBy(this.VideoFiles, file => file.resolution)
769   }
770
771   getVideoFilename (videoFile: VideoFileModel) {
772     return this.uuid + '-' + videoFile.resolution + videoFile.extname
773   }
774
775   getThumbnailName () {
776     // We always have a copy of the thumbnail
777     const extension = '.jpg'
778     return this.uuid + extension
779   }
780
781   getPreviewName () {
782     const extension = '.jpg'
783     return this.uuid + extension
784   }
785
786   getTorrentFileName (videoFile: VideoFileModel) {
787     const extension = '.torrent'
788     return this.uuid + '-' + videoFile.resolution + extension
789   }
790
791   isOwned () {
792     return this.remote === false
793   }
794
795   createPreview (videoFile: VideoFileModel) {
796     return generateImageFromVideoFile(
797       this.getVideoFilePath(videoFile),
798       CONFIG.STORAGE.PREVIEWS_DIR,
799       this.getPreviewName(),
800       PREVIEWS_SIZE
801     )
802   }
803
804   createThumbnail (videoFile: VideoFileModel) {
805     return generateImageFromVideoFile(
806       this.getVideoFilePath(videoFile),
807       CONFIG.STORAGE.THUMBNAILS_DIR,
808       this.getThumbnailName(),
809       THUMBNAILS_SIZE
810     )
811   }
812
813   getVideoFilePath (videoFile: VideoFileModel) {
814     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
815   }
816
817   createTorrentAndSetInfoHash = async function (videoFile: VideoFileModel) {
818     const options = {
819       announceList: [
820         [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ],
821         [ CONFIG.WEBSERVER.URL + '/tracker/announce' ]
822       ],
823       urlList: [
824         CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
825       ]
826     }
827
828     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
829
830     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
831     logger.info('Creating torrent %s.', filePath)
832
833     await writeFilePromise(filePath, torrent)
834
835     const parsedTorrent = parseTorrent(torrent)
836     videoFile.infoHash = parsedTorrent.infoHash
837   }
838
839   getEmbedPath () {
840     return '/videos/embed/' + this.uuid
841   }
842
843   getThumbnailPath () {
844     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
845   }
846
847   getPreviewPath () {
848     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
849   }
850
851   toFormattedJSON (): Video {
852     let serverHost
853
854     if (this.VideoChannel.Account.Actor.Server) {
855       serverHost = this.VideoChannel.Account.Actor.Server.host
856     } else {
857       // It means it's our video
858       serverHost = CONFIG.WEBSERVER.HOST
859     }
860
861     return {
862       id: this.id,
863       uuid: this.uuid,
864       name: this.name,
865       category: this.category,
866       categoryLabel: this.getCategoryLabel(),
867       licence: this.licence,
868       licenceLabel: this.getLicenceLabel(),
869       language: this.language,
870       languageLabel: this.getLanguageLabel(),
871       nsfw: this.nsfw,
872       description: this.getTruncatedDescription(),
873       serverHost,
874       isLocal: this.isOwned(),
875       accountName: this.VideoChannel.Account.name,
876       duration: this.duration,
877       views: this.views,
878       likes: this.likes,
879       dislikes: this.dislikes,
880       thumbnailPath: this.getThumbnailPath(),
881       previewPath: this.getPreviewPath(),
882       embedPath: this.getEmbedPath(),
883       createdAt: this.createdAt,
884       updatedAt: this.updatedAt
885     }
886   }
887
888   toFormattedDetailsJSON (): VideoDetails {
889     const formattedJson = this.toFormattedJSON()
890
891     // Maybe our server is not up to date and there are new privacy settings since our version
892     let privacyLabel = VIDEO_PRIVACIES[this.privacy]
893     if (!privacyLabel) privacyLabel = 'Unknown'
894
895     const detailsJson = {
896       privacyLabel,
897       privacy: this.privacy,
898       support: this.support,
899       descriptionPath: this.getDescriptionPath(),
900       channel: this.VideoChannel.toFormattedJSON(),
901       account: this.VideoChannel.Account.toFormattedJSON(),
902       tags: map<TagModel, string>(this.Tags, 'name'),
903       commentsEnabled: this.commentsEnabled,
904       files: []
905     }
906
907     // Format and sort video files
908     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
909     detailsJson.files = this.VideoFiles
910       .map(videoFile => {
911         let resolutionLabel = videoFile.resolution + 'p'
912
913         return {
914           resolution: videoFile.resolution,
915           resolutionLabel,
916           magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
917           size: videoFile.size,
918           torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
919           fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
920         }
921       })
922       .sort((a, b) => {
923         if (a.resolution < b.resolution) return 1
924         if (a.resolution === b.resolution) return 0
925         return -1
926       })
927
928     return Object.assign(formattedJson, detailsJson)
929   }
930
931   toActivityPubObject (): VideoTorrentObject {
932     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
933     if (!this.Tags) this.Tags = []
934
935     const tag = this.Tags.map(t => ({
936       type: 'Hashtag' as 'Hashtag',
937       name: t.name
938     }))
939
940     let language
941     if (this.language) {
942       language = {
943         identifier: this.language + '',
944         name: this.getLanguageLabel()
945       }
946     }
947
948     let category
949     if (this.category) {
950       category = {
951         identifier: this.category + '',
952         name: this.getCategoryLabel()
953       }
954     }
955
956     let licence
957     if (this.licence) {
958       licence = {
959         identifier: this.licence + '',
960         name: this.getLicenceLabel()
961       }
962     }
963
964     let likesObject
965     let dislikesObject
966
967     if (Array.isArray(this.AccountVideoRates)) {
968       const res = this.toRatesActivityPubObjects()
969       likesObject = res.likesObject
970       dislikesObject = res.dislikesObject
971     }
972
973     let sharesObject
974     if (Array.isArray(this.VideoShares)) {
975       sharesObject = this.toAnnouncesActivityPubObject()
976     }
977
978     let commentsObject
979     if (Array.isArray(this.VideoComments)) {
980       commentsObject = this.toCommentsActivityPubObject()
981     }
982
983     const url = []
984     for (const file of this.VideoFiles) {
985       url.push({
986         type: 'Link',
987         mimeType: 'video/' + file.extname.replace('.', ''),
988         href: this.getVideoFileUrl(file, baseUrlHttp),
989         width: file.resolution,
990         size: file.size
991       })
992
993       url.push({
994         type: 'Link',
995         mimeType: 'application/x-bittorrent',
996         href: this.getTorrentUrl(file, baseUrlHttp),
997         width: file.resolution
998       })
999
1000       url.push({
1001         type: 'Link',
1002         mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
1003         href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1004         width: file.resolution
1005       })
1006     }
1007
1008     // Add video url too
1009     url.push({
1010       type: 'Link',
1011       mimeType: 'text/html',
1012       href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1013     })
1014
1015     return {
1016       type: 'Video' as 'Video',
1017       id: this.url,
1018       name: this.name,
1019       duration: this.getActivityStreamDuration(),
1020       uuid: this.uuid,
1021       tag,
1022       category,
1023       licence,
1024       language,
1025       views: this.views,
1026       sensitive: this.nsfw,
1027       commentsEnabled: this.commentsEnabled,
1028       published: this.createdAt.toISOString(),
1029       updated: this.updatedAt.toISOString(),
1030       mediaType: 'text/markdown',
1031       content: this.getTruncatedDescription(),
1032       support: this.support,
1033       icon: {
1034         type: 'Image',
1035         url: this.getThumbnailUrl(baseUrlHttp),
1036         mediaType: 'image/jpeg',
1037         width: THUMBNAILS_SIZE.width,
1038         height: THUMBNAILS_SIZE.height
1039       },
1040       url,
1041       likes: likesObject,
1042       dislikes: dislikesObject,
1043       shares: sharesObject,
1044       comments: commentsObject,
1045       attributedTo: [
1046         {
1047           type: 'Group',
1048           id: this.VideoChannel.Actor.url
1049         },
1050         {
1051           type: 'Person',
1052           id: this.VideoChannel.Account.Actor.url
1053         }
1054       ]
1055     }
1056   }
1057
1058   toAnnouncesActivityPubObject () {
1059     const shares: string[] = []
1060
1061     for (const videoShare of this.VideoShares) {
1062       shares.push(videoShare.url)
1063     }
1064
1065     return activityPubCollection(getVideoSharesActivityPubUrl(this), shares)
1066   }
1067
1068   toCommentsActivityPubObject () {
1069     const comments: string[] = []
1070
1071     for (const videoComment of this.VideoComments) {
1072       comments.push(videoComment.url)
1073     }
1074
1075     return activityPubCollection(getVideoCommentsActivityPubUrl(this), comments)
1076   }
1077
1078   toRatesActivityPubObjects () {
1079     const likes: string[] = []
1080     const dislikes: string[] = []
1081
1082     for (const rate of this.AccountVideoRates) {
1083       if (rate.type === 'like') {
1084         likes.push(rate.Account.Actor.url)
1085       } else if (rate.type === 'dislike') {
1086         dislikes.push(rate.Account.Actor.url)
1087       }
1088     }
1089
1090     const likesObject = activityPubCollection(getVideoLikesActivityPubUrl(this), likes)
1091     const dislikesObject = activityPubCollection(getVideoDislikesActivityPubUrl(this), dislikes)
1092
1093     return { likesObject, dislikesObject }
1094   }
1095
1096   getTruncatedDescription () {
1097     if (!this.description) return null
1098
1099     const options = {
1100       length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1101     }
1102
1103     return truncate(this.description, options)
1104   }
1105
1106   optimizeOriginalVideofile = async function () {
1107     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1108     const newExtname = '.mp4'
1109     const inputVideoFile = this.getOriginalFile()
1110     const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1111     const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1112
1113     const transcodeOptions = {
1114       inputPath: videoInputPath,
1115       outputPath: videoOutputPath
1116     }
1117
1118     try {
1119       // Could be very long!
1120       await transcode(transcodeOptions)
1121
1122       await unlinkPromise(videoInputPath)
1123
1124       // Important to do this before getVideoFilename() to take in account the new file extension
1125       inputVideoFile.set('extname', newExtname)
1126
1127       await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
1128       const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
1129
1130       inputVideoFile.set('size', stats.size)
1131
1132       await this.createTorrentAndSetInfoHash(inputVideoFile)
1133       await inputVideoFile.save()
1134
1135     } catch (err) {
1136       // Auto destruction...
1137       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
1138
1139       throw err
1140     }
1141   }
1142
1143   transcodeOriginalVideofile = async function (resolution: VideoResolution, isPortraitMode: boolean) {
1144     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1145     const extname = '.mp4'
1146
1147     // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1148     const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1149
1150     const newVideoFile = new VideoFileModel({
1151       resolution,
1152       extname,
1153       size: 0,
1154       videoId: this.id
1155     })
1156     const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1157
1158     const transcodeOptions = {
1159       inputPath: videoInputPath,
1160       outputPath: videoOutputPath,
1161       resolution,
1162       isPortraitMode
1163     }
1164
1165     await transcode(transcodeOptions)
1166
1167     const stats = await statPromise(videoOutputPath)
1168
1169     newVideoFile.set('size', stats.size)
1170
1171     await this.createTorrentAndSetInfoHash(newVideoFile)
1172
1173     await newVideoFile.save()
1174
1175     this.VideoFiles.push(newVideoFile)
1176   }
1177
1178   getOriginalFileResolution () {
1179     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1180
1181     return getVideoFileResolution(originalFilePath)
1182   }
1183
1184   getDescriptionPath () {
1185     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1186   }
1187
1188   getCategoryLabel () {
1189     let categoryLabel = VIDEO_CATEGORIES[this.category]
1190     if (!categoryLabel) categoryLabel = 'Misc'
1191
1192     return categoryLabel
1193   }
1194
1195   getLicenceLabel () {
1196     let licenceLabel = VIDEO_LICENCES[this.licence]
1197     if (!licenceLabel) licenceLabel = 'Unknown'
1198
1199     return licenceLabel
1200   }
1201
1202   getLanguageLabel () {
1203     let languageLabel = VIDEO_LANGUAGES[this.language]
1204     if (!languageLabel) languageLabel = 'Unknown'
1205
1206     return languageLabel
1207   }
1208
1209   removeThumbnail () {
1210     const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1211     return unlinkPromise(thumbnailPath)
1212   }
1213
1214   removePreview () {
1215     // Same name than video thumbnail
1216     return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1217   }
1218
1219   removeFile (videoFile: VideoFileModel) {
1220     const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1221     return unlinkPromise(filePath)
1222   }
1223
1224   removeTorrent (videoFile: VideoFileModel) {
1225     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1226     return unlinkPromise(torrentPath)
1227   }
1228
1229   getActivityStreamDuration () {
1230     // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1231     return 'PT' + this.duration + 'S'
1232   }
1233
1234   private getBaseUrls () {
1235     let baseUrlHttp
1236     let baseUrlWs
1237
1238     if (this.isOwned()) {
1239       baseUrlHttp = CONFIG.WEBSERVER.URL
1240       baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1241     } else {
1242       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1243       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1244     }
1245
1246     return { baseUrlHttp, baseUrlWs }
1247   }
1248
1249   private getThumbnailUrl (baseUrlHttp: string) {
1250     return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1251   }
1252
1253   private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1254     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1255   }
1256
1257   private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1258     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1259   }
1260
1261   private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1262     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1263     const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1264     const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1265
1266     const magnetHash = {
1267       xs,
1268       announce,
1269       urlList,
1270       infoHash: videoFile.infoHash,
1271       name: this.name
1272     }
1273
1274     return magnetUtil.encode(magnetHash)
1275   }
1276 }