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