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