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