Improve torrent/video download
[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 {
33   createTorrentPromise,
34   peertubeTruncate,
35   renamePromise,
36   statPromise,
37   unlinkPromise,
38   writeFilePromise
39 } from '../../helpers/core-utils'
40 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
41 import { isBooleanValid } from '../../helpers/custom-validators/misc'
42 import {
43   isVideoCategoryValid,
44   isVideoDescriptionValid,
45   isVideoDurationValid,
46   isVideoLanguageValid,
47   isVideoLicenceValid,
48   isVideoNameValid,
49   isVideoPrivacyValid,
50   isVideoSupportValid
51 } from '../../helpers/custom-validators/videos'
52 import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
53 import { logger } from '../../helpers/logger'
54 import { getServerActor } from '../../helpers/utils'
55 import {
56   API_VERSION,
57   CONFIG,
58   CONSTRAINTS_FIELDS,
59   PREVIEWS_SIZE,
60   REMOTE_SCHEME,
61   STATIC_DOWNLOAD_PATHS,
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           // We only want videos shared by this actor
606           where: {
607             [Sequelize.Op.and]: [
608               {
609                 id: {
610                   [Sequelize.Op.not]: null
611                 }
612               },
613               {
614                 actorId
615               }
616             ]
617           },
618           include: [
619             {
620               attributes: [ 'id', 'url' ],
621               model: ActorModel.unscoped()
622             }
623           ]
624         },
625         {
626           model: VideoChannelModel.unscoped(),
627           required: true,
628           include: [
629             {
630               attributes: [ 'name' ],
631               model: AccountModel.unscoped(),
632               required: true,
633               include: [
634                 {
635                   attributes: [ 'id', 'url', 'followersUrl' ],
636                   model: ActorModel.unscoped(),
637                   required: true
638                 }
639               ]
640             },
641             {
642               attributes: [ 'id', 'url', 'followersUrl' ],
643               model: ActorModel.unscoped(),
644               required: true
645             }
646           ]
647         },
648         VideoFileModel,
649         TagModel
650       ]
651     }
652
653     return Bluebird.all([
654       // FIXME: typing issue
655       VideoModel.findAll(query as any),
656       VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
657     ]).then(([ rows, totals ]) => {
658       // totals: totalVideos + totalVideoShares
659       let totalVideos = 0
660       let totalVideoShares = 0
661       if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
662       if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
663
664       const total = totalVideos + totalVideoShares
665       return {
666         data: rows,
667         total: total
668       }
669     })
670   }
671
672   static listAccountVideosForApi (accountId: number, start: number, count: number, sort: string, hideNSFW: boolean, withFiles = false) {
673     const query: IFindOptions<VideoModel> = {
674       offset: start,
675       limit: count,
676       order: getSort(sort),
677       include: [
678         {
679           model: VideoChannelModel,
680           required: true,
681           include: [
682             {
683               model: AccountModel,
684               where: {
685                 id: accountId
686               },
687               required: true
688             }
689           ]
690         }
691       ]
692     }
693
694     if (withFiles === true) {
695       query.include.push({
696         model: VideoFileModel.unscoped(),
697         required: true
698       })
699     }
700
701     if (hideNSFW === true) {
702       query.where = {
703         nsfw: false
704       }
705     }
706
707     return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
708       return {
709         data: rows,
710         total: count
711       }
712     })
713   }
714
715   static async listForApi (options: {
716     start: number,
717     count: number,
718     sort: string,
719     hideNSFW: boolean,
720     withFiles: boolean,
721     filter?: VideoFilter,
722     accountId?: number,
723     videoChannelId?: number
724   }) {
725     const query = {
726       offset: options.start,
727       limit: options.count,
728       order: getSort(options.sort)
729     }
730
731     const serverActor = await getServerActor()
732     const scopes = {
733       method: [
734         ScopeNames.AVAILABLE_FOR_LIST, {
735           actorId: serverActor.id,
736           hideNSFW: options.hideNSFW,
737           filter: options.filter,
738           withFiles: options.withFiles,
739           accountId: options.accountId,
740           videoChannelId: options.videoChannelId
741         }
742       ]
743     }
744
745     return VideoModel.scope(scopes)
746       .findAndCountAll(query)
747       .then(({ rows, count }) => {
748         return {
749           data: rows,
750           total: count
751         }
752       })
753   }
754
755   static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) {
756     const query: IFindOptions<VideoModel> = {
757       offset: start,
758       limit: count,
759       order: getSort(sort),
760       where: {
761         [Sequelize.Op.or]: [
762           {
763             name: {
764               [ Sequelize.Op.iLike ]: '%' + value + '%'
765             }
766           },
767           {
768             preferredUsernameChannel: Sequelize.where(Sequelize.col('VideoChannel->Actor.preferredUsername'), {
769               [ Sequelize.Op.iLike ]: '%' + value + '%'
770             })
771           },
772           {
773             preferredUsernameAccount: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor.preferredUsername'), {
774               [ Sequelize.Op.iLike ]: '%' + value + '%'
775             })
776           },
777           {
778             host: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor->Server.host'), {
779               [ Sequelize.Op.iLike ]: '%' + value + '%'
780             })
781           }
782         ]
783       }
784     }
785
786     const serverActor = await getServerActor()
787     const scopes = {
788       method: [
789         ScopeNames.AVAILABLE_FOR_LIST, {
790           actorId: serverActor.id,
791           hideNSFW
792         }
793       ]
794     }
795
796     return VideoModel.scope(scopes)
797       .findAndCountAll(query)
798       .then(({ rows, count }) => {
799         return {
800           data: rows,
801           total: count
802         }
803       })
804   }
805
806   static load (id: number) {
807     return VideoModel.findById(id)
808   }
809
810   static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
811     const query: IFindOptions<VideoModel> = {
812       where: {
813         url
814       }
815     }
816
817     if (t !== undefined) query.transaction = t
818
819     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
820   }
821
822   static loadByUUIDOrURLAndPopulateAccount (uuid: string, url: string, t?: Sequelize.Transaction) {
823     const query: IFindOptions<VideoModel> = {
824       where: {
825         [Sequelize.Op.or]: [
826           { uuid },
827           { url }
828         ]
829       }
830     }
831
832     if (t !== undefined) query.transaction = t
833
834     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
835   }
836
837   static loadAndPopulateAccountAndServerAndTags (id: number) {
838     const options = {
839       order: [ [ 'Tags', 'name', 'ASC' ] ]
840     }
841
842     return VideoModel
843       .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
844       .findById(id, options)
845   }
846
847   static loadByUUID (uuid: string) {
848     const options = {
849       where: {
850         uuid
851       }
852     }
853
854     return VideoModel
855       .scope([ ScopeNames.WITH_FILES ])
856       .findOne(options)
857   }
858
859   static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) {
860     const options = {
861       order: [ [ 'Tags', 'name', 'ASC' ] ],
862       where: {
863         uuid
864       }
865     }
866
867     return VideoModel
868       .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
869       .findOne(options)
870   }
871
872   static async getStats () {
873     const totalLocalVideos = await VideoModel.count({
874       where: {
875         remote: false
876       }
877     })
878     const totalVideos = await VideoModel.count()
879
880     let totalLocalVideoViews = await VideoModel.sum('views', {
881       where: {
882         remote: false
883       }
884     })
885     // Sequelize could return null...
886     if (!totalLocalVideoViews) totalLocalVideoViews = 0
887
888     return {
889       totalLocalVideos,
890       totalLocalVideoViews,
891       totalVideos
892     }
893   }
894
895   private static buildActorWhereWithFilter (filter?: VideoFilter) {
896     if (filter && filter === 'local') {
897       return {
898         serverId: null
899       }
900     }
901
902     return {}
903   }
904
905   private static getCategoryLabel (id: number) {
906     let categoryLabel = VIDEO_CATEGORIES[id]
907     if (!categoryLabel) categoryLabel = 'Misc'
908
909     return categoryLabel
910   }
911
912   private static getLicenceLabel (id: number) {
913     let licenceLabel = VIDEO_LICENCES[id]
914     if (!licenceLabel) licenceLabel = 'Unknown'
915
916     return licenceLabel
917   }
918
919   private static getLanguageLabel (id: string) {
920     let languageLabel = VIDEO_LANGUAGES[id]
921     if (!languageLabel) languageLabel = 'Unknown'
922
923     return languageLabel
924   }
925
926   private static getPrivacyLabel (id: number) {
927     let privacyLabel = VIDEO_PRIVACIES[id]
928     if (!privacyLabel) privacyLabel = 'Unknown'
929
930     return privacyLabel
931   }
932
933   getOriginalFile () {
934     if (Array.isArray(this.VideoFiles) === false) return undefined
935
936     // The original file is the file that have the higher resolution
937     return maxBy(this.VideoFiles, file => file.resolution)
938   }
939
940   getVideoFilename (videoFile: VideoFileModel) {
941     return this.uuid + '-' + videoFile.resolution + videoFile.extname
942   }
943
944   getThumbnailName () {
945     // We always have a copy of the thumbnail
946     const extension = '.jpg'
947     return this.uuid + extension
948   }
949
950   getPreviewName () {
951     const extension = '.jpg'
952     return this.uuid + extension
953   }
954
955   getTorrentFileName (videoFile: VideoFileModel) {
956     const extension = '.torrent'
957     return this.uuid + '-' + videoFile.resolution + extension
958   }
959
960   isOwned () {
961     return this.remote === false
962   }
963
964   createPreview (videoFile: VideoFileModel) {
965     return generateImageFromVideoFile(
966       this.getVideoFilePath(videoFile),
967       CONFIG.STORAGE.PREVIEWS_DIR,
968       this.getPreviewName(),
969       PREVIEWS_SIZE
970     )
971   }
972
973   createThumbnail (videoFile: VideoFileModel) {
974     return generateImageFromVideoFile(
975       this.getVideoFilePath(videoFile),
976       CONFIG.STORAGE.THUMBNAILS_DIR,
977       this.getThumbnailName(),
978       THUMBNAILS_SIZE
979     )
980   }
981
982   getTorrentFilePath (videoFile: VideoFileModel) {
983     return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
984   }
985
986   getVideoFilePath (videoFile: VideoFileModel) {
987     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
988   }
989
990   async createTorrentAndSetInfoHash (videoFile: VideoFileModel) {
991     const options = {
992       // Keep the extname, it's used by the client to stream the file inside a web browser
993       name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
994       createdBy: 'PeerTube',
995       announceList: [
996         [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ],
997         [ CONFIG.WEBSERVER.URL + '/tracker/announce' ]
998       ],
999       urlList: [
1000         CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1001       ]
1002     }
1003
1004     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
1005
1006     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1007     logger.info('Creating torrent %s.', filePath)
1008
1009     await writeFilePromise(filePath, torrent)
1010
1011     const parsedTorrent = parseTorrent(torrent)
1012     videoFile.infoHash = parsedTorrent.infoHash
1013   }
1014
1015   getEmbedPath () {
1016     return '/videos/embed/' + this.uuid
1017   }
1018
1019   getThumbnailPath () {
1020     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
1021   }
1022
1023   getPreviewPath () {
1024     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1025   }
1026
1027   toFormattedJSON (): Video {
1028     const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
1029     const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
1030
1031     return {
1032       id: this.id,
1033       uuid: this.uuid,
1034       name: this.name,
1035       category: {
1036         id: this.category,
1037         label: VideoModel.getCategoryLabel(this.category)
1038       },
1039       licence: {
1040         id: this.licence,
1041         label: VideoModel.getLicenceLabel(this.licence)
1042       },
1043       language: {
1044         id: this.language,
1045         label: VideoModel.getLanguageLabel(this.language)
1046       },
1047       privacy: {
1048         id: this.privacy,
1049         label: VideoModel.getPrivacyLabel(this.privacy)
1050       },
1051       nsfw: this.nsfw,
1052       description: this.getTruncatedDescription(),
1053       isLocal: this.isOwned(),
1054       duration: this.duration,
1055       views: this.views,
1056       likes: this.likes,
1057       dislikes: this.dislikes,
1058       thumbnailPath: this.getThumbnailPath(),
1059       previewPath: this.getPreviewPath(),
1060       embedPath: this.getEmbedPath(),
1061       createdAt: this.createdAt,
1062       updatedAt: this.updatedAt,
1063       publishedAt: this.publishedAt,
1064       account: {
1065         id: formattedAccount.id,
1066         uuid: formattedAccount.uuid,
1067         name: formattedAccount.name,
1068         displayName: formattedAccount.displayName,
1069         url: formattedAccount.url,
1070         host: formattedAccount.host,
1071         avatar: formattedAccount.avatar
1072       },
1073       channel: {
1074         id: formattedVideoChannel.id,
1075         uuid: formattedVideoChannel.uuid,
1076         name: formattedVideoChannel.name,
1077         displayName: formattedVideoChannel.displayName,
1078         url: formattedVideoChannel.url,
1079         host: formattedVideoChannel.host,
1080         avatar: formattedVideoChannel.avatar
1081       }
1082     }
1083   }
1084
1085   toFormattedDetailsJSON (): VideoDetails {
1086     const formattedJson = this.toFormattedJSON()
1087
1088     const detailsJson = {
1089       support: this.support,
1090       descriptionPath: this.getDescriptionPath(),
1091       channel: this.VideoChannel.toFormattedJSON(),
1092       account: this.VideoChannel.Account.toFormattedJSON(),
1093       tags: map(this.Tags, 'name'),
1094       commentsEnabled: this.commentsEnabled,
1095       files: []
1096     }
1097
1098     // Format and sort video files
1099     detailsJson.files = this.getFormattedVideoFilesJSON()
1100
1101     return Object.assign(formattedJson, detailsJson)
1102   }
1103
1104   getFormattedVideoFilesJSON (): VideoFile[] {
1105     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1106
1107     return this.VideoFiles
1108         .map(videoFile => {
1109           let resolutionLabel = videoFile.resolution + 'p'
1110
1111           return {
1112             resolution: {
1113               id: videoFile.resolution,
1114               label: resolutionLabel
1115             },
1116             magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1117             size: videoFile.size,
1118             torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1119             torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
1120             fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
1121             fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
1122           } as VideoFile
1123         })
1124         .sort((a, b) => {
1125           if (a.resolution.id < b.resolution.id) return 1
1126           if (a.resolution.id === b.resolution.id) return 0
1127           return -1
1128         })
1129   }
1130
1131   toActivityPubObject (): VideoTorrentObject {
1132     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1133     if (!this.Tags) this.Tags = []
1134
1135     const tag = this.Tags.map(t => ({
1136       type: 'Hashtag' as 'Hashtag',
1137       name: t.name
1138     }))
1139
1140     let language
1141     if (this.language) {
1142       language = {
1143         identifier: this.language,
1144         name: VideoModel.getLanguageLabel(this.language)
1145       }
1146     }
1147
1148     let category
1149     if (this.category) {
1150       category = {
1151         identifier: this.category + '',
1152         name: VideoModel.getCategoryLabel(this.category)
1153       }
1154     }
1155
1156     let licence
1157     if (this.licence) {
1158       licence = {
1159         identifier: this.licence + '',
1160         name: VideoModel.getLicenceLabel(this.licence)
1161       }
1162     }
1163
1164     const url = []
1165     for (const file of this.VideoFiles) {
1166       url.push({
1167         type: 'Link',
1168         mimeType: 'video/' + file.extname.replace('.', ''),
1169         href: this.getVideoFileUrl(file, baseUrlHttp),
1170         width: file.resolution,
1171         size: file.size
1172       })
1173
1174       url.push({
1175         type: 'Link',
1176         mimeType: 'application/x-bittorrent',
1177         href: this.getTorrentUrl(file, baseUrlHttp),
1178         width: file.resolution
1179       })
1180
1181       url.push({
1182         type: 'Link',
1183         mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
1184         href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1185         width: file.resolution
1186       })
1187     }
1188
1189     // Add video url too
1190     url.push({
1191       type: 'Link',
1192       mimeType: 'text/html',
1193       href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1194     })
1195
1196     return {
1197       type: 'Video' as 'Video',
1198       id: this.url,
1199       name: this.name,
1200       duration: this.getActivityStreamDuration(),
1201       uuid: this.uuid,
1202       tag,
1203       category,
1204       licence,
1205       language,
1206       views: this.views,
1207       sensitive: this.nsfw,
1208       commentsEnabled: this.commentsEnabled,
1209       published: this.publishedAt.toISOString(),
1210       updated: this.updatedAt.toISOString(),
1211       mediaType: 'text/markdown',
1212       content: this.getTruncatedDescription(),
1213       support: this.support,
1214       icon: {
1215         type: 'Image',
1216         url: this.getThumbnailUrl(baseUrlHttp),
1217         mediaType: 'image/jpeg',
1218         width: THUMBNAILS_SIZE.width,
1219         height: THUMBNAILS_SIZE.height
1220       },
1221       url,
1222       likes: getVideoLikesActivityPubUrl(this),
1223       dislikes: getVideoDislikesActivityPubUrl(this),
1224       shares: getVideoSharesActivityPubUrl(this),
1225       comments: getVideoCommentsActivityPubUrl(this),
1226       attributedTo: [
1227         {
1228           type: 'Person',
1229           id: this.VideoChannel.Account.Actor.url
1230         },
1231         {
1232           type: 'Group',
1233           id: this.VideoChannel.Actor.url
1234         }
1235       ]
1236     }
1237   }
1238
1239   getTruncatedDescription () {
1240     if (!this.description) return null
1241
1242     const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1243     return peertubeTruncate(this.description, maxLength)
1244   }
1245
1246   async optimizeOriginalVideofile () {
1247     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1248     const newExtname = '.mp4'
1249     const inputVideoFile = this.getOriginalFile()
1250     const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1251     const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1252
1253     const transcodeOptions = {
1254       inputPath: videoInputPath,
1255       outputPath: videoOutputPath
1256     }
1257
1258     // Could be very long!
1259     await transcode(transcodeOptions)
1260
1261     try {
1262       await unlinkPromise(videoInputPath)
1263
1264       // Important to do this before getVideoFilename() to take in account the new file extension
1265       inputVideoFile.set('extname', newExtname)
1266
1267       await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
1268       const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
1269
1270       inputVideoFile.set('size', stats.size)
1271
1272       await this.createTorrentAndSetInfoHash(inputVideoFile)
1273       await inputVideoFile.save()
1274
1275     } catch (err) {
1276       // Auto destruction...
1277       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
1278
1279       throw err
1280     }
1281   }
1282
1283   async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
1284     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1285     const extname = '.mp4'
1286
1287     // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1288     const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1289
1290     const newVideoFile = new VideoFileModel({
1291       resolution,
1292       extname,
1293       size: 0,
1294       videoId: this.id
1295     })
1296     const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1297
1298     const transcodeOptions = {
1299       inputPath: videoInputPath,
1300       outputPath: videoOutputPath,
1301       resolution,
1302       isPortraitMode
1303     }
1304
1305     await transcode(transcodeOptions)
1306
1307     const stats = await statPromise(videoOutputPath)
1308
1309     newVideoFile.set('size', stats.size)
1310
1311     await this.createTorrentAndSetInfoHash(newVideoFile)
1312
1313     await newVideoFile.save()
1314
1315     this.VideoFiles.push(newVideoFile)
1316   }
1317
1318   getOriginalFileResolution () {
1319     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1320
1321     return getVideoFileResolution(originalFilePath)
1322   }
1323
1324   getDescriptionPath () {
1325     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1326   }
1327
1328   removeThumbnail () {
1329     const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1330     return unlinkPromise(thumbnailPath)
1331   }
1332
1333   removePreview () {
1334     // Same name than video thumbnail
1335     return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1336   }
1337
1338   removeFile (videoFile: VideoFileModel) {
1339     const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1340     return unlinkPromise(filePath)
1341   }
1342
1343   removeTorrent (videoFile: VideoFileModel) {
1344     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1345     return unlinkPromise(torrentPath)
1346   }
1347
1348   getActivityStreamDuration () {
1349     // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1350     return 'PT' + this.duration + 'S'
1351   }
1352
1353   private getBaseUrls () {
1354     let baseUrlHttp
1355     let baseUrlWs
1356
1357     if (this.isOwned()) {
1358       baseUrlHttp = CONFIG.WEBSERVER.URL
1359       baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1360     } else {
1361       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1362       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1363     }
1364
1365     return { baseUrlHttp, baseUrlWs }
1366   }
1367
1368   private getThumbnailUrl (baseUrlHttp: string) {
1369     return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1370   }
1371
1372   private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1373     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1374   }
1375
1376   private getTorrentDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1377     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1378   }
1379
1380   private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1381     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1382   }
1383
1384   private getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1385     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1386   }
1387
1388   private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1389     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1390     const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1391     const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1392
1393     const magnetHash = {
1394       xs,
1395       announce,
1396       urlList,
1397       infoHash: videoFile.infoHash,
1398       name: this.name
1399     }
1400
1401     return magnetUtil.encode(magnetHash)
1402   }
1403 }