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