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