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