Video channel API routes refractor
[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     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         name: formattedAccount.name,
1082         displayName: formattedAccount.displayName,
1083         url: formattedAccount.url,
1084         host: formattedAccount.host,
1085         avatar: formattedAccount.avatar
1086       }
1087     }
1088   }
1089
1090   toFormattedDetailsJSON (): VideoDetails {
1091     const formattedJson = this.toFormattedJSON()
1092
1093     const detailsJson = {
1094       support: this.support,
1095       descriptionPath: this.getDescriptionPath(),
1096       channel: this.VideoChannel.toFormattedJSON(),
1097       account: this.VideoChannel.Account.toFormattedJSON(),
1098       tags: map(this.Tags, 'name'),
1099       commentsEnabled: this.commentsEnabled,
1100       files: []
1101     }
1102
1103     // Format and sort video files
1104     detailsJson.files = this.getFormattedVideoFilesJSON()
1105
1106     return Object.assign(formattedJson, detailsJson)
1107   }
1108
1109   getFormattedVideoFilesJSON (): VideoFile[] {
1110     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1111
1112     return this.VideoFiles
1113         .map(videoFile => {
1114           let resolutionLabel = videoFile.resolution + 'p'
1115
1116           return {
1117             resolution: {
1118               id: videoFile.resolution,
1119               label: resolutionLabel
1120             },
1121             magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1122             size: videoFile.size,
1123             torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1124             fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
1125           } as VideoFile
1126         })
1127         .sort((a, b) => {
1128           if (a.resolution.id < b.resolution.id) return 1
1129           if (a.resolution.id === b.resolution.id) return 0
1130           return -1
1131         })
1132   }
1133
1134   toActivityPubObject (): VideoTorrentObject {
1135     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1136     if (!this.Tags) this.Tags = []
1137
1138     const tag = this.Tags.map(t => ({
1139       type: 'Hashtag' as 'Hashtag',
1140       name: t.name
1141     }))
1142
1143     let language
1144     if (this.language) {
1145       language = {
1146         identifier: this.language,
1147         name: VideoModel.getLanguageLabel(this.language)
1148       }
1149     }
1150
1151     let category
1152     if (this.category) {
1153       category = {
1154         identifier: this.category + '',
1155         name: VideoModel.getCategoryLabel(this.category)
1156       }
1157     }
1158
1159     let licence
1160     if (this.licence) {
1161       licence = {
1162         identifier: this.licence + '',
1163         name: VideoModel.getLicenceLabel(this.licence)
1164       }
1165     }
1166
1167     let likesObject
1168     let dislikesObject
1169
1170     if (Array.isArray(this.AccountVideoRates)) {
1171       const res = this.toRatesActivityPubObjects()
1172       likesObject = res.likesObject
1173       dislikesObject = res.dislikesObject
1174     }
1175
1176     let sharesObject
1177     if (Array.isArray(this.VideoShares)) {
1178       sharesObject = this.toAnnouncesActivityPubObject()
1179     }
1180
1181     let commentsObject
1182     if (Array.isArray(this.VideoComments)) {
1183       commentsObject = this.toCommentsActivityPubObject()
1184     }
1185
1186     const url = []
1187     for (const file of this.VideoFiles) {
1188       url.push({
1189         type: 'Link',
1190         mimeType: 'video/' + file.extname.replace('.', ''),
1191         href: this.getVideoFileUrl(file, baseUrlHttp),
1192         width: file.resolution,
1193         size: file.size
1194       })
1195
1196       url.push({
1197         type: 'Link',
1198         mimeType: 'application/x-bittorrent',
1199         href: this.getTorrentUrl(file, baseUrlHttp),
1200         width: file.resolution
1201       })
1202
1203       url.push({
1204         type: 'Link',
1205         mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
1206         href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1207         width: file.resolution
1208       })
1209     }
1210
1211     // Add video url too
1212     url.push({
1213       type: 'Link',
1214       mimeType: 'text/html',
1215       href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1216     })
1217
1218     return {
1219       type: 'Video' as 'Video',
1220       id: this.url,
1221       name: this.name,
1222       duration: this.getActivityStreamDuration(),
1223       uuid: this.uuid,
1224       tag,
1225       category,
1226       licence,
1227       language,
1228       views: this.views,
1229       sensitive: this.nsfw,
1230       commentsEnabled: this.commentsEnabled,
1231       published: this.publishedAt.toISOString(),
1232       updated: this.updatedAt.toISOString(),
1233       mediaType: 'text/markdown',
1234       content: this.getTruncatedDescription(),
1235       support: this.support,
1236       icon: {
1237         type: 'Image',
1238         url: this.getThumbnailUrl(baseUrlHttp),
1239         mediaType: 'image/jpeg',
1240         width: THUMBNAILS_SIZE.width,
1241         height: THUMBNAILS_SIZE.height
1242       },
1243       url,
1244       likes: likesObject,
1245       dislikes: dislikesObject,
1246       shares: sharesObject,
1247       comments: commentsObject,
1248       attributedTo: [
1249         {
1250           type: 'Person',
1251           id: this.VideoChannel.Account.Actor.url
1252         },
1253         {
1254           type: 'Group',
1255           id: this.VideoChannel.Actor.url
1256         }
1257       ]
1258     }
1259   }
1260
1261   toAnnouncesActivityPubObject () {
1262     const shares: string[] = []
1263
1264     for (const videoShare of this.VideoShares) {
1265       shares.push(videoShare.url)
1266     }
1267
1268     return activityPubCollection(getVideoSharesActivityPubUrl(this), shares)
1269   }
1270
1271   toCommentsActivityPubObject () {
1272     const comments: string[] = []
1273
1274     for (const videoComment of this.VideoComments) {
1275       comments.push(videoComment.url)
1276     }
1277
1278     return activityPubCollection(getVideoCommentsActivityPubUrl(this), comments)
1279   }
1280
1281   toRatesActivityPubObjects () {
1282     const likes: string[] = []
1283     const dislikes: string[] = []
1284
1285     for (const rate of this.AccountVideoRates) {
1286       if (rate.type === 'like') {
1287         likes.push(rate.Account.Actor.url)
1288       } else if (rate.type === 'dislike') {
1289         dislikes.push(rate.Account.Actor.url)
1290       }
1291     }
1292
1293     const likesObject = activityPubCollection(getVideoLikesActivityPubUrl(this), likes)
1294     const dislikesObject = activityPubCollection(getVideoDislikesActivityPubUrl(this), dislikes)
1295
1296     return { likesObject, dislikesObject }
1297   }
1298
1299   getTruncatedDescription () {
1300     if (!this.description) return null
1301
1302     const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1303     return peertubeTruncate(this.description, maxLength)
1304   }
1305
1306   async optimizeOriginalVideofile () {
1307     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1308     const newExtname = '.mp4'
1309     const inputVideoFile = this.getOriginalFile()
1310     const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1311     const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1312
1313     const transcodeOptions = {
1314       inputPath: videoInputPath,
1315       outputPath: videoOutputPath
1316     }
1317
1318     // Could be very long!
1319     await transcode(transcodeOptions)
1320
1321     try {
1322       await unlinkPromise(videoInputPath)
1323
1324       // Important to do this before getVideoFilename() to take in account the new file extension
1325       inputVideoFile.set('extname', newExtname)
1326
1327       await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
1328       const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
1329
1330       inputVideoFile.set('size', stats.size)
1331
1332       await this.createTorrentAndSetInfoHash(inputVideoFile)
1333       await inputVideoFile.save()
1334
1335     } catch (err) {
1336       // Auto destruction...
1337       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
1338
1339       throw err
1340     }
1341   }
1342
1343   async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
1344     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1345     const extname = '.mp4'
1346
1347     // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1348     const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1349
1350     const newVideoFile = new VideoFileModel({
1351       resolution,
1352       extname,
1353       size: 0,
1354       videoId: this.id
1355     })
1356     const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1357
1358     const transcodeOptions = {
1359       inputPath: videoInputPath,
1360       outputPath: videoOutputPath,
1361       resolution,
1362       isPortraitMode
1363     }
1364
1365     await transcode(transcodeOptions)
1366
1367     const stats = await statPromise(videoOutputPath)
1368
1369     newVideoFile.set('size', stats.size)
1370
1371     await this.createTorrentAndSetInfoHash(newVideoFile)
1372
1373     await newVideoFile.save()
1374
1375     this.VideoFiles.push(newVideoFile)
1376   }
1377
1378   getOriginalFileResolution () {
1379     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1380
1381     return getVideoFileResolution(originalFilePath)
1382   }
1383
1384   getDescriptionPath () {
1385     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1386   }
1387
1388   removeThumbnail () {
1389     const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1390     return unlinkPromise(thumbnailPath)
1391   }
1392
1393   removePreview () {
1394     // Same name than video thumbnail
1395     return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1396   }
1397
1398   removeFile (videoFile: VideoFileModel) {
1399     const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1400     return unlinkPromise(filePath)
1401   }
1402
1403   removeTorrent (videoFile: VideoFileModel) {
1404     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1405     return unlinkPromise(torrentPath)
1406   }
1407
1408   getActivityStreamDuration () {
1409     // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1410     return 'PT' + this.duration + 'S'
1411   }
1412
1413   private getBaseUrls () {
1414     let baseUrlHttp
1415     let baseUrlWs
1416
1417     if (this.isOwned()) {
1418       baseUrlHttp = CONFIG.WEBSERVER.URL
1419       baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1420     } else {
1421       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1422       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1423     }
1424
1425     return { baseUrlHttp, baseUrlWs }
1426   }
1427
1428   private getThumbnailUrl (baseUrlHttp: string) {
1429     return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1430   }
1431
1432   private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1433     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1434   }
1435
1436   private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1437     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1438   }
1439
1440   private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1441     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1442     const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1443     const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1444
1445     const magnetHash = {
1446       xs,
1447       announce,
1448       urlList,
1449       infoHash: videoFile.infoHash,
1450       name: this.name
1451     }
1452
1453     return magnetUtil.encode(magnetHash)
1454   }
1455 }