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