Fix comment creation
[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 loadAndPopulateAccount (id: number) {
511     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS ]).findById(id)
512   }
513
514   static loadByUrl (url: string, t?: Sequelize.Transaction) {
515     const query: IFindOptions<VideoModel> = {
516       where: {
517         url
518       }
519     }
520
521     if (t !== undefined) query.transaction = t
522
523     return VideoModel.findOne(query)
524   }
525
526   static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
527     const query: IFindOptions<VideoModel> = {
528       where: {
529         url
530       }
531     }
532
533     if (t !== undefined) query.transaction = t
534
535     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
536   }
537
538   static loadByUUIDOrURL (uuid: string, url: string, t?: Sequelize.Transaction) {
539     const query: IFindOptions<VideoModel> = {
540       where: {
541         [Sequelize.Op.or]: [
542           { uuid },
543           { url }
544         ]
545       }
546     }
547
548     if (t !== undefined) query.transaction = t
549
550     return VideoModel.scope(ScopeNames.WITH_FILES).findOne(query)
551   }
552
553   static loadAndPopulateAccountAndServerAndTags (id: number) {
554     const options = {
555       order: [ [ 'Tags', 'name', 'ASC' ] ]
556     }
557
558     return VideoModel
559       .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
560       .findById(id, options)
561   }
562
563   static loadByUUID (uuid: string) {
564     const options = {
565       where: {
566         uuid
567       }
568     }
569
570     return VideoModel
571       .scope([ ScopeNames.WITH_FILES ])
572       .findOne(options)
573   }
574
575   static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) {
576     const options = {
577       order: [ [ 'Tags', 'name', 'ASC' ] ],
578       where: {
579         uuid
580       }
581     }
582
583     return VideoModel
584       .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
585       .findOne(options)
586   }
587
588   static loadAndPopulateAll (id: number) {
589     const options = {
590       order: [ [ 'Tags', 'name', 'ASC' ] ],
591       where: {
592         id
593       }
594     }
595
596     return VideoModel
597       .scope([
598         ScopeNames.WITH_RATES,
599         ScopeNames.WITH_SHARES,
600         ScopeNames.WITH_TAGS,
601         ScopeNames.WITH_FILES,
602         ScopeNames.WITH_ACCOUNT_DETAILS,
603         ScopeNames.WITH_COMMENTS
604       ])
605       .findOne(options)
606   }
607
608   static searchAndPopulateAccountAndServerAndTags (value: string, start: number, count: number, sort: string) {
609     const serverInclude: IIncludeOptions = {
610       model: ServerModel,
611       required: false
612     }
613
614     const accountInclude: IIncludeOptions = {
615       model: AccountModel,
616       include: [
617         {
618           model: ActorModel,
619           required: true,
620           include: [ serverInclude ]
621         }
622       ]
623     }
624
625     const videoChannelInclude: IIncludeOptions = {
626       model: VideoChannelModel,
627       include: [ accountInclude ],
628       required: true
629     }
630
631     const tagInclude: IIncludeOptions = {
632       model: TagModel
633     }
634
635     const query: IFindOptions<VideoModel> = {
636       distinct: true, // Because we have tags
637       offset: start,
638       limit: count,
639       order: [ getSort(sort) ],
640       where: {}
641     }
642
643     // TODO: search on tags too
644     // const escapedValue = Video['sequelize'].escape('%' + value + '%')
645     // query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
646     //   `(SELECT "VideoTags"."videoId"
647     //     FROM "Tags"
648     //     INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
649     //     WHERE name ILIKE ${escapedValue}
650     //    )`
651     // )
652
653     // TODO: search on account too
654     // accountInclude.where = {
655     //   name: {
656     //     [Sequelize.Op.iLike]: '%' + value + '%'
657     //   }
658     // }
659     query.where['name'] = {
660       [Sequelize.Op.iLike]: '%' + value + '%'
661     }
662
663     query.include = [
664       videoChannelInclude, tagInclude
665     ]
666
667     return VideoModel.scope([ ScopeNames.AVAILABLE_FOR_LIST ])
668       .findAndCountAll(query).then(({ rows, count }) => {
669         return {
670           data: rows,
671           total: count
672         }
673       })
674   }
675
676   getOriginalFile () {
677     if (Array.isArray(this.VideoFiles) === false) return undefined
678
679     // The original file is the file that have the higher resolution
680     return maxBy(this.VideoFiles, file => file.resolution)
681   }
682
683   getVideoFilename (videoFile: VideoFileModel) {
684     return this.uuid + '-' + videoFile.resolution + videoFile.extname
685   }
686
687   getThumbnailName () {
688     // We always have a copy of the thumbnail
689     const extension = '.jpg'
690     return this.uuid + extension
691   }
692
693   getPreviewName () {
694     const extension = '.jpg'
695     return this.uuid + extension
696   }
697
698   getTorrentFileName (videoFile: VideoFileModel) {
699     const extension = '.torrent'
700     return this.uuid + '-' + videoFile.resolution + extension
701   }
702
703   isOwned () {
704     return this.remote === false
705   }
706
707   createPreview (videoFile: VideoFileModel) {
708     const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
709
710     return generateImageFromVideoFile(
711       this.getVideoFilePath(videoFile),
712       CONFIG.STORAGE.PREVIEWS_DIR,
713       this.getPreviewName(),
714       imageSize
715     )
716   }
717
718   createThumbnail (videoFile: VideoFileModel) {
719     const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
720
721     return generateImageFromVideoFile(
722       this.getVideoFilePath(videoFile),
723       CONFIG.STORAGE.THUMBNAILS_DIR,
724       this.getThumbnailName(),
725       imageSize
726     )
727   }
728
729   getVideoFilePath (videoFile: VideoFileModel) {
730     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
731   }
732
733   createTorrentAndSetInfoHash = async function (videoFile: VideoFileModel) {
734     const options = {
735       announceList: [
736         [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
737       ],
738       urlList: [
739         CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
740       ]
741     }
742
743     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
744
745     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
746     logger.info('Creating torrent %s.', filePath)
747
748     await writeFilePromise(filePath, torrent)
749
750     const parsedTorrent = parseTorrent(torrent)
751     videoFile.infoHash = parsedTorrent.infoHash
752   }
753
754   getEmbedPath () {
755     return '/videos/embed/' + this.uuid
756   }
757
758   getThumbnailPath () {
759     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
760   }
761
762   getPreviewPath () {
763     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
764   }
765
766   toFormattedJSON () {
767     let serverHost
768
769     if (this.VideoChannel.Account.Actor.Server) {
770       serverHost = this.VideoChannel.Account.Actor.Server.host
771     } else {
772       // It means it's our video
773       serverHost = CONFIG.WEBSERVER.HOST
774     }
775
776     return {
777       id: this.id,
778       uuid: this.uuid,
779       name: this.name,
780       category: this.category,
781       categoryLabel: this.getCategoryLabel(),
782       licence: this.licence,
783       licenceLabel: this.getLicenceLabel(),
784       language: this.language,
785       languageLabel: this.getLanguageLabel(),
786       nsfw: this.nsfw,
787       description: this.getTruncatedDescription(),
788       serverHost,
789       isLocal: this.isOwned(),
790       accountName: this.VideoChannel.Account.name,
791       duration: this.duration,
792       views: this.views,
793       likes: this.likes,
794       dislikes: this.dislikes,
795       thumbnailPath: this.getThumbnailPath(),
796       previewPath: this.getPreviewPath(),
797       embedPath: this.getEmbedPath(),
798       createdAt: this.createdAt,
799       updatedAt: this.updatedAt
800     } as Video
801   }
802
803   toFormattedDetailsJSON () {
804     const formattedJson = this.toFormattedJSON()
805
806     // Maybe our server is not up to date and there are new privacy settings since our version
807     let privacyLabel = VIDEO_PRIVACIES[this.privacy]
808     if (!privacyLabel) privacyLabel = 'Unknown'
809
810     const detailsJson = {
811       privacyLabel,
812       privacy: this.privacy,
813       descriptionPath: this.getDescriptionPath(),
814       channel: this.VideoChannel.toFormattedJSON(),
815       account: this.VideoChannel.Account.toFormattedJSON(),
816       tags: map<TagModel, string>(this.Tags, 'name'),
817       commentsEnabled: this.commentsEnabled,
818       files: []
819     }
820
821     // Format and sort video files
822     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
823     detailsJson.files = this.VideoFiles
824       .map(videoFile => {
825         let resolutionLabel = videoFile.resolution + 'p'
826
827         return {
828           resolution: videoFile.resolution,
829           resolutionLabel,
830           magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
831           size: videoFile.size,
832           torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
833           fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
834         }
835       })
836       .sort((a, b) => {
837         if (a.resolution < b.resolution) return 1
838         if (a.resolution === b.resolution) return 0
839         return -1
840       })
841
842     return Object.assign(formattedJson, detailsJson) as VideoDetails
843   }
844
845   toActivityPubObject (): VideoTorrentObject {
846     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
847     if (!this.Tags) this.Tags = []
848
849     const tag = this.Tags.map(t => ({
850       type: 'Hashtag' as 'Hashtag',
851       name: t.name
852     }))
853
854     let language
855     if (this.language) {
856       language = {
857         identifier: this.language + '',
858         name: this.getLanguageLabel()
859       }
860     }
861
862     let category
863     if (this.category) {
864       category = {
865         identifier: this.category + '',
866         name: this.getCategoryLabel()
867       }
868     }
869
870     let licence
871     if (this.licence) {
872       licence = {
873         identifier: this.licence + '',
874         name: this.getLicenceLabel()
875       }
876     }
877
878     let likesObject
879     let dislikesObject
880
881     if (Array.isArray(this.AccountVideoRates)) {
882       const likes: string[] = []
883       const dislikes: string[] = []
884
885       for (const rate of this.AccountVideoRates) {
886         if (rate.type === 'like') {
887           likes.push(rate.Account.Actor.url)
888         } else if (rate.type === 'dislike') {
889           dislikes.push(rate.Account.Actor.url)
890         }
891       }
892
893       likesObject = activityPubCollection(likes)
894       dislikesObject = activityPubCollection(dislikes)
895     }
896
897     let sharesObject
898     if (Array.isArray(this.VideoShares)) {
899       const shares: string[] = []
900
901       for (const videoShare of this.VideoShares) {
902         const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Actor)
903         shares.push(shareUrl)
904       }
905
906       sharesObject = activityPubCollection(shares)
907     }
908
909     let commentsObject
910     if (Array.isArray(this.VideoComments)) {
911       const comments: string[] = []
912
913       for (const videoComment of this.VideoComments) {
914         comments.push(videoComment.url)
915       }
916
917       commentsObject = activityPubCollection(comments)
918     }
919
920     const url = []
921     for (const file of this.VideoFiles) {
922       url.push({
923         type: 'Link',
924         mimeType: 'video/' + file.extname.replace('.', ''),
925         url: this.getVideoFileUrl(file, baseUrlHttp),
926         width: file.resolution,
927         size: file.size
928       })
929
930       url.push({
931         type: 'Link',
932         mimeType: 'application/x-bittorrent',
933         url: this.getTorrentUrl(file, baseUrlHttp),
934         width: file.resolution
935       })
936
937       url.push({
938         type: 'Link',
939         mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
940         url: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
941         width: file.resolution
942       })
943     }
944
945     // Add video url too
946     url.push({
947       type: 'Link',
948       mimeType: 'text/html',
949       url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
950     })
951
952     return {
953       type: 'Video' as 'Video',
954       id: this.url,
955       name: this.name,
956       // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
957       duration: 'PT' + this.duration + 'S',
958       uuid: this.uuid,
959       tag,
960       category,
961       licence,
962       language,
963       views: this.views,
964       nsfw: this.nsfw,
965       commentsEnabled: this.commentsEnabled,
966       published: this.createdAt.toISOString(),
967       updated: this.updatedAt.toISOString(),
968       mediaType: 'text/markdown',
969       content: this.getTruncatedDescription(),
970       icon: {
971         type: 'Image',
972         url: this.getThumbnailUrl(baseUrlHttp),
973         mediaType: 'image/jpeg',
974         width: THUMBNAILS_SIZE.width,
975         height: THUMBNAILS_SIZE.height
976       },
977       url,
978       likes: likesObject,
979       dislikes: dislikesObject,
980       shares: sharesObject,
981       comments: commentsObject,
982       attributedTo: [
983         {
984           type: 'Group',
985           id: this.VideoChannel.Actor.url
986         }
987       ]
988     }
989   }
990
991   getTruncatedDescription () {
992     if (!this.description) return null
993
994     const options = {
995       length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
996     }
997
998     return truncate(this.description, options)
999   }
1000
1001   optimizeOriginalVideofile = async function () {
1002     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1003     const newExtname = '.mp4'
1004     const inputVideoFile = this.getOriginalFile()
1005     const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1006     const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1007
1008     const transcodeOptions = {
1009       inputPath: videoInputPath,
1010       outputPath: videoOutputPath
1011     }
1012
1013     try {
1014       // Could be very long!
1015       await transcode(transcodeOptions)
1016
1017       await unlinkPromise(videoInputPath)
1018
1019       // Important to do this before getVideoFilename() to take in account the new file extension
1020       inputVideoFile.set('extname', newExtname)
1021
1022       await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
1023       const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
1024
1025       inputVideoFile.set('size', stats.size)
1026
1027       await this.createTorrentAndSetInfoHash(inputVideoFile)
1028       await inputVideoFile.save()
1029
1030     } catch (err) {
1031       // Auto destruction...
1032       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
1033
1034       throw err
1035     }
1036   }
1037
1038   transcodeOriginalVideofile = async function (resolution: VideoResolution) {
1039     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1040     const extname = '.mp4'
1041
1042     // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1043     const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1044
1045     const newVideoFile = new VideoFileModel({
1046       resolution,
1047       extname,
1048       size: 0,
1049       videoId: this.id
1050     })
1051     const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1052
1053     const transcodeOptions = {
1054       inputPath: videoInputPath,
1055       outputPath: videoOutputPath,
1056       resolution
1057     }
1058
1059     await transcode(transcodeOptions)
1060
1061     const stats = await statPromise(videoOutputPath)
1062
1063     newVideoFile.set('size', stats.size)
1064
1065     await this.createTorrentAndSetInfoHash(newVideoFile)
1066
1067     await newVideoFile.save()
1068
1069     this.VideoFiles.push(newVideoFile)
1070   }
1071
1072   getOriginalFileHeight () {
1073     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1074
1075     return getVideoFileHeight(originalFilePath)
1076   }
1077
1078   getDescriptionPath () {
1079     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1080   }
1081
1082   getCategoryLabel () {
1083     let categoryLabel = VIDEO_CATEGORIES[this.category]
1084     if (!categoryLabel) categoryLabel = 'Misc'
1085
1086     return categoryLabel
1087   }
1088
1089   getLicenceLabel () {
1090     let licenceLabel = VIDEO_LICENCES[this.licence]
1091     if (!licenceLabel) licenceLabel = 'Unknown'
1092
1093     return licenceLabel
1094   }
1095
1096   getLanguageLabel () {
1097     let languageLabel = VIDEO_LANGUAGES[this.language]
1098     if (!languageLabel) languageLabel = 'Unknown'
1099
1100     return languageLabel
1101   }
1102
1103   removeThumbnail () {
1104     const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1105     return unlinkPromise(thumbnailPath)
1106   }
1107
1108   removePreview () {
1109     // Same name than video thumbnail
1110     return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1111   }
1112
1113   removeFile (videoFile: VideoFileModel) {
1114     const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1115     return unlinkPromise(filePath)
1116   }
1117
1118   removeTorrent (videoFile: VideoFileModel) {
1119     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1120     return unlinkPromise(torrentPath)
1121   }
1122
1123   private getBaseUrls () {
1124     let baseUrlHttp
1125     let baseUrlWs
1126
1127     if (this.isOwned()) {
1128       baseUrlHttp = CONFIG.WEBSERVER.URL
1129       baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1130     } else {
1131       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1132       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1133     }
1134
1135     return { baseUrlHttp, baseUrlWs }
1136   }
1137
1138   private getThumbnailUrl (baseUrlHttp: string) {
1139     return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1140   }
1141
1142   private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1143     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1144   }
1145
1146   private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1147     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1148   }
1149
1150   private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1151     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1152     const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1153     const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1154
1155     const magnetHash = {
1156       xs,
1157       announce,
1158       urlList,
1159       infoHash: videoFile.infoHash,
1160       name: this.name
1161     }
1162
1163     return magnetUtil.encode(magnetHash)
1164   }
1165 }