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