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