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