Correctly migrate to fs-extra
[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 { extname, join } from 'path'
6 import * as Sequelize from 'sequelize'
7 import {
8   AllowNull,
9   BeforeDestroy,
10   BelongsTo,
11   BelongsToMany,
12   Column,
13   CreatedAt,
14   DataType,
15   Default,
16   ForeignKey,
17   HasMany,
18   HasOne,
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, VideoState } 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 { createTorrentPromise, peertubeTruncate } from '../../helpers/core-utils'
34 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
35 import { isBooleanValid } from '../../helpers/custom-validators/misc'
36 import {
37   isVideoCategoryValid,
38   isVideoDescriptionValid,
39   isVideoDurationValid,
40   isVideoLanguageValid,
41   isVideoLicenceValid,
42   isVideoNameValid,
43   isVideoPrivacyValid,
44   isVideoStateValid,
45   isVideoSupportValid
46 } from '../../helpers/custom-validators/videos'
47 import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
48 import { logger } from '../../helpers/logger'
49 import { getServerActor } from '../../helpers/utils'
50 import {
51   ACTIVITY_PUB,
52   API_VERSION,
53   CONFIG,
54   CONSTRAINTS_FIELDS,
55   PREVIEWS_SIZE,
56   REMOTE_SCHEME,
57   STATIC_DOWNLOAD_PATHS,
58   STATIC_PATHS,
59   THUMBNAILS_SIZE,
60   VIDEO_CATEGORIES,
61   VIDEO_EXT_MIMETYPE,
62   VIDEO_LANGUAGES,
63   VIDEO_LICENCES,
64   VIDEO_PRIVACIES,
65   VIDEO_STATES
66 } from '../../initializers'
67 import {
68   getVideoCommentsActivityPubUrl,
69   getVideoDislikesActivityPubUrl,
70   getVideoLikesActivityPubUrl,
71   getVideoSharesActivityPubUrl
72 } from '../../lib/activitypub'
73 import { sendDeleteVideo } from '../../lib/activitypub/send'
74 import { AccountModel } from '../account/account'
75 import { AccountVideoRateModel } from '../account/account-video-rate'
76 import { ActorModel } from '../activitypub/actor'
77 import { AvatarModel } from '../avatar/avatar'
78 import { ServerModel } from '../server/server'
79 import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
80 import { TagModel } from './tag'
81 import { VideoAbuseModel } from './video-abuse'
82 import { VideoChannelModel } from './video-channel'
83 import { VideoCommentModel } from './video-comment'
84 import { VideoFileModel } from './video-file'
85 import { VideoShareModel } from './video-share'
86 import { VideoTagModel } from './video-tag'
87 import { ScheduleVideoUpdateModel } from './schedule-video-update'
88 import { VideoCaptionModel } from './video-caption'
89 import { VideoBlacklistModel } from './video-blacklist'
90 import { copy, remove, rename, stat, writeFile } from 'fs-extra'
91
92 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
93 const indexes: Sequelize.DefineIndexesOptions[] = [
94   buildTrigramSearchIndex('video_name_trigram', 'name'),
95
96   { fields: [ 'createdAt' ] },
97   { fields: [ 'publishedAt' ] },
98   { fields: [ 'duration' ] },
99   { fields: [ 'category' ] },
100   { fields: [ 'licence' ] },
101   { fields: [ 'nsfw' ] },
102   { fields: [ 'language' ] },
103   { fields: [ 'waitTranscoding' ] },
104   { fields: [ 'state' ] },
105   { fields: [ 'remote' ] },
106   { fields: [ 'views' ] },
107   { fields: [ 'likes' ] },
108   { fields: [ 'channelId' ] },
109   {
110     fields: [ 'uuid' ],
111     unique: true
112   },
113   {
114     fields: [ 'url'],
115     unique: true
116   }
117 ]
118
119 export enum ScopeNames {
120   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
121   WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
122   WITH_TAGS = 'WITH_TAGS',
123   WITH_FILES = 'WITH_FILES',
124   WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
125   WITH_BLACKLISTED = 'WITH_BLACKLISTED'
126 }
127
128 type AvailableForListOptions = {
129   actorId: number,
130   includeLocalVideos: boolean,
131   filter?: VideoFilter,
132   categoryOneOf?: number[],
133   nsfw?: boolean,
134   licenceOneOf?: number[],
135   languageOneOf?: string[],
136   tagsOneOf?: string[],
137   tagsAllOf?: string[],
138   withFiles?: boolean,
139   accountId?: number,
140   videoChannelId?: number
141 }
142
143 @Scopes({
144   [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
145     const accountInclude = {
146       attributes: [ 'id', 'name' ],
147       model: AccountModel.unscoped(),
148       required: true,
149       where: {},
150       include: [
151         {
152           attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
153           model: ActorModel.unscoped(),
154           required: true,
155           where: VideoModel.buildActorWhereWithFilter(options.filter),
156           include: [
157             {
158               attributes: [ 'host' ],
159               model: ServerModel.unscoped(),
160               required: false
161             },
162             {
163               model: AvatarModel.unscoped(),
164               required: false
165             }
166           ]
167         }
168       ]
169     }
170
171     const videoChannelInclude = {
172       attributes: [ 'name', 'description', 'id' ],
173       model: VideoChannelModel.unscoped(),
174       required: true,
175       where: {},
176       include: [
177         {
178           attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
179           model: ActorModel.unscoped(),
180           required: true,
181           include: [
182             {
183               attributes: [ 'host' ],
184               model: ServerModel.unscoped(),
185               required: false
186             },
187             {
188               model: AvatarModel.unscoped(),
189               required: false
190             }
191           ]
192         },
193         accountInclude
194       ]
195     }
196
197     // FIXME: It would be more efficient to use a CTE so we join AFTER the filters, but sequelize does not support it...
198     const query: IFindOptions<VideoModel> = {
199       where: {
200         id: {
201           [Sequelize.Op.notIn]: Sequelize.literal(
202             '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
203           )
204         },
205         // Always list public videos
206         privacy: VideoPrivacy.PUBLIC,
207         // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
208         [ Sequelize.Op.or ]: [
209           {
210             state: VideoState.PUBLISHED
211           },
212           {
213             [ Sequelize.Op.and ]: {
214               state: VideoState.TO_TRANSCODE,
215               waitTranscoding: false
216             }
217           }
218         ]
219       },
220       include: [ videoChannelInclude ]
221     }
222
223     if (options.actorId) {
224       let localVideosReq = ''
225       if (options.includeLocalVideos === true) {
226         localVideosReq = ' UNION ALL ' +
227           'SELECT "video"."id" AS "id" FROM "video" ' +
228           'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
229           'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
230           'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
231           'WHERE "actor"."serverId" IS NULL'
232       }
233
234       // Force actorId to be a number to avoid SQL injections
235       const actorIdNumber = parseInt(options.actorId.toString(), 10)
236       query.where['id'][ Sequelize.Op.in ] = Sequelize.literal(
237         '(' +
238         'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
239         'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
240         'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
241         ' UNION ALL ' +
242         'SELECT "video"."id" AS "id" FROM "video" ' +
243         'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
244         'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
245         'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
246         'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
247         'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
248         localVideosReq +
249         ')'
250       )
251     }
252
253     if (options.withFiles === true) {
254       query.include.push({
255         model: VideoFileModel.unscoped(),
256         required: true
257       })
258     }
259
260     // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN()
261     if (options.tagsAllOf || options.tagsOneOf) {
262       const createTagsIn = (tags: string[]) => {
263         return tags.map(t => VideoModel.sequelize.escape(t))
264                    .join(', ')
265       }
266
267       if (options.tagsOneOf) {
268         query.where['id'][Sequelize.Op.in] = Sequelize.literal(
269           '(' +
270             'SELECT "videoId" FROM "videoTag" ' +
271             'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
272             'WHERE "tag"."name" IN (' + createTagsIn(options.tagsOneOf) + ')' +
273           ')'
274         )
275       }
276
277       if (options.tagsAllOf) {
278         query.where['id'][Sequelize.Op.in] = Sequelize.literal(
279             '(' +
280               'SELECT "videoId" FROM "videoTag" ' +
281               'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
282               'WHERE "tag"."name" IN (' + createTagsIn(options.tagsAllOf) + ')' +
283               'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
284             ')'
285         )
286       }
287     }
288
289     if (options.nsfw === true || options.nsfw === false) {
290       query.where['nsfw'] = options.nsfw
291     }
292
293     if (options.categoryOneOf) {
294       query.where['category'] = {
295         [Sequelize.Op.or]: options.categoryOneOf
296       }
297     }
298
299     if (options.licenceOneOf) {
300       query.where['licence'] = {
301         [Sequelize.Op.or]: options.licenceOneOf
302       }
303     }
304
305     if (options.languageOneOf) {
306       query.where['language'] = {
307         [Sequelize.Op.or]: options.languageOneOf
308       }
309     }
310
311     if (options.accountId) {
312       accountInclude.where = {
313         id: options.accountId
314       }
315     }
316
317     if (options.videoChannelId) {
318       videoChannelInclude.where = {
319         id: options.videoChannelId
320       }
321     }
322
323     return query
324   },
325   [ScopeNames.WITH_ACCOUNT_DETAILS]: {
326     include: [
327       {
328         model: () => VideoChannelModel.unscoped(),
329         required: true,
330         include: [
331           {
332             attributes: {
333               exclude: [ 'privateKey', 'publicKey' ]
334             },
335             model: () => ActorModel.unscoped(),
336             required: true,
337             include: [
338               {
339                 attributes: [ 'host' ],
340                 model: () => ServerModel.unscoped(),
341                 required: false
342               },
343               {
344                 model: () => AvatarModel.unscoped(),
345                 required: false
346               }
347             ]
348           },
349           {
350             model: () => AccountModel.unscoped(),
351             required: true,
352             include: [
353               {
354                 model: () => ActorModel.unscoped(),
355                 attributes: {
356                   exclude: [ 'privateKey', 'publicKey' ]
357                 },
358                 required: true,
359                 include: [
360                   {
361                     attributes: [ 'host' ],
362                     model: () => ServerModel.unscoped(),
363                     required: false
364                   },
365                   {
366                     model: () => AvatarModel.unscoped(),
367                     required: false
368                   }
369                 ]
370               }
371             ]
372           }
373         ]
374       }
375     ]
376   },
377   [ScopeNames.WITH_TAGS]: {
378     include: [ () => TagModel ]
379   },
380   [ScopeNames.WITH_BLACKLISTED]: {
381     include: [
382       {
383         attributes: [ 'id', 'reason' ],
384         model: () => VideoBlacklistModel,
385         required: false
386       }
387     ]
388   },
389   [ScopeNames.WITH_FILES]: {
390     include: [
391       {
392         model: () => VideoFileModel.unscoped(),
393         required: false
394       }
395     ]
396   },
397   [ScopeNames.WITH_SCHEDULED_UPDATE]: {
398     include: [
399       {
400         model: () => ScheduleVideoUpdateModel.unscoped(),
401         required: false
402       }
403     ]
404   }
405 })
406 @Table({
407   tableName: 'video',
408   indexes
409 })
410 export class VideoModel extends Model<VideoModel> {
411
412   @AllowNull(false)
413   @Default(DataType.UUIDV4)
414   @IsUUID(4)
415   @Column(DataType.UUID)
416   uuid: string
417
418   @AllowNull(false)
419   @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
420   @Column
421   name: string
422
423   @AllowNull(true)
424   @Default(null)
425   @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category'))
426   @Column
427   category: number
428
429   @AllowNull(true)
430   @Default(null)
431   @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence'))
432   @Column
433   licence: number
434
435   @AllowNull(true)
436   @Default(null)
437   @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language'))
438   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
439   language: string
440
441   @AllowNull(false)
442   @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
443   @Column
444   privacy: number
445
446   @AllowNull(false)
447   @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
448   @Column
449   nsfw: boolean
450
451   @AllowNull(true)
452   @Default(null)
453   @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description'))
454   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
455   description: string
456
457   @AllowNull(true)
458   @Default(null)
459   @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support'))
460   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
461   support: string
462
463   @AllowNull(false)
464   @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
465   @Column
466   duration: number
467
468   @AllowNull(false)
469   @Default(0)
470   @IsInt
471   @Min(0)
472   @Column
473   views: number
474
475   @AllowNull(false)
476   @Default(0)
477   @IsInt
478   @Min(0)
479   @Column
480   likes: number
481
482   @AllowNull(false)
483   @Default(0)
484   @IsInt
485   @Min(0)
486   @Column
487   dislikes: number
488
489   @AllowNull(false)
490   @Column
491   remote: boolean
492
493   @AllowNull(false)
494   @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
495   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
496   url: string
497
498   @AllowNull(false)
499   @Column
500   commentsEnabled: boolean
501
502   @AllowNull(false)
503   @Column
504   waitTranscoding: boolean
505
506   @AllowNull(false)
507   @Default(null)
508   @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
509   @Column
510   state: VideoState
511
512   @CreatedAt
513   createdAt: Date
514
515   @UpdatedAt
516   updatedAt: Date
517
518   @AllowNull(false)
519   @Default(Sequelize.NOW)
520   @Column
521   publishedAt: Date
522
523   @ForeignKey(() => VideoChannelModel)
524   @Column
525   channelId: number
526
527   @BelongsTo(() => VideoChannelModel, {
528     foreignKey: {
529       allowNull: true
530     },
531     hooks: true
532   })
533   VideoChannel: VideoChannelModel
534
535   @BelongsToMany(() => TagModel, {
536     foreignKey: 'videoId',
537     through: () => VideoTagModel,
538     onDelete: 'CASCADE'
539   })
540   Tags: TagModel[]
541
542   @HasMany(() => VideoAbuseModel, {
543     foreignKey: {
544       name: 'videoId',
545       allowNull: false
546     },
547     onDelete: 'cascade'
548   })
549   VideoAbuses: VideoAbuseModel[]
550
551   @HasMany(() => VideoFileModel, {
552     foreignKey: {
553       name: 'videoId',
554       allowNull: false
555     },
556     onDelete: 'cascade'
557   })
558   VideoFiles: VideoFileModel[]
559
560   @HasMany(() => VideoShareModel, {
561     foreignKey: {
562       name: 'videoId',
563       allowNull: false
564     },
565     onDelete: 'cascade'
566   })
567   VideoShares: VideoShareModel[]
568
569   @HasMany(() => AccountVideoRateModel, {
570     foreignKey: {
571       name: 'videoId',
572       allowNull: false
573     },
574     onDelete: 'cascade'
575   })
576   AccountVideoRates: AccountVideoRateModel[]
577
578   @HasMany(() => VideoCommentModel, {
579     foreignKey: {
580       name: 'videoId',
581       allowNull: false
582     },
583     onDelete: 'cascade',
584     hooks: true
585   })
586   VideoComments: VideoCommentModel[]
587
588   @HasOne(() => ScheduleVideoUpdateModel, {
589     foreignKey: {
590       name: 'videoId',
591       allowNull: false
592     },
593     onDelete: 'cascade'
594   })
595   ScheduleVideoUpdate: ScheduleVideoUpdateModel
596
597   @HasOne(() => VideoBlacklistModel, {
598     foreignKey: {
599       name: 'videoId',
600       allowNull: false
601     },
602     onDelete: 'cascade'
603   })
604   VideoBlacklist: VideoBlacklistModel
605
606   @HasMany(() => VideoCaptionModel, {
607     foreignKey: {
608       name: 'videoId',
609       allowNull: false
610     },
611     onDelete: 'cascade',
612     hooks: true,
613     ['separate' as any]: true
614   })
615   VideoCaptions: VideoCaptionModel[]
616
617   @BeforeDestroy
618   static async sendDelete (instance: VideoModel, options) {
619     if (instance.isOwned()) {
620       if (!instance.VideoChannel) {
621         instance.VideoChannel = await instance.$get('VideoChannel', {
622           include: [
623             {
624               model: AccountModel,
625               include: [ ActorModel ]
626             }
627           ],
628           transaction: options.transaction
629         }) as VideoChannelModel
630       }
631
632       return sendDeleteVideo(instance, options.transaction)
633     }
634
635     return undefined
636   }
637
638   @BeforeDestroy
639   static async removeFiles (instance: VideoModel) {
640     const tasks: Promise<any>[] = []
641
642     logger.info('Removing files of video %s.', instance.url)
643
644     tasks.push(instance.removeThumbnail())
645
646     if (instance.isOwned()) {
647       if (!Array.isArray(instance.VideoFiles)) {
648         instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
649       }
650
651       tasks.push(instance.removePreview())
652
653       // Remove physical files and torrents
654       instance.VideoFiles.forEach(file => {
655         tasks.push(instance.removeFile(file))
656         tasks.push(instance.removeTorrent(file))
657       })
658     }
659
660     // Do not wait video deletion because we could be in a transaction
661     Promise.all(tasks)
662       .catch(err => {
663         logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
664       })
665
666     return undefined
667   }
668
669   static list () {
670     return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
671   }
672
673   static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
674     function getRawQuery (select: string) {
675       const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
676         'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
677         'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
678         'WHERE "Account"."actorId" = ' + actorId
679       const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
680         'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
681         'WHERE "VideoShare"."actorId" = ' + actorId
682
683       return `(${queryVideo}) UNION (${queryVideoShare})`
684     }
685
686     const rawQuery = getRawQuery('"Video"."id"')
687     const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
688
689     const query = {
690       distinct: true,
691       offset: start,
692       limit: count,
693       order: getSort('createdAt', [ 'Tags', 'name', 'ASC' ]),
694       where: {
695         id: {
696           [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
697         },
698         [Sequelize.Op.or]: [
699           { privacy: VideoPrivacy.PUBLIC },
700           { privacy: VideoPrivacy.UNLISTED }
701         ]
702       },
703       include: [
704         {
705           attributes: [ 'language' ],
706           model: VideoCaptionModel.unscoped(),
707           required: false
708         },
709         {
710           attributes: [ 'id', 'url' ],
711           model: VideoShareModel.unscoped(),
712           required: false,
713           // We only want videos shared by this actor
714           where: {
715             [Sequelize.Op.and]: [
716               {
717                 id: {
718                   [Sequelize.Op.not]: null
719                 }
720               },
721               {
722                 actorId
723               }
724             ]
725           },
726           include: [
727             {
728               attributes: [ 'id', 'url' ],
729               model: ActorModel.unscoped()
730             }
731           ]
732         },
733         {
734           model: VideoChannelModel.unscoped(),
735           required: true,
736           include: [
737             {
738               attributes: [ 'name' ],
739               model: AccountModel.unscoped(),
740               required: true,
741               include: [
742                 {
743                   attributes: [ 'id', 'url', 'followersUrl' ],
744                   model: ActorModel.unscoped(),
745                   required: true
746                 }
747               ]
748             },
749             {
750               attributes: [ 'id', 'url', 'followersUrl' ],
751               model: ActorModel.unscoped(),
752               required: true
753             }
754           ]
755         },
756         VideoFileModel,
757         TagModel
758       ]
759     }
760
761     return Bluebird.all([
762       // FIXME: typing issue
763       VideoModel.findAll(query as any),
764       VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
765     ]).then(([ rows, totals ]) => {
766       // totals: totalVideos + totalVideoShares
767       let totalVideos = 0
768       let totalVideoShares = 0
769       if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
770       if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
771
772       const total = totalVideos + totalVideoShares
773       return {
774         data: rows,
775         total: total
776       }
777     })
778   }
779
780   static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) {
781     const query: IFindOptions<VideoModel> = {
782       offset: start,
783       limit: count,
784       order: getSort(sort),
785       include: [
786         {
787           model: VideoChannelModel,
788           required: true,
789           include: [
790             {
791               model: AccountModel,
792               where: {
793                 id: accountId
794               },
795               required: true
796             }
797           ]
798         },
799         {
800           model: ScheduleVideoUpdateModel,
801           required: false
802         },
803         {
804           model: VideoBlacklistModel,
805           required: false
806         }
807       ]
808     }
809
810     if (withFiles === true) {
811       query.include.push({
812         model: VideoFileModel.unscoped(),
813         required: true
814       })
815     }
816
817     return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
818       return {
819         data: rows,
820         total: count
821       }
822     })
823   }
824
825   static async listForApi (options: {
826     start: number,
827     count: number,
828     sort: string,
829     nsfw: boolean,
830     includeLocalVideos: boolean,
831     withFiles: boolean,
832     categoryOneOf?: number[],
833     licenceOneOf?: number[],
834     languageOneOf?: string[],
835     tagsOneOf?: string[],
836     tagsAllOf?: string[],
837     filter?: VideoFilter,
838     accountId?: number,
839     videoChannelId?: number,
840     actorId?: number
841   }) {
842     const query = {
843       offset: options.start,
844       limit: options.count,
845       order: getSort(options.sort)
846     }
847
848     // actorId === null has a meaning, so just check undefined
849     const actorId = options.actorId !== undefined ? options.actorId : (await getServerActor()).id
850
851     const scopes = {
852       method: [
853         ScopeNames.AVAILABLE_FOR_LIST, {
854           actorId,
855           nsfw: options.nsfw,
856           categoryOneOf: options.categoryOneOf,
857           licenceOneOf: options.licenceOneOf,
858           languageOneOf: options.languageOneOf,
859           tagsOneOf: options.tagsOneOf,
860           tagsAllOf: options.tagsAllOf,
861           filter: options.filter,
862           withFiles: options.withFiles,
863           accountId: options.accountId,
864           videoChannelId: options.videoChannelId,
865           includeLocalVideos: options.includeLocalVideos
866         } as AvailableForListOptions
867       ]
868     }
869
870     return VideoModel.scope(scopes)
871       .findAndCountAll(query)
872       .then(({ rows, count }) => {
873         return {
874           data: rows,
875           total: count
876         }
877       })
878   }
879
880   static async searchAndPopulateAccountAndServer (options: {
881     includeLocalVideos: boolean
882     search?: string
883     start?: number
884     count?: number
885     sort?: string
886     startDate?: string // ISO 8601
887     endDate?: string // ISO 8601
888     nsfw?: boolean
889     categoryOneOf?: number[]
890     licenceOneOf?: number[]
891     languageOneOf?: string[]
892     tagsOneOf?: string[]
893     tagsAllOf?: string[]
894     durationMin?: number // seconds
895     durationMax?: number // seconds
896   }) {
897     const whereAnd = [ ]
898
899     if (options.startDate || options.endDate) {
900       const publishedAtRange = { }
901
902       if (options.startDate) publishedAtRange[Sequelize.Op.gte] = options.startDate
903       if (options.endDate) publishedAtRange[Sequelize.Op.lte] = options.endDate
904
905       whereAnd.push({ publishedAt: publishedAtRange })
906     }
907
908     if (options.durationMin || options.durationMax) {
909       const durationRange = { }
910
911       if (options.durationMin) durationRange[Sequelize.Op.gte] = options.durationMin
912       if (options.durationMax) durationRange[Sequelize.Op.lte] = options.durationMax
913
914       whereAnd.push({ duration: durationRange })
915     }
916
917     const attributesInclude = []
918     const escapedSearch = VideoModel.sequelize.escape(options.search)
919     const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
920     if (options.search) {
921       whereAnd.push(
922         {
923           id: {
924             [ Sequelize.Op.in ]: Sequelize.literal(
925               '(' +
926                 'SELECT "video"."id" FROM "video" ' +
927                 'WHERE ' +
928                 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
929                 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
930                 'UNION ALL ' +
931                 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
932                 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
933                 'WHERE "tag"."name" = ' + escapedSearch +
934               ')'
935             )
936           }
937         }
938       )
939
940       attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
941     }
942
943     // Cannot search on similarity if we don't have a search
944     if (!options.search) {
945       attributesInclude.push(
946         Sequelize.literal('0 as similarity')
947       )
948     }
949
950     const query: IFindOptions<VideoModel> = {
951       attributes: {
952         include: attributesInclude
953       },
954       offset: options.start,
955       limit: options.count,
956       order: getSort(options.sort),
957       where: {
958         [ Sequelize.Op.and ]: whereAnd
959       }
960     }
961
962     const serverActor = await getServerActor()
963     const scopes = {
964       method: [
965         ScopeNames.AVAILABLE_FOR_LIST, {
966           actorId: serverActor.id,
967           includeLocalVideos: options.includeLocalVideos,
968           nsfw: options.nsfw,
969           categoryOneOf: options.categoryOneOf,
970           licenceOneOf: options.licenceOneOf,
971           languageOneOf: options.languageOneOf,
972           tagsOneOf: options.tagsOneOf,
973           tagsAllOf: options.tagsAllOf
974         } as AvailableForListOptions
975       ]
976     }
977
978     return VideoModel.scope(scopes)
979       .findAndCountAll(query)
980       .then(({ rows, count }) => {
981         return {
982           data: rows,
983           total: count
984         }
985       })
986   }
987
988   static load (id: number, t?: Sequelize.Transaction) {
989     const options = t ? { transaction: t } : undefined
990
991     return VideoModel.findById(id, options)
992   }
993
994   static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
995     const query: IFindOptions<VideoModel> = {
996       where: {
997         url
998       }
999     }
1000
1001     if (t !== undefined) query.transaction = t
1002
1003     return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
1004   }
1005
1006   static loadAndPopulateAccountAndServerAndTags (id: number) {
1007     const options = {
1008       order: [ [ 'Tags', 'name', 'ASC' ] ]
1009     }
1010
1011     return VideoModel
1012       .scope([
1013         ScopeNames.WITH_TAGS,
1014         ScopeNames.WITH_BLACKLISTED,
1015         ScopeNames.WITH_FILES,
1016         ScopeNames.WITH_ACCOUNT_DETAILS,
1017         ScopeNames.WITH_SCHEDULED_UPDATE
1018       ])
1019       .findById(id, options)
1020   }
1021
1022   static loadByUUID (uuid: string) {
1023     const options = {
1024       where: {
1025         uuid
1026       }
1027     }
1028
1029     return VideoModel
1030       .scope([ ScopeNames.WITH_FILES ])
1031       .findOne(options)
1032   }
1033
1034   static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string, t?: Sequelize.Transaction) {
1035     const options = {
1036       order: [ [ 'Tags', 'name', 'ASC' ] ],
1037       where: {
1038         uuid
1039       },
1040       transaction: t
1041     }
1042
1043     return VideoModel
1044       .scope([
1045         ScopeNames.WITH_TAGS,
1046         ScopeNames.WITH_BLACKLISTED,
1047         ScopeNames.WITH_FILES,
1048         ScopeNames.WITH_ACCOUNT_DETAILS,
1049         ScopeNames.WITH_SCHEDULED_UPDATE
1050       ])
1051       .findOne(options)
1052   }
1053
1054   static async getStats () {
1055     const totalLocalVideos = await VideoModel.count({
1056       where: {
1057         remote: false
1058       }
1059     })
1060     const totalVideos = await VideoModel.count()
1061
1062     let totalLocalVideoViews = await VideoModel.sum('views', {
1063       where: {
1064         remote: false
1065       }
1066     })
1067     // Sequelize could return null...
1068     if (!totalLocalVideoViews) totalLocalVideoViews = 0
1069
1070     return {
1071       totalLocalVideos,
1072       totalLocalVideoViews,
1073       totalVideos
1074     }
1075   }
1076
1077   private static buildActorWhereWithFilter (filter?: VideoFilter) {
1078     if (filter && filter === 'local') {
1079       return {
1080         serverId: null
1081       }
1082     }
1083
1084     return {}
1085   }
1086
1087   private static getCategoryLabel (id: number) {
1088     return VIDEO_CATEGORIES[id] || 'Misc'
1089   }
1090
1091   private static getLicenceLabel (id: number) {
1092     return VIDEO_LICENCES[id] || 'Unknown'
1093   }
1094
1095   private static getLanguageLabel (id: string) {
1096     return VIDEO_LANGUAGES[id] || 'Unknown'
1097   }
1098
1099   private static getPrivacyLabel (id: number) {
1100     return VIDEO_PRIVACIES[id] || 'Unknown'
1101   }
1102
1103   private static getStateLabel (id: number) {
1104     return VIDEO_STATES[id] || 'Unknown'
1105   }
1106
1107   getOriginalFile () {
1108     if (Array.isArray(this.VideoFiles) === false) return undefined
1109
1110     // The original file is the file that have the higher resolution
1111     return maxBy(this.VideoFiles, file => file.resolution)
1112   }
1113
1114   getVideoFilename (videoFile: VideoFileModel) {
1115     return this.uuid + '-' + videoFile.resolution + videoFile.extname
1116   }
1117
1118   getThumbnailName () {
1119     // We always have a copy of the thumbnail
1120     const extension = '.jpg'
1121     return this.uuid + extension
1122   }
1123
1124   getPreviewName () {
1125     const extension = '.jpg'
1126     return this.uuid + extension
1127   }
1128
1129   getTorrentFileName (videoFile: VideoFileModel) {
1130     const extension = '.torrent'
1131     return this.uuid + '-' + videoFile.resolution + extension
1132   }
1133
1134   isOwned () {
1135     return this.remote === false
1136   }
1137
1138   createPreview (videoFile: VideoFileModel) {
1139     return generateImageFromVideoFile(
1140       this.getVideoFilePath(videoFile),
1141       CONFIG.STORAGE.PREVIEWS_DIR,
1142       this.getPreviewName(),
1143       PREVIEWS_SIZE
1144     )
1145   }
1146
1147   createThumbnail (videoFile: VideoFileModel) {
1148     return generateImageFromVideoFile(
1149       this.getVideoFilePath(videoFile),
1150       CONFIG.STORAGE.THUMBNAILS_DIR,
1151       this.getThumbnailName(),
1152       THUMBNAILS_SIZE
1153     )
1154   }
1155
1156   getTorrentFilePath (videoFile: VideoFileModel) {
1157     return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1158   }
1159
1160   getVideoFilePath (videoFile: VideoFileModel) {
1161     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1162   }
1163
1164   async createTorrentAndSetInfoHash (videoFile: VideoFileModel) {
1165     const options = {
1166       // Keep the extname, it's used by the client to stream the file inside a web browser
1167       name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
1168       createdBy: 'PeerTube',
1169       announceList: [
1170         [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ],
1171         [ CONFIG.WEBSERVER.URL + '/tracker/announce' ]
1172       ],
1173       urlList: [
1174         CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1175       ]
1176     }
1177
1178     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
1179
1180     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1181     logger.info('Creating torrent %s.', filePath)
1182
1183     await writeFile(filePath, torrent)
1184
1185     const parsedTorrent = parseTorrent(torrent)
1186     videoFile.infoHash = parsedTorrent.infoHash
1187   }
1188
1189   getEmbedStaticPath () {
1190     return '/videos/embed/' + this.uuid
1191   }
1192
1193   getThumbnailStaticPath () {
1194     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
1195   }
1196
1197   getPreviewStaticPath () {
1198     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1199   }
1200
1201   toFormattedJSON (options?: {
1202     additionalAttributes: {
1203       state?: boolean,
1204       waitTranscoding?: boolean,
1205       scheduledUpdate?: boolean,
1206       blacklistInfo?: boolean
1207     }
1208   }): Video {
1209     const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
1210     const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
1211
1212     const videoObject: Video = {
1213       id: this.id,
1214       uuid: this.uuid,
1215       name: this.name,
1216       category: {
1217         id: this.category,
1218         label: VideoModel.getCategoryLabel(this.category)
1219       },
1220       licence: {
1221         id: this.licence,
1222         label: VideoModel.getLicenceLabel(this.licence)
1223       },
1224       language: {
1225         id: this.language,
1226         label: VideoModel.getLanguageLabel(this.language)
1227       },
1228       privacy: {
1229         id: this.privacy,
1230         label: VideoModel.getPrivacyLabel(this.privacy)
1231       },
1232       nsfw: this.nsfw,
1233       description: this.getTruncatedDescription(),
1234       isLocal: this.isOwned(),
1235       duration: this.duration,
1236       views: this.views,
1237       likes: this.likes,
1238       dislikes: this.dislikes,
1239       thumbnailPath: this.getThumbnailStaticPath(),
1240       previewPath: this.getPreviewStaticPath(),
1241       embedPath: this.getEmbedStaticPath(),
1242       createdAt: this.createdAt,
1243       updatedAt: this.updatedAt,
1244       publishedAt: this.publishedAt,
1245       account: {
1246         id: formattedAccount.id,
1247         uuid: formattedAccount.uuid,
1248         name: formattedAccount.name,
1249         displayName: formattedAccount.displayName,
1250         url: formattedAccount.url,
1251         host: formattedAccount.host,
1252         avatar: formattedAccount.avatar
1253       },
1254       channel: {
1255         id: formattedVideoChannel.id,
1256         uuid: formattedVideoChannel.uuid,
1257         name: formattedVideoChannel.name,
1258         displayName: formattedVideoChannel.displayName,
1259         url: formattedVideoChannel.url,
1260         host: formattedVideoChannel.host,
1261         avatar: formattedVideoChannel.avatar
1262       }
1263     }
1264
1265     if (options) {
1266       if (options.additionalAttributes.state === true) {
1267         videoObject.state = {
1268           id: this.state,
1269           label: VideoModel.getStateLabel(this.state)
1270         }
1271       }
1272
1273       if (options.additionalAttributes.waitTranscoding === true) {
1274         videoObject.waitTranscoding = this.waitTranscoding
1275       }
1276
1277       if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
1278         videoObject.scheduledUpdate = {
1279           updateAt: this.ScheduleVideoUpdate.updateAt,
1280           privacy: this.ScheduleVideoUpdate.privacy || undefined
1281         }
1282       }
1283
1284       if (options.additionalAttributes.blacklistInfo === true) {
1285         videoObject.blacklisted = !!this.VideoBlacklist
1286         videoObject.blacklistedReason = this.VideoBlacklist ? this.VideoBlacklist.reason : null
1287       }
1288     }
1289
1290     return videoObject
1291   }
1292
1293   toFormattedDetailsJSON (): VideoDetails {
1294     const formattedJson = this.toFormattedJSON({
1295       additionalAttributes: {
1296         scheduledUpdate: true,
1297         blacklistInfo: true
1298       }
1299     })
1300
1301     const detailsJson = {
1302       support: this.support,
1303       descriptionPath: this.getDescriptionPath(),
1304       channel: this.VideoChannel.toFormattedJSON(),
1305       account: this.VideoChannel.Account.toFormattedJSON(),
1306       tags: map(this.Tags, 'name'),
1307       commentsEnabled: this.commentsEnabled,
1308       waitTranscoding: this.waitTranscoding,
1309       state: {
1310         id: this.state,
1311         label: VideoModel.getStateLabel(this.state)
1312       },
1313       files: []
1314     }
1315
1316     // Format and sort video files
1317     detailsJson.files = this.getFormattedVideoFilesJSON()
1318
1319     return Object.assign(formattedJson, detailsJson)
1320   }
1321
1322   getFormattedVideoFilesJSON (): VideoFile[] {
1323     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1324
1325     return this.VideoFiles
1326         .map(videoFile => {
1327           let resolutionLabel = videoFile.resolution + 'p'
1328
1329           return {
1330             resolution: {
1331               id: videoFile.resolution,
1332               label: resolutionLabel
1333             },
1334             magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1335             size: videoFile.size,
1336             fps: videoFile.fps,
1337             torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1338             torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
1339             fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
1340             fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
1341           } as VideoFile
1342         })
1343         .sort((a, b) => {
1344           if (a.resolution.id < b.resolution.id) return 1
1345           if (a.resolution.id === b.resolution.id) return 0
1346           return -1
1347         })
1348   }
1349
1350   toActivityPubObject (): VideoTorrentObject {
1351     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1352     if (!this.Tags) this.Tags = []
1353
1354     const tag = this.Tags.map(t => ({
1355       type: 'Hashtag' as 'Hashtag',
1356       name: t.name
1357     }))
1358
1359     let language
1360     if (this.language) {
1361       language = {
1362         identifier: this.language,
1363         name: VideoModel.getLanguageLabel(this.language)
1364       }
1365     }
1366
1367     let category
1368     if (this.category) {
1369       category = {
1370         identifier: this.category + '',
1371         name: VideoModel.getCategoryLabel(this.category)
1372       }
1373     }
1374
1375     let licence
1376     if (this.licence) {
1377       licence = {
1378         identifier: this.licence + '',
1379         name: VideoModel.getLicenceLabel(this.licence)
1380       }
1381     }
1382
1383     const url = []
1384     for (const file of this.VideoFiles) {
1385       url.push({
1386         type: 'Link',
1387         mimeType: VIDEO_EXT_MIMETYPE[file.extname],
1388         href: this.getVideoFileUrl(file, baseUrlHttp),
1389         height: file.resolution,
1390         size: file.size,
1391         fps: file.fps
1392       })
1393
1394       url.push({
1395         type: 'Link',
1396         mimeType: 'application/x-bittorrent',
1397         href: this.getTorrentUrl(file, baseUrlHttp),
1398         height: file.resolution
1399       })
1400
1401       url.push({
1402         type: 'Link',
1403         mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
1404         href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1405         height: file.resolution
1406       })
1407     }
1408
1409     // Add video url too
1410     url.push({
1411       type: 'Link',
1412       mimeType: 'text/html',
1413       href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1414     })
1415
1416     const subtitleLanguage = []
1417     for (const caption of this.VideoCaptions) {
1418       subtitleLanguage.push({
1419         identifier: caption.language,
1420         name: VideoCaptionModel.getLanguageLabel(caption.language)
1421       })
1422     }
1423
1424     return {
1425       type: 'Video' as 'Video',
1426       id: this.url,
1427       name: this.name,
1428       duration: this.getActivityStreamDuration(),
1429       uuid: this.uuid,
1430       tag,
1431       category,
1432       licence,
1433       language,
1434       views: this.views,
1435       sensitive: this.nsfw,
1436       waitTranscoding: this.waitTranscoding,
1437       state: this.state,
1438       commentsEnabled: this.commentsEnabled,
1439       published: this.publishedAt.toISOString(),
1440       updated: this.updatedAt.toISOString(),
1441       mediaType: 'text/markdown',
1442       content: this.getTruncatedDescription(),
1443       support: this.support,
1444       subtitleLanguage,
1445       icon: {
1446         type: 'Image',
1447         url: this.getThumbnailUrl(baseUrlHttp),
1448         mediaType: 'image/jpeg',
1449         width: THUMBNAILS_SIZE.width,
1450         height: THUMBNAILS_SIZE.height
1451       },
1452       url,
1453       likes: getVideoLikesActivityPubUrl(this),
1454       dislikes: getVideoDislikesActivityPubUrl(this),
1455       shares: getVideoSharesActivityPubUrl(this),
1456       comments: getVideoCommentsActivityPubUrl(this),
1457       attributedTo: [
1458         {
1459           type: 'Person',
1460           id: this.VideoChannel.Account.Actor.url
1461         },
1462         {
1463           type: 'Group',
1464           id: this.VideoChannel.Actor.url
1465         }
1466       ]
1467     }
1468   }
1469
1470   getTruncatedDescription () {
1471     if (!this.description) return null
1472
1473     const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1474     return peertubeTruncate(this.description, maxLength)
1475   }
1476
1477   async optimizeOriginalVideofile () {
1478     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1479     const newExtname = '.mp4'
1480     const inputVideoFile = this.getOriginalFile()
1481     const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1482     const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1483
1484     const transcodeOptions = {
1485       inputPath: videoInputPath,
1486       outputPath: videoTranscodedPath
1487     }
1488
1489     // Could be very long!
1490     await transcode(transcodeOptions)
1491
1492     try {
1493       await remove(videoInputPath)
1494
1495       // Important to do this before getVideoFilename() to take in account the new file extension
1496       inputVideoFile.set('extname', newExtname)
1497
1498       const videoOutputPath = this.getVideoFilePath(inputVideoFile)
1499       await rename(videoTranscodedPath, videoOutputPath)
1500       const stats = await stat(videoOutputPath)
1501       const fps = await getVideoFileFPS(videoOutputPath)
1502
1503       inputVideoFile.set('size', stats.size)
1504       inputVideoFile.set('fps', fps)
1505
1506       await this.createTorrentAndSetInfoHash(inputVideoFile)
1507       await inputVideoFile.save()
1508
1509     } catch (err) {
1510       // Auto destruction...
1511       this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
1512
1513       throw err
1514     }
1515   }
1516
1517   async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
1518     const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1519     const extname = '.mp4'
1520
1521     // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1522     const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1523
1524     const newVideoFile = new VideoFileModel({
1525       resolution,
1526       extname,
1527       size: 0,
1528       videoId: this.id
1529     })
1530     const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1531
1532     const transcodeOptions = {
1533       inputPath: videoInputPath,
1534       outputPath: videoOutputPath,
1535       resolution,
1536       isPortraitMode
1537     }
1538
1539     await transcode(transcodeOptions)
1540
1541     const stats = await stat(videoOutputPath)
1542     const fps = await getVideoFileFPS(videoOutputPath)
1543
1544     newVideoFile.set('size', stats.size)
1545     newVideoFile.set('fps', fps)
1546
1547     await this.createTorrentAndSetInfoHash(newVideoFile)
1548
1549     await newVideoFile.save()
1550
1551     this.VideoFiles.push(newVideoFile)
1552   }
1553
1554   async importVideoFile (inputFilePath: string) {
1555     const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
1556     const { size } = await stat(inputFilePath)
1557     const fps = await getVideoFileFPS(inputFilePath)
1558
1559     let updatedVideoFile = new VideoFileModel({
1560       resolution: videoFileResolution,
1561       extname: extname(inputFilePath),
1562       size,
1563       fps,
1564       videoId: this.id
1565     })
1566
1567     const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
1568
1569     if (currentVideoFile) {
1570       // Remove old file and old torrent
1571       await this.removeFile(currentVideoFile)
1572       await this.removeTorrent(currentVideoFile)
1573       // Remove the old video file from the array
1574       this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile)
1575
1576       // Update the database
1577       currentVideoFile.set('extname', updatedVideoFile.extname)
1578       currentVideoFile.set('size', updatedVideoFile.size)
1579       currentVideoFile.set('fps', updatedVideoFile.fps)
1580
1581       updatedVideoFile = currentVideoFile
1582     }
1583
1584     const outputPath = this.getVideoFilePath(updatedVideoFile)
1585     await copy(inputFilePath, outputPath)
1586
1587     await this.createTorrentAndSetInfoHash(updatedVideoFile)
1588
1589     await updatedVideoFile.save()
1590
1591     this.VideoFiles.push(updatedVideoFile)
1592   }
1593
1594   getOriginalFileResolution () {
1595     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1596
1597     return getVideoFileResolution(originalFilePath)
1598   }
1599
1600   getDescriptionPath () {
1601     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1602   }
1603
1604   removeThumbnail () {
1605     const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1606     return remove(thumbnailPath)
1607       .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
1608   }
1609
1610   removePreview () {
1611     const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1612     return remove(previewPath)
1613       .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
1614   }
1615
1616   removeFile (videoFile: VideoFileModel) {
1617     const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1618     return remove(filePath)
1619       .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1620   }
1621
1622   removeTorrent (videoFile: VideoFileModel) {
1623     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1624     return remove(torrentPath)
1625       .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1626   }
1627
1628   getActivityStreamDuration () {
1629     // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1630     return 'PT' + this.duration + 'S'
1631   }
1632
1633   isOutdated () {
1634     if (this.isOwned()) return false
1635
1636     const now = Date.now()
1637     const createdAtTime = this.createdAt.getTime()
1638     const updatedAtTime = this.updatedAt.getTime()
1639
1640     return (now - createdAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL &&
1641       (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL
1642   }
1643
1644   private getBaseUrls () {
1645     let baseUrlHttp
1646     let baseUrlWs
1647
1648     if (this.isOwned()) {
1649       baseUrlHttp = CONFIG.WEBSERVER.URL
1650       baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1651     } else {
1652       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1653       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1654     }
1655
1656     return { baseUrlHttp, baseUrlWs }
1657   }
1658
1659   private getThumbnailUrl (baseUrlHttp: string) {
1660     return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1661   }
1662
1663   private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1664     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1665   }
1666
1667   private getTorrentDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1668     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1669   }
1670
1671   private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1672     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1673   }
1674
1675   private getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1676     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1677   }
1678
1679   private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1680     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1681     const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1682     const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1683
1684     const magnetHash = {
1685       xs,
1686       announce,
1687       urlList,
1688       infoHash: videoFile.infoHash,
1689       name: this.name
1690     }
1691
1692     return magnetUtil.encode(magnetHash)
1693   }
1694 }