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