Add account link in videos list
[oweals/peertube.git] / server / models / video / video.ts
1 import * as Bluebird from 'bluebird'
2 import { map, maxBy } from 'lodash'
3 import * as magnetUtil from 'magnet-uri'
4 import * as parseTorrent from 'parse-torrent'
5 import { join } from 'path'
6 import * as Sequelize from 'sequelize'
7 import {
8   AfterDestroy,
9   AllowNull,
10   BeforeDestroy,
11   BelongsTo,
12   BelongsToMany,
13   Column,
14   CreatedAt,
15   DataType,
16   Default,
17   ForeignKey,
18   HasMany,
19   IFindOptions,
20   Is,
21   IsInt,
22   IsUUID,
23   Min,
24   Model,
25   Scopes,
26   Table,
27   UpdatedAt
28 } from 'sequelize-typescript'
29 import { VideoPrivacy, VideoResolution } from '../../../shared'
30 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
31 import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
32 import { VideoFilter } from '../../../shared/models/videos/video-query.type'
33 import { activityPubCollection } from '../../helpers/activitypub'
34 import {
35   createTorrentPromise, peertubeTruncate, renamePromise, statPromise, unlinkPromise,
36   writeFilePromise
37 } from '../../helpers/core-utils'
38 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
39 import { isBooleanValid } from '../../helpers/custom-validators/misc'
40 import {
41   isVideoCategoryValid,
42   isVideoDescriptionValid,
43   isVideoDurationValid,
44   isVideoLanguageValid,
45   isVideoLicenceValid,
46   isVideoNameValid,
47   isVideoPrivacyValid,
48   isVideoSupportValid
49 } from '../../helpers/custom-validators/videos'
50 import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
51 import { logger } from '../../helpers/logger'
52 import { getServerActor } from '../../helpers/utils'
53 import {
54   API_VERSION,
55   CONFIG,
56   CONSTRAINTS_FIELDS,
57   PREVIEWS_SIZE,
58   REMOTE_SCHEME,
59   STATIC_PATHS,
60   THUMBNAILS_SIZE,
61   VIDEO_CATEGORIES,
62   VIDEO_LANGUAGES,
63   VIDEO_LICENCES,
64   VIDEO_PRIVACIES
65 } from '../../initializers'
66 import {
67   getVideoCommentsActivityPubUrl,
68   getVideoDislikesActivityPubUrl,
69   getVideoLikesActivityPubUrl,
70   getVideoSharesActivityPubUrl
71 } from '../../lib/activitypub'
72 import { sendDeleteVideo } from '../../lib/activitypub/send'
73 import { AccountModel } from '../account/account'
74 import { AccountVideoRateModel } from '../account/account-video-rate'
75 import { ActorModel } from '../activitypub/actor'
76 import { AvatarModel } from '../avatar/avatar'
77 import { ServerModel } from '../server/server'
78 import { getSort, throwIfNotValid } from '../utils'
79 import { TagModel } from './tag'
80 import { VideoAbuseModel } from './video-abuse'
81 import { VideoChannelModel } from './video-channel'
82 import { VideoCommentModel } from './video-comment'
83 import { VideoFileModel } from './video-file'
84 import { VideoShareModel } from './video-share'
85 import { VideoTagModel } from './video-tag'
86
87 enum ScopeNames {
88   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
89   WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
90   WITH_TAGS = 'WITH_TAGS',
91   WITH_FILES = 'WITH_FILES',
92   WITH_SHARES = 'WITH_SHARES',
93   WITH_RATES = 'WITH_RATES',
94   WITH_COMMENTS = 'WITH_COMMENTS'
95 }
96
97 @Scopes({
98   [ScopeNames.AVAILABLE_FOR_LIST]: (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: [ 'id', 'name' ],
108       model: AccountModel.unscoped(),
109       required: true,
110       where: {},
111       include: [
112         {
113           attributes: [ 'id', 'uuid', '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     hooks: true
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   @BeforeDestroy
514   static async removeFilesAndSendDelete (instance: VideoModel) {
515     const tasks: Promise<any>[] = []
516
517     logger.debug('Removing files of video %s.', instance.url)
518
519     tasks.push(instance.removeThumbnail())
520
521     if (instance.isOwned()) {
522       if (!Array.isArray(instance.VideoFiles)) {
523         instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
524       }
525
526       tasks.push(instance.removePreview())
527
528       // Remove physical files and torrents
529       instance.VideoFiles.forEach(file => {
530         tasks.push(instance.removeFile(file))
531         tasks.push(instance.removeTorrent(file))
532       })
533     }
534
535     // Do not wait video deletion because we could be in a transaction
536     Promise.all(tasks)
537       .catch(err => {
538         logger.error('Some errors when removing files of video %s in after destroy hook.', instance.uuid, { err })
539       })
540
541     return undefined
542   }
543
544   static list () {
545     return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
546   }
547
548   static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
549     function getRawQuery (select: string) {
550       const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
551         'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
552         'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
553         'WHERE "Account"."actorId" = ' + actorId
554       const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
555         'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
556         'WHERE "VideoShare"."actorId" = ' + actorId
557
558       return `(${queryVideo}) UNION (${queryVideoShare})`
559     }
560
561     const rawQuery = getRawQuery('"Video"."id"')
562     const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
563
564     const query = {
565       distinct: true,
566       offset: start,
567       limit: count,
568       order: getSort('createdAt', [ 'Tags', 'name', 'ASC' ]),
569       where: {
570         id: {
571           [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
572         },
573         [Sequelize.Op.or]: [
574           { privacy: VideoPrivacy.PUBLIC },
575           { privacy: VideoPrivacy.UNLISTED }
576         ]
577       },
578       include: [
579         {
580           attributes: [ 'id', 'url' ],
581           model: VideoShareModel.unscoped(),
582           required: false,
583           where: {
584             [Sequelize.Op.and]: [
585               {
586                 id: {
587                   [Sequelize.Op.not]: null
588                 }
589               },
590               {
591                 actorId
592               }
593             ]
594           },
595           include: [
596             {
597               attributes: [ 'id', 'url' ],
598               model: ActorModel.unscoped()
599             }
600           ]
601         },
602         {
603           model: VideoChannelModel.unscoped(),
604           required: true,
605           include: [
606             {
607               attributes: [ 'name' ],
608               model: AccountModel.unscoped(),
609               required: true,
610               include: [
611                 {
612                   attributes: [ 'id', 'url' ],
613                   model: ActorModel.unscoped(),
614                   required: true
615                 }
616               ]
617             },
618             {
619               attributes: [ 'id', 'url' ],
620               model: ActorModel.unscoped(),
621               required: true
622             }
623           ]
624         },
625         {
626           attributes: [ 'type' ],
627           model: AccountVideoRateModel,
628           required: false,
629           include: [
630             {
631               attributes: [ 'id' ],
632               model: AccountModel.unscoped(),
633               include: [
634                 {
635                   attributes: [ 'url' ],
636                   model: ActorModel.unscoped(),
637                   include: [
638                     {
639                       attributes: [ 'host' ],
640                       model: ServerModel,
641                       required: false
642                     }
643                   ]
644                 }
645               ]
646             }
647           ]
648         },
649         {
650           attributes: [ 'url' ],
651           model: VideoCommentModel,
652           required: false
653         },
654         VideoFileModel,
655         TagModel
656       ]
657     }
658
659     return Bluebird.all([
660       // FIXME: typing issue
661       VideoModel.findAll(query as any),
662       VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
663     ]).then(([ rows, totals ]) => {
664       // totals: totalVideos + totalVideoShares
665       let totalVideos = 0
666       let totalVideoShares = 0
667       if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
668       if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
669
670       const total = totalVideos + totalVideoShares
671       return {
672         data: rows,
673         total: total
674       }
675     })
676   }
677
678   static listAccountVideosForApi (accountId: number, start: number, count: number, sort: string, hideNSFW: boolean, withFiles = false) {
679     const query: IFindOptions<VideoModel> = {
680       offset: start,
681       limit: count,
682       order: getSort(sort),
683       include: [
684         {
685           model: VideoChannelModel,
686           required: true,
687           include: [
688             {
689               model: AccountModel,
690               where: {
691                 id: accountId
692               },
693               required: true
694             }
695           ]
696         }
697       ]
698     }
699
700     if (withFiles === true) {
701       query.include.push({
702         model: VideoFileModel.unscoped(),
703         required: true
704       })
705     }
706
707     if (hideNSFW === true) {
708       query.where = {
709         nsfw: false
710       }
711     }
712
713     return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
714       return {
715         data: rows,
716         total: count
717       }
718     })
719   }
720
721   static async listForApi (options: {
722     start: number,
723     count: number,
724     sort: string,
725     hideNSFW: boolean,
726     withFiles: boolean,
727     filter?: VideoFilter,
728     accountId?: number,
729     videoChannelId?: number
730   }) {
731     const query = {
732       offset: options.start,
733       limit: options.count,
734       order: getSort(options.sort)
735     }
736
737     const serverActor = await getServerActor()
738     const scopes = {
739       method: [
740         ScopeNames.AVAILABLE_FOR_LIST, {
741           actorId: serverActor.id,
742           hideNSFW: options.hideNSFW,
743           filter: options.filter,
744           withFiles: options.withFiles,
745           accountId: options.accountId,
746           videoChannelId: options.videoChannelId
747         }
748       ]
749     }
750
751     return VideoModel.scope(scopes)
752       .findAndCountAll(query)
753       .then(({ rows, count }) => {
754         return {
755           data: rows,
756           total: count
757         }
758       })
759   }
760
761   static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) {
762     const query: IFindOptions<VideoModel> = {
763       offset: start,
764       limit: count,
765       order: getSort(sort),
766       where: {
767         [Sequelize.Op.or]: [
768           {
769             name: {
770               [ Sequelize.Op.iLike ]: '%' + value + '%'
771             }
772           },
773           {
774             preferredUsername: Sequelize.where(Sequelize.col('preferredUsername'), {
775               [ Sequelize.Op.iLike ]: '%' + value + '%'
776             })
777           },
778           {
779             host: Sequelize.where(Sequelize.col('host'), {
780               [ Sequelize.Op.iLike ]: '%' + value + '%'
781             })
782           }
783         ]
784       }
785     }
786
787     const serverActor = await getServerActor()
788     const scopes = {
789       method: [
790         ScopeNames.AVAILABLE_FOR_LIST, {
791           actorId: serverActor.id,
792           hideNSFW
793         }
794       ]
795     }
796
797     return VideoModel.scope(scopes)
798       .findAndCountAll(query)
799       .then(({ rows, count }) => {
800         return {
801           data: rows,
802           total: count
803         }
804       })
805   }
806
807   static load (id: number) {
808     return VideoModel.findById(id)
809   }
810
811   static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
812     const query: IFindOptions<VideoModel> = {
813       where: {
814         url
815       }
816     }
817
818     if (t !== undefined) query.transaction = t
819
820     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
821   }
822
823   static loadByUUIDOrURLAndPopulateAccount (uuid: string, url: string, t?: Sequelize.Transaction) {
824     const query: IFindOptions<VideoModel> = {
825       where: {
826         [Sequelize.Op.or]: [
827           { uuid },
828           { url }
829         ]
830       }
831     }
832
833     if (t !== undefined) query.transaction = t
834
835     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
836   }
837
838   static loadAndPopulateAccountAndServerAndTags (id: number) {
839     const options = {
840       order: [ [ 'Tags', 'name', 'ASC' ] ]
841     }
842
843     return VideoModel
844       .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
845       .findById(id, options)
846   }
847
848   static loadByUUID (uuid: string) {
849     const options = {
850       where: {
851         uuid
852       }
853     }
854
855     return VideoModel
856       .scope([ ScopeNames.WITH_FILES ])
857       .findOne(options)
858   }
859
860   static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) {
861     const options = {
862       order: [ [ 'Tags', 'name', 'ASC' ] ],
863       where: {
864         uuid
865       }
866     }
867
868     return VideoModel
869       .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ])
870       .findOne(options)
871   }
872
873   static loadAndPopulateAll (id: number) {
874     const options = {
875       order: [ [ 'Tags', 'name', 'ASC' ] ],
876       where: {
877         id
878       }
879     }
880
881     return VideoModel
882       .scope([
883         ScopeNames.WITH_RATES,
884         ScopeNames.WITH_SHARES,
885         ScopeNames.WITH_TAGS,
886         ScopeNames.WITH_FILES,
887         ScopeNames.WITH_ACCOUNT_DETAILS,
888         ScopeNames.WITH_COMMENTS
889       ])
890       .findOne(options)
891   }
892
893   static async getStats () {
894     const totalLocalVideos = await VideoModel.count({
895       where: {
896         remote: false
897       }
898     })
899     const totalVideos = await VideoModel.count()
900
901     let totalLocalVideoViews = await VideoModel.sum('views', {
902       where: {
903         remote: false
904       }
905     })
906     // Sequelize could return null...
907     if (!totalLocalVideoViews) totalLocalVideoViews = 0
908
909     return {
910       totalLocalVideos,
911       totalLocalVideoViews,
912       totalVideos
913     }
914   }
915
916   private static buildActorWhereWithFilter (filter?: VideoFilter) {
917     if (filter && filter === 'local') {
918       return {
919         serverId: null
920       }
921     }
922
923     return {}
924   }
925
926   private static getCategoryLabel (id: number) {
927     let categoryLabel = VIDEO_CATEGORIES[id]
928     if (!categoryLabel) categoryLabel = 'Misc'
929
930     return categoryLabel
931   }
932
933   private static getLicenceLabel (id: number) {
934     let licenceLabel = VIDEO_LICENCES[id]
935     if (!licenceLabel) licenceLabel = 'Unknown'
936
937     return licenceLabel
938   }
939
940   private static getLanguageLabel (id: string) {
941     let languageLabel = VIDEO_LANGUAGES[id]
942     if (!languageLabel) languageLabel = 'Unknown'
943
944     return languageLabel
945   }
946
947   private static getPrivacyLabel (id: number) {
948     let privacyLabel = VIDEO_PRIVACIES[id]
949     if (!privacyLabel) privacyLabel = 'Unknown'
950
951     return privacyLabel
952   }
953
954   getOriginalFile () {
955     if (Array.isArray(this.VideoFiles) === false) return undefined
956
957     // The original file is the file that have the higher resolution
958     return maxBy(this.VideoFiles, file => file.resolution)
959   }
960
961   getVideoFilename (videoFile: VideoFileModel) {
962     return this.uuid + '-' + videoFile.resolution + videoFile.extname
963   }
964
965   getThumbnailName () {
966     // We always have a copy of the thumbnail
967     const extension = '.jpg'
968     return this.uuid + extension
969   }
970
971   getPreviewName () {
972     const extension = '.jpg'
973     return this.uuid + extension
974   }
975
976   getTorrentFileName (videoFile: VideoFileModel) {
977     const extension = '.torrent'
978     return this.uuid + '-' + videoFile.resolution + extension
979   }
980
981   isOwned () {
982     return this.remote === false
983   }
984
985   createPreview (videoFile: VideoFileModel) {
986     return generateImageFromVideoFile(
987       this.getVideoFilePath(videoFile),
988       CONFIG.STORAGE.PREVIEWS_DIR,
989       this.getPreviewName(),
990       PREVIEWS_SIZE
991     )
992   }
993
994   createThumbnail (videoFile: VideoFileModel) {
995     return generateImageFromVideoFile(
996       this.getVideoFilePath(videoFile),
997       CONFIG.STORAGE.THUMBNAILS_DIR,
998       this.getThumbnailName(),
999       THUMBNAILS_SIZE
1000     )
1001   }
1002
1003   getVideoFilePath (videoFile: VideoFileModel) {
1004     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1005   }
1006
1007   async createTorrentAndSetInfoHash (videoFile: VideoFileModel) {
1008     const options = {
1009       // Keep the extname, it's used by the client to stream the file inside a web browser
1010       name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
1011       createdBy: 'PeerTube',
1012       announceList: [
1013         [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ],
1014         [ CONFIG.WEBSERVER.URL + '/tracker/announce' ]
1015       ],
1016       urlList: [
1017         CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1018       ]
1019     }
1020
1021     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
1022
1023     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1024     logger.info('Creating torrent %s.', filePath)
1025
1026     await writeFilePromise(filePath, torrent)
1027
1028     const parsedTorrent = parseTorrent(torrent)
1029     videoFile.infoHash = parsedTorrent.infoHash
1030   }
1031
1032   getEmbedPath () {
1033     return '/videos/embed/' + this.uuid
1034   }
1035
1036   getThumbnailPath () {
1037     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
1038   }
1039
1040   getPreviewPath () {
1041     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1042   }
1043
1044   toFormattedJSON (): Video {
1045     const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
1046
1047     return {
1048       id: this.id,
1049       uuid: this.uuid,
1050       name: this.name,
1051       category: {
1052         id: this.category,
1053         label: VideoModel.getCategoryLabel(this.category)
1054       },
1055       licence: {
1056         id: this.licence,
1057         label: VideoModel.getLicenceLabel(this.licence)
1058       },
1059       language: {
1060         id: this.language,
1061         label: VideoModel.getLanguageLabel(this.language)
1062       },
1063       privacy: {
1064         id: this.privacy,
1065         label: VideoModel.getPrivacyLabel(this.privacy)
1066       },
1067       nsfw: this.nsfw,
1068       description: this.getTruncatedDescription(),
1069       isLocal: this.isOwned(),
1070       duration: this.duration,
1071       views: this.views,
1072       likes: this.likes,
1073       dislikes: this.dislikes,
1074       thumbnailPath: this.getThumbnailPath(),
1075       previewPath: this.getPreviewPath(),
1076       embedPath: this.getEmbedPath(),
1077       createdAt: this.createdAt,
1078       updatedAt: this.updatedAt,
1079       publishedAt: this.publishedAt,
1080       account: {
1081         id: formattedAccount.id,
1082         uuid: formattedAccount.uuid,
1083         name: formattedAccount.name,
1084         displayName: formattedAccount.displayName,
1085         url: formattedAccount.url,
1086         host: formattedAccount.host,
1087         avatar: formattedAccount.avatar
1088       }
1089     }
1090   }
1091
1092   toFormattedDetailsJSON (): VideoDetails {
1093     const formattedJson = this.toFormattedJSON()
1094
1095     const detailsJson = {
1096       support: this.support,
1097       descriptionPath: this.getDescriptionPath(),
1098       channel: this.VideoChannel.toFormattedJSON(),
1099       account: this.VideoChannel.Account.toFormattedJSON(),
1100       tags: map(this.Tags, 'name'),
1101       commentsEnabled: this.commentsEnabled,
1102       files: []
1103     }
1104
1105     // Format and sort video files
1106     detailsJson.files = this.getFormattedVideoFilesJSON()
1107
1108     return Object.assign(formattedJson, detailsJson)
1109   }
1110
1111   getFormattedVideoFilesJSON (): VideoFile[] {
1112     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1113
1114     return this.VideoFiles
1115         .map(videoFile => {
1116           let resolutionLabel = videoFile.resolution + 'p'
1117
1118           return {
1119             resolution: {
1120               id: videoFile.resolution,
1121               label: resolutionLabel
1122             },
1123             magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1124             size: videoFile.size,
1125             torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1126             fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
1127           } as VideoFile
1128         })
1129         .sort((a, b) => {
1130           if (a.resolution.id < b.resolution.id) return 1
1131           if (a.resolution.id === b.resolution.id) return 0
1132           return -1
1133         })
1134   }
1135
1136   toActivityPubObject (): VideoTorrentObject {
1137     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1138     if (!this.Tags) this.Tags = []
1139
1140     const tag = this.Tags.map(t => ({
1141       type: 'Hashtag' as 'Hashtag',
1142       name: t.name
1143     }))
1144
1145     let language
1146     if (this.language) {
1147       language = {
1148         identifier: this.language,
1149         name: VideoModel.getLanguageLabel(this.language)
1150       }
1151     }
1152
1153     let category
1154     if (this.category) {
1155       category = {
1156         identifier: this.category + '',
1157         name: VideoModel.getCategoryLabel(this.category)
1158       }
1159     }
1160
1161     let licence
1162     if (this.licence) {
1163       licence = {
1164         identifier: this.licence + '',
1165         name: VideoModel.getLicenceLabel(this.licence)
1166       }
1167     }
1168
1169     let likesObject
1170     let dislikesObject
1171
1172     if (Array.isArray(this.AccountVideoRates)) {
1173       const res = this.toRatesActivityPubObjects()
1174       likesObject = res.likesObject
1175       dislikesObject = res.dislikesObject
1176     }
1177
1178     let sharesObject
1179     if (Array.isArray(this.VideoShares)) {
1180       sharesObject = this.toAnnouncesActivityPubObject()
1181     }
1182
1183     let commentsObject
1184     if (Array.isArray(this.VideoComments)) {
1185       commentsObject = this.toCommentsActivityPubObject()
1186     }
1187
1188     const url = []
1189     for (const file of this.VideoFiles) {
1190       url.push({
1191         type: 'Link',
1192         mimeType: 'video/' + file.extname.replace('.', ''),
1193         href: this.getVideoFileUrl(file, baseUrlHttp),
1194         width: file.resolution,
1195         size: file.size
1196       })
1197
1198       url.push({
1199         type: 'Link',
1200         mimeType: 'application/x-bittorrent',
1201         href: this.getTorrentUrl(file, baseUrlHttp),
1202         width: file.resolution
1203       })
1204
1205       url.push({
1206         type: 'Link',
1207         mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
1208         href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1209         width: file.resolution
1210       })
1211     }
1212
1213     // Add video url too
1214     url.push({
1215       type: 'Link',
1216       mimeType: 'text/html',
1217       href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1218     })
1219
1220     return {
1221       type: 'Video' as 'Video',
1222       id: this.url,
1223       name: this.name,
1224       duration: this.getActivityStreamDuration(),
1225       uuid: this.uuid,
1226       tag,
1227       category,
1228       licence,
1229       language,
1230       views: this.views,
1231       sensitive: this.nsfw,
1232       commentsEnabled: this.commentsEnabled,
1233       published: this.publishedAt.toISOString(),
1234       updated: this.updatedAt.toISOString(),
1235       mediaType: 'text/markdown',
1236       content: this.getTruncatedDescription(),
1237       support: this.support,
1238       icon: {
1239         type: 'Image',
1240         url: this.getThumbnailUrl(baseUrlHttp),
1241         mediaType: 'image/jpeg',
1242         width: THUMBNAILS_SIZE.width,
1243         height: THUMBNAILS_SIZE.height
1244       },
1245       url,
1246       likes: likesObject,
1247       dislikes: dislikesObject,
1248       shares: sharesObject,
1249       comments: commentsObject,
1250       attributedTo: [
1251         {
1252           type: 'Person',
1253           id: this.VideoChannel.Account.Actor.url
1254         },
1255         {
1256           type: 'Group',
1257           id: this.VideoChannel.Actor.url
1258         }
1259       ]
1260     }
1261   }
1262
1263   toAnnouncesActivityPubObject () {
1264     const shares: string[] = []
1265
1266     for (const videoShare of this.VideoShares) {
1267       shares.push(videoShare.url)
1268     }
1269
1270     return activityPubCollection(getVideoSharesActivityPubUrl(this), shares)
1271   }
1272
1273   toCommentsActivityPubObject () {
1274     const comments: string[] = []
1275
1276     for (const videoComment of this.VideoComments) {
1277       comments.push(videoComment.url)
1278     }
1279
1280     return activityPubCollection(getVideoCommentsActivityPubUrl(this), comments)
1281   }
1282
1283   toRatesActivityPubObjects () {
1284     const likes: string[] = []
1285     const dislikes: string[] = []
1286
1287     for (const rate of this.AccountVideoRates) {
1288       if (rate.type === 'like') {
1289         likes.push(rate.Account.Actor.url)
1290       } else if (rate.type === 'dislike') {
1291         dislikes.push(rate.Account.Actor.url)
1292       }
1293     }
1294
1295     const likesObject = activityPubCollection(getVideoLikesActivityPubUrl(this), likes)
1296     const dislikesObject = activityPubCollection(getVideoDislikesActivityPubUrl(this), dislikes)
1297
1298     return { likesObject, dislikesObject }
1299   }
1300
1301   getTruncatedDescription () {
1302     if (!this.description) return null
1303
1304     const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1305     return peertubeTruncate(this.description, maxLength)
1306   }
1307
1308   async optimizeOriginalVideofile () {
1309     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1310     const newExtname = '.mp4'
1311     const inputVideoFile = this.getOriginalFile()
1312     const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1313     const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1314
1315     const transcodeOptions = {
1316       inputPath: videoInputPath,
1317       outputPath: videoOutputPath
1318     }
1319
1320     // Could be very long!
1321     await transcode(transcodeOptions)
1322
1323     try {
1324       await unlinkPromise(videoInputPath)
1325
1326       // Important to do this before getVideoFilename() to take in account the new file extension
1327       inputVideoFile.set('extname', newExtname)
1328
1329       await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
1330       const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
1331
1332       inputVideoFile.set('size', stats.size)
1333
1334       await this.createTorrentAndSetInfoHash(inputVideoFile)
1335       await inputVideoFile.save()
1336
1337     } catch (err) {
1338       // Auto destruction...
1339       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
1340
1341       throw err
1342     }
1343   }
1344
1345   async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
1346     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1347     const extname = '.mp4'
1348
1349     // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1350     const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1351
1352     const newVideoFile = new VideoFileModel({
1353       resolution,
1354       extname,
1355       size: 0,
1356       videoId: this.id
1357     })
1358     const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1359
1360     const transcodeOptions = {
1361       inputPath: videoInputPath,
1362       outputPath: videoOutputPath,
1363       resolution,
1364       isPortraitMode
1365     }
1366
1367     await transcode(transcodeOptions)
1368
1369     const stats = await statPromise(videoOutputPath)
1370
1371     newVideoFile.set('size', stats.size)
1372
1373     await this.createTorrentAndSetInfoHash(newVideoFile)
1374
1375     await newVideoFile.save()
1376
1377     this.VideoFiles.push(newVideoFile)
1378   }
1379
1380   getOriginalFileResolution () {
1381     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1382
1383     return getVideoFileResolution(originalFilePath)
1384   }
1385
1386   getDescriptionPath () {
1387     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1388   }
1389
1390   removeThumbnail () {
1391     const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1392     return unlinkPromise(thumbnailPath)
1393   }
1394
1395   removePreview () {
1396     // Same name than video thumbnail
1397     return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1398   }
1399
1400   removeFile (videoFile: VideoFileModel) {
1401     const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1402     return unlinkPromise(filePath)
1403   }
1404
1405   removeTorrent (videoFile: VideoFileModel) {
1406     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1407     return unlinkPromise(torrentPath)
1408   }
1409
1410   getActivityStreamDuration () {
1411     // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1412     return 'PT' + this.duration + 'S'
1413   }
1414
1415   private getBaseUrls () {
1416     let baseUrlHttp
1417     let baseUrlWs
1418
1419     if (this.isOwned()) {
1420       baseUrlHttp = CONFIG.WEBSERVER.URL
1421       baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1422     } else {
1423       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1424       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1425     }
1426
1427     return { baseUrlHttp, baseUrlWs }
1428   }
1429
1430   private getThumbnailUrl (baseUrlHttp: string) {
1431     return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1432   }
1433
1434   private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1435     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1436   }
1437
1438   private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1439     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1440   }
1441
1442   private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1443     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1444     const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1445     const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1446
1447     const magnetHash = {
1448       xs,
1449       announce,
1450       urlList,
1451       infoHash: videoFile.infoHash,
1452       name: this.name
1453     }
1454
1455     return magnetUtil.encode(magnetHash)
1456   }
1457 }