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