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