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