Add information concerning video privacy in my videos list
[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
326   language: number
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: number) {
881     let languageLabel = VIDEO_LANGUAGES[id]
882     if (!languageLabel) languageLabel = 'Unknown'
883
884     return languageLabel
885   }
886
887   private static getPrivacyLabel (id: number) {
888     let privacyLabel = VIDEO_PRIVACIES[id]
889     if (!privacyLabel) privacyLabel = 'Unknown'
890
891     return privacyLabel
892   }
893
894   getOriginalFile () {
895     if (Array.isArray(this.VideoFiles) === false) return undefined
896
897     // The original file is the file that have the higher resolution
898     return maxBy(this.VideoFiles, file => file.resolution)
899   }
900
901   getVideoFilename (videoFile: VideoFileModel) {
902     return this.uuid + '-' + videoFile.resolution + videoFile.extname
903   }
904
905   getThumbnailName () {
906     // We always have a copy of the thumbnail
907     const extension = '.jpg'
908     return this.uuid + extension
909   }
910
911   getPreviewName () {
912     const extension = '.jpg'
913     return this.uuid + extension
914   }
915
916   getTorrentFileName (videoFile: VideoFileModel) {
917     const extension = '.torrent'
918     return this.uuid + '-' + videoFile.resolution + extension
919   }
920
921   isOwned () {
922     return this.remote === false
923   }
924
925   createPreview (videoFile: VideoFileModel) {
926     return generateImageFromVideoFile(
927       this.getVideoFilePath(videoFile),
928       CONFIG.STORAGE.PREVIEWS_DIR,
929       this.getPreviewName(),
930       PREVIEWS_SIZE
931     )
932   }
933
934   createThumbnail (videoFile: VideoFileModel) {
935     return generateImageFromVideoFile(
936       this.getVideoFilePath(videoFile),
937       CONFIG.STORAGE.THUMBNAILS_DIR,
938       this.getThumbnailName(),
939       THUMBNAILS_SIZE
940     )
941   }
942
943   getVideoFilePath (videoFile: VideoFileModel) {
944     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
945   }
946
947   createTorrentAndSetInfoHash = async function (videoFile: VideoFileModel) {
948     const options = {
949       announceList: [
950         [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ],
951         [ CONFIG.WEBSERVER.URL + '/tracker/announce' ]
952       ],
953       urlList: [
954         CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
955       ]
956     }
957
958     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
959
960     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
961     logger.info('Creating torrent %s.', filePath)
962
963     await writeFilePromise(filePath, torrent)
964
965     const parsedTorrent = parseTorrent(torrent)
966     videoFile.infoHash = parsedTorrent.infoHash
967   }
968
969   getEmbedPath () {
970     return '/videos/embed/' + this.uuid
971   }
972
973   getThumbnailPath () {
974     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
975   }
976
977   getPreviewPath () {
978     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
979   }
980
981   toFormattedJSON (): Video {
982     const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
983
984     return {
985       id: this.id,
986       uuid: this.uuid,
987       name: this.name,
988       category: {
989         id: this.category,
990         label: VideoModel.getCategoryLabel(this.category)
991       },
992       licence: {
993         id: this.licence,
994         label: VideoModel.getLicenceLabel(this.licence)
995       },
996       language: {
997         id: this.language,
998         label: VideoModel.getLanguageLabel(this.language)
999       },
1000       privacy: {
1001         id: this.privacy,
1002         label: VideoModel.getPrivacyLabel(this.privacy)
1003       },
1004       nsfw: this.nsfw,
1005       description: this.getTruncatedDescription(),
1006       isLocal: this.isOwned(),
1007       duration: this.duration,
1008       views: this.views,
1009       likes: this.likes,
1010       dislikes: this.dislikes,
1011       thumbnailPath: this.getThumbnailPath(),
1012       previewPath: this.getPreviewPath(),
1013       embedPath: this.getEmbedPath(),
1014       createdAt: this.createdAt,
1015       updatedAt: this.updatedAt,
1016       publishedAt: this.publishedAt,
1017       account: {
1018         name: formattedAccount.name,
1019         displayName: formattedAccount.displayName,
1020         url: formattedAccount.url,
1021         host: formattedAccount.host,
1022         avatar: formattedAccount.avatar
1023       }
1024     }
1025   }
1026
1027   toFormattedDetailsJSON (): VideoDetails {
1028     const formattedJson = this.toFormattedJSON()
1029
1030     const detailsJson = {
1031       support: this.support,
1032       descriptionPath: this.getDescriptionPath(),
1033       channel: this.VideoChannel.toFormattedJSON(),
1034       account: this.VideoChannel.Account.toFormattedJSON(),
1035       tags: map(this.Tags, 'name'),
1036       commentsEnabled: this.commentsEnabled,
1037       files: []
1038     }
1039
1040     // Format and sort video files
1041     detailsJson.files = this.getFormattedVideoFilesJSON()
1042
1043     return Object.assign(formattedJson, detailsJson)
1044   }
1045
1046   getFormattedVideoFilesJSON (): VideoFile[] {
1047     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1048
1049     return this.VideoFiles
1050         .map(videoFile => {
1051           let resolutionLabel = videoFile.resolution + 'p'
1052
1053           return {
1054             resolution: {
1055               id: videoFile.resolution,
1056               label: resolutionLabel
1057             },
1058             magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1059             size: videoFile.size,
1060             torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1061             fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
1062           } as VideoFile
1063         })
1064         .sort((a, b) => {
1065           if (a.resolution.id < b.resolution.id) return 1
1066           if (a.resolution.id === b.resolution.id) return 0
1067           return -1
1068         })
1069   }
1070
1071   toActivityPubObject (): VideoTorrentObject {
1072     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1073     if (!this.Tags) this.Tags = []
1074
1075     const tag = this.Tags.map(t => ({
1076       type: 'Hashtag' as 'Hashtag',
1077       name: t.name
1078     }))
1079
1080     let language
1081     if (this.language) {
1082       language = {
1083         identifier: this.language + '',
1084         name: VideoModel.getLanguageLabel(this.language)
1085       }
1086     }
1087
1088     let category
1089     if (this.category) {
1090       category = {
1091         identifier: this.category + '',
1092         name: VideoModel.getCategoryLabel(this.category)
1093       }
1094     }
1095
1096     let licence
1097     if (this.licence) {
1098       licence = {
1099         identifier: this.licence + '',
1100         name: VideoModel.getLicenceLabel(this.licence)
1101       }
1102     }
1103
1104     let likesObject
1105     let dislikesObject
1106
1107     if (Array.isArray(this.AccountVideoRates)) {
1108       const res = this.toRatesActivityPubObjects()
1109       likesObject = res.likesObject
1110       dislikesObject = res.dislikesObject
1111     }
1112
1113     let sharesObject
1114     if (Array.isArray(this.VideoShares)) {
1115       sharesObject = this.toAnnouncesActivityPubObject()
1116     }
1117
1118     let commentsObject
1119     if (Array.isArray(this.VideoComments)) {
1120       commentsObject = this.toCommentsActivityPubObject()
1121     }
1122
1123     const url = []
1124     for (const file of this.VideoFiles) {
1125       url.push({
1126         type: 'Link',
1127         mimeType: 'video/' + file.extname.replace('.', ''),
1128         href: this.getVideoFileUrl(file, baseUrlHttp),
1129         width: file.resolution,
1130         size: file.size
1131       })
1132
1133       url.push({
1134         type: 'Link',
1135         mimeType: 'application/x-bittorrent',
1136         href: this.getTorrentUrl(file, baseUrlHttp),
1137         width: file.resolution
1138       })
1139
1140       url.push({
1141         type: 'Link',
1142         mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
1143         href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1144         width: file.resolution
1145       })
1146     }
1147
1148     // Add video url too
1149     url.push({
1150       type: 'Link',
1151       mimeType: 'text/html',
1152       href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1153     })
1154
1155     return {
1156       type: 'Video' as 'Video',
1157       id: this.url,
1158       name: this.name,
1159       duration: this.getActivityStreamDuration(),
1160       uuid: this.uuid,
1161       tag,
1162       category,
1163       licence,
1164       language,
1165       views: this.views,
1166       sensitive: this.nsfw,
1167       commentsEnabled: this.commentsEnabled,
1168       published: this.publishedAt.toISOString(),
1169       updated: this.updatedAt.toISOString(),
1170       mediaType: 'text/markdown',
1171       content: this.getTruncatedDescription(),
1172       support: this.support,
1173       icon: {
1174         type: 'Image',
1175         url: this.getThumbnailUrl(baseUrlHttp),
1176         mediaType: 'image/jpeg',
1177         width: THUMBNAILS_SIZE.width,
1178         height: THUMBNAILS_SIZE.height
1179       },
1180       url,
1181       likes: likesObject,
1182       dislikes: dislikesObject,
1183       shares: sharesObject,
1184       comments: commentsObject,
1185       attributedTo: [
1186         {
1187           type: 'Person',
1188           id: this.VideoChannel.Account.Actor.url
1189         },
1190         {
1191           type: 'Group',
1192           id: this.VideoChannel.Actor.url
1193         }
1194       ]
1195     }
1196   }
1197
1198   toAnnouncesActivityPubObject () {
1199     const shares: string[] = []
1200
1201     for (const videoShare of this.VideoShares) {
1202       shares.push(videoShare.url)
1203     }
1204
1205     return activityPubCollection(getVideoSharesActivityPubUrl(this), shares)
1206   }
1207
1208   toCommentsActivityPubObject () {
1209     const comments: string[] = []
1210
1211     for (const videoComment of this.VideoComments) {
1212       comments.push(videoComment.url)
1213     }
1214
1215     return activityPubCollection(getVideoCommentsActivityPubUrl(this), comments)
1216   }
1217
1218   toRatesActivityPubObjects () {
1219     const likes: string[] = []
1220     const dislikes: string[] = []
1221
1222     for (const rate of this.AccountVideoRates) {
1223       if (rate.type === 'like') {
1224         likes.push(rate.Account.Actor.url)
1225       } else if (rate.type === 'dislike') {
1226         dislikes.push(rate.Account.Actor.url)
1227       }
1228     }
1229
1230     const likesObject = activityPubCollection(getVideoLikesActivityPubUrl(this), likes)
1231     const dislikesObject = activityPubCollection(getVideoDislikesActivityPubUrl(this), dislikes)
1232
1233     return { likesObject, dislikesObject }
1234   }
1235
1236   getTruncatedDescription () {
1237     if (!this.description) return null
1238
1239     const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1240     return peertubeTruncate(this.description, maxLength)
1241   }
1242
1243   optimizeOriginalVideofile = async function () {
1244     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1245     const newExtname = '.mp4'
1246     const inputVideoFile = this.getOriginalFile()
1247     const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1248     const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1249
1250     const transcodeOptions = {
1251       inputPath: videoInputPath,
1252       outputPath: videoOutputPath
1253     }
1254
1255     // Could be very long!
1256     await transcode(transcodeOptions)
1257
1258     try {
1259       await unlinkPromise(videoInputPath)
1260
1261       // Important to do this before getVideoFilename() to take in account the new file extension
1262       inputVideoFile.set('extname', newExtname)
1263
1264       await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
1265       const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
1266
1267       inputVideoFile.set('size', stats.size)
1268
1269       await this.createTorrentAndSetInfoHash(inputVideoFile)
1270       await inputVideoFile.save()
1271
1272     } catch (err) {
1273       // Auto destruction...
1274       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
1275
1276       throw err
1277     }
1278   }
1279
1280   transcodeOriginalVideofile = async function (resolution: VideoResolution, isPortraitMode: boolean) {
1281     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1282     const extname = '.mp4'
1283
1284     // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1285     const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1286
1287     const newVideoFile = new VideoFileModel({
1288       resolution,
1289       extname,
1290       size: 0,
1291       videoId: this.id
1292     })
1293     const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1294
1295     const transcodeOptions = {
1296       inputPath: videoInputPath,
1297       outputPath: videoOutputPath,
1298       resolution,
1299       isPortraitMode
1300     }
1301
1302     await transcode(transcodeOptions)
1303
1304     const stats = await statPromise(videoOutputPath)
1305
1306     newVideoFile.set('size', stats.size)
1307
1308     await this.createTorrentAndSetInfoHash(newVideoFile)
1309
1310     await newVideoFile.save()
1311
1312     this.VideoFiles.push(newVideoFile)
1313   }
1314
1315   getOriginalFileResolution () {
1316     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1317
1318     return getVideoFileResolution(originalFilePath)
1319   }
1320
1321   getDescriptionPath () {
1322     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1323   }
1324
1325   removeThumbnail () {
1326     const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1327     return unlinkPromise(thumbnailPath)
1328   }
1329
1330   removePreview () {
1331     // Same name than video thumbnail
1332     return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1333   }
1334
1335   removeFile (videoFile: VideoFileModel) {
1336     const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1337     return unlinkPromise(filePath)
1338   }
1339
1340   removeTorrent (videoFile: VideoFileModel) {
1341     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1342     return unlinkPromise(torrentPath)
1343   }
1344
1345   getActivityStreamDuration () {
1346     // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1347     return 'PT' + this.duration + 'S'
1348   }
1349
1350   private getBaseUrls () {
1351     let baseUrlHttp
1352     let baseUrlWs
1353
1354     if (this.isOwned()) {
1355       baseUrlHttp = CONFIG.WEBSERVER.URL
1356       baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1357     } else {
1358       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1359       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1360     }
1361
1362     return { baseUrlHttp, baseUrlWs }
1363   }
1364
1365   private getThumbnailUrl (baseUrlHttp: string) {
1366     return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1367   }
1368
1369   private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1370     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1371   }
1372
1373   private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1374     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1375   }
1376
1377   private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1378     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1379     const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1380     const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1381
1382     const magnetHash = {
1383       xs,
1384       announce,
1385       urlList,
1386       infoHash: videoFile.infoHash,
1387       name: this.name
1388     }
1389
1390     return magnetUtil.encode(magnetHash)
1391   }
1392 }