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