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