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