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