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