Server: Bulk update videos support field
[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     const findScopes = [
1130       ScopeNames.WITH_SCHEDULED_UPDATE,
1131       ScopeNames.WITH_BLACKLISTED,
1132       ScopeNames.WITH_THUMBNAILS
1133     ]
1134
1135     if (withFiles === true) {
1136       findQuery.include.push({
1137         model: VideoFileModel.unscoped(),
1138         required: true
1139       })
1140     }
1141
1142     return Promise.all([
1143       VideoModel.count(countQuery),
1144       VideoModel.scope(findScopes).findAll(findQuery)
1145     ]).then(([ count, rows ]) => {
1146       return {
1147         data: rows,
1148         total: count
1149       }
1150     })
1151   }
1152
1153   static async listForApi (options: {
1154     start: number,
1155     count: number,
1156     sort: string,
1157     nsfw: boolean,
1158     includeLocalVideos: boolean,
1159     withFiles: boolean,
1160     categoryOneOf?: number[],
1161     licenceOneOf?: number[],
1162     languageOneOf?: string[],
1163     tagsOneOf?: string[],
1164     tagsAllOf?: string[],
1165     filter?: VideoFilter,
1166     accountId?: number,
1167     videoChannelId?: number,
1168     followerActorId?: number
1169     videoPlaylistId?: number,
1170     trendingDays?: number,
1171     user?: UserModel,
1172     historyOfUser?: UserModel
1173   }, countVideos = true) {
1174     if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1175       throw new Error('Try to filter all-local but no user has not the see all videos right')
1176     }
1177
1178     const query: FindOptions = {
1179       offset: options.start,
1180       limit: options.count,
1181       order: getVideoSort(options.sort)
1182     }
1183
1184     let trendingDays: number
1185     if (options.sort.endsWith('trending')) {
1186       trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1187
1188       query.group = 'VideoModel.id'
1189     }
1190
1191     const serverActor = await getServerActor()
1192
1193     // followerActorId === null has a meaning, so just check undefined
1194     const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id
1195
1196     const queryOptions = {
1197       followerActorId,
1198       serverAccountId: serverActor.Account.id,
1199       nsfw: options.nsfw,
1200       categoryOneOf: options.categoryOneOf,
1201       licenceOneOf: options.licenceOneOf,
1202       languageOneOf: options.languageOneOf,
1203       tagsOneOf: options.tagsOneOf,
1204       tagsAllOf: options.tagsAllOf,
1205       filter: options.filter,
1206       withFiles: options.withFiles,
1207       accountId: options.accountId,
1208       videoChannelId: options.videoChannelId,
1209       videoPlaylistId: options.videoPlaylistId,
1210       includeLocalVideos: options.includeLocalVideos,
1211       user: options.user,
1212       historyOfUser: options.historyOfUser,
1213       trendingDays
1214     }
1215
1216     return VideoModel.getAvailableForApi(query, queryOptions, countVideos)
1217   }
1218
1219   static async searchAndPopulateAccountAndServer (options: {
1220     includeLocalVideos: boolean
1221     search?: string
1222     start?: number
1223     count?: number
1224     sort?: string
1225     startDate?: string // ISO 8601
1226     endDate?: string // ISO 8601
1227     originallyPublishedStartDate?: string
1228     originallyPublishedEndDate?: string
1229     nsfw?: boolean
1230     categoryOneOf?: number[]
1231     licenceOneOf?: number[]
1232     languageOneOf?: string[]
1233     tagsOneOf?: string[]
1234     tagsAllOf?: string[]
1235     durationMin?: number // seconds
1236     durationMax?: number // seconds
1237     user?: UserModel,
1238     filter?: VideoFilter
1239   }) {
1240     const whereAnd = []
1241
1242     if (options.startDate || options.endDate) {
1243       const publishedAtRange = {}
1244
1245       if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate
1246       if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate
1247
1248       whereAnd.push({ publishedAt: publishedAtRange })
1249     }
1250
1251     if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) {
1252       const originallyPublishedAtRange = {}
1253
1254       if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate
1255       if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate
1256
1257       whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange })
1258     }
1259
1260     if (options.durationMin || options.durationMax) {
1261       const durationRange = {}
1262
1263       if (options.durationMin) durationRange[ Op.gte ] = options.durationMin
1264       if (options.durationMax) durationRange[ Op.lte ] = options.durationMax
1265
1266       whereAnd.push({ duration: durationRange })
1267     }
1268
1269     const attributesInclude = []
1270     const escapedSearch = VideoModel.sequelize.escape(options.search)
1271     const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
1272     if (options.search) {
1273       whereAnd.push(
1274         {
1275           id: {
1276             [ Op.in ]: Sequelize.literal(
1277               '(' +
1278               'SELECT "video"."id" FROM "video" ' +
1279               'WHERE ' +
1280               'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
1281               'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
1282               'UNION ALL ' +
1283               'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
1284               'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
1285               'WHERE "tag"."name" = ' + escapedSearch +
1286               ')'
1287             )
1288           }
1289         }
1290       )
1291
1292       attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
1293     }
1294
1295     // Cannot search on similarity if we don't have a search
1296     if (!options.search) {
1297       attributesInclude.push(
1298         Sequelize.literal('0 as similarity')
1299       )
1300     }
1301
1302     const query: FindOptions = {
1303       attributes: {
1304         include: attributesInclude
1305       },
1306       offset: options.start,
1307       limit: options.count,
1308       order: getVideoSort(options.sort),
1309       where: {
1310         [ Op.and ]: whereAnd
1311       }
1312     }
1313
1314     const serverActor = await getServerActor()
1315     const queryOptions = {
1316       followerActorId: serverActor.id,
1317       serverAccountId: serverActor.Account.id,
1318       includeLocalVideos: options.includeLocalVideos,
1319       nsfw: options.nsfw,
1320       categoryOneOf: options.categoryOneOf,
1321       licenceOneOf: options.licenceOneOf,
1322       languageOneOf: options.languageOneOf,
1323       tagsOneOf: options.tagsOneOf,
1324       tagsAllOf: options.tagsAllOf,
1325       user: options.user,
1326       filter: options.filter
1327     }
1328
1329     return VideoModel.getAvailableForApi(query, queryOptions)
1330   }
1331
1332   static load (id: number | string, t?: Transaction) {
1333     const where = buildWhereIdOrUUID(id)
1334     const options = {
1335       where,
1336       transaction: t
1337     }
1338
1339     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1340   }
1341
1342   static loadWithRights (id: number | string, t?: Transaction) {
1343     const where = buildWhereIdOrUUID(id)
1344     const options = {
1345       where,
1346       transaction: t
1347     }
1348
1349     return VideoModel.scope([
1350       ScopeNames.WITH_BLACKLISTED,
1351       ScopeNames.WITH_USER_ID,
1352       ScopeNames.WITH_THUMBNAILS
1353     ]).findOne(options)
1354   }
1355
1356   static loadOnlyId (id: number | string, t?: Transaction) {
1357     const where = buildWhereIdOrUUID(id)
1358
1359     const options = {
1360       attributes: [ 'id' ],
1361       where,
1362       transaction: t
1363     }
1364
1365     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1366   }
1367
1368   static loadWithFiles (id: number, t?: Transaction, logging?: boolean) {
1369     return VideoModel.scope([
1370       ScopeNames.WITH_FILES,
1371       ScopeNames.WITH_STREAMING_PLAYLISTS,
1372       ScopeNames.WITH_THUMBNAILS
1373     ]).findByPk(id, { transaction: t, logging })
1374   }
1375
1376   static loadByUUIDWithFile (uuid: string) {
1377     const options = {
1378       where: {
1379         uuid
1380       }
1381     }
1382
1383     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1384   }
1385
1386   static loadByUrl (url: string, transaction?: Transaction) {
1387     const query: FindOptions = {
1388       where: {
1389         url
1390       },
1391       transaction
1392     }
1393
1394     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1395   }
1396
1397   static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction) {
1398     const query: FindOptions = {
1399       where: {
1400         url
1401       },
1402       transaction
1403     }
1404
1405     return VideoModel.scope([
1406       ScopeNames.WITH_ACCOUNT_DETAILS,
1407       ScopeNames.WITH_FILES,
1408       ScopeNames.WITH_STREAMING_PLAYLISTS,
1409       ScopeNames.WITH_THUMBNAILS
1410     ]).findOne(query)
1411   }
1412
1413   static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number) {
1414     const where = buildWhereIdOrUUID(id)
1415
1416     const options = {
1417       order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1418       where,
1419       transaction: t
1420     }
1421
1422     const scopes: (string | ScopeOptions)[] = [
1423       ScopeNames.WITH_TAGS,
1424       ScopeNames.WITH_BLACKLISTED,
1425       ScopeNames.WITH_ACCOUNT_DETAILS,
1426       ScopeNames.WITH_SCHEDULED_UPDATE,
1427       ScopeNames.WITH_FILES,
1428       ScopeNames.WITH_STREAMING_PLAYLISTS,
1429       ScopeNames.WITH_THUMBNAILS
1430     ]
1431
1432     if (userId) {
1433       scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1434     }
1435
1436     return VideoModel
1437       .scope(scopes)
1438       .findOne(options)
1439   }
1440
1441   static loadForGetAPI (id: number | string, t?: Transaction, userId?: number) {
1442     const where = buildWhereIdOrUUID(id)
1443
1444     const options = {
1445       order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1446       where,
1447       transaction: t
1448     }
1449
1450     const scopes: (string | ScopeOptions)[] = [
1451       ScopeNames.WITH_TAGS,
1452       ScopeNames.WITH_BLACKLISTED,
1453       ScopeNames.WITH_ACCOUNT_DETAILS,
1454       ScopeNames.WITH_SCHEDULED_UPDATE,
1455       ScopeNames.WITH_THUMBNAILS,
1456       { method: [ ScopeNames.WITH_FILES, true ] },
1457       { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1458     ]
1459
1460     if (userId) {
1461       scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1462     }
1463
1464     return VideoModel
1465       .scope(scopes)
1466       .findOne(options)
1467   }
1468
1469   static async getStats () {
1470     const totalLocalVideos = await VideoModel.count({
1471       where: {
1472         remote: false
1473       }
1474     })
1475     const totalVideos = await VideoModel.count()
1476
1477     let totalLocalVideoViews = await VideoModel.sum('views', {
1478       where: {
1479         remote: false
1480       }
1481     })
1482     // Sequelize could return null...
1483     if (!totalLocalVideoViews) totalLocalVideoViews = 0
1484
1485     return {
1486       totalLocalVideos,
1487       totalLocalVideoViews,
1488       totalVideos
1489     }
1490   }
1491
1492   static incrementViews (id: number, views: number) {
1493     return VideoModel.increment('views', {
1494       by: views,
1495       where: {
1496         id
1497       }
1498     })
1499   }
1500
1501   static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1502     // Instances only share videos
1503     const query = 'SELECT 1 FROM "videoShare" ' +
1504     'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1505     'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1506     'LIMIT 1'
1507
1508     const options = {
1509       type: QueryTypes.SELECT,
1510       bind: { followerActorId, videoId },
1511       raw: true
1512     }
1513
1514     return VideoModel.sequelize.query(query, options)
1515                      .then(results => results.length === 1)
1516   }
1517
1518   static bulkUpdateSupportField (videoChannel: VideoChannelModel, t: Transaction) {
1519     const options = {
1520       where: {
1521         channelId: videoChannel.id
1522       },
1523       transaction: t
1524     }
1525
1526     return VideoModel.update({ support: videoChannel.support }, options)
1527   }
1528
1529   static getAllIdsFromChannel (videoChannel: VideoChannelModel) {
1530     const query = {
1531       attributes: [ 'id' ],
1532       where: {
1533         channelId: videoChannel.id
1534       }
1535     }
1536
1537     return VideoModel.findAll(query)
1538       .then(videos => videos.map(v => v.id))
1539   }
1540
1541   // threshold corresponds to how many video the field should have to be returned
1542   static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1543     const serverActor = await getServerActor()
1544     const followerActorId = serverActor.id
1545
1546     const scopeOptions: AvailableForListIDsOptions = {
1547       serverAccountId: serverActor.Account.id,
1548       followerActorId,
1549       includeLocalVideos: true,
1550       withoutId: true // Don't break aggregation
1551     }
1552
1553     const query: FindOptions = {
1554       attributes: [ field ],
1555       limit: count,
1556       group: field,
1557       having: Sequelize.where(
1558         Sequelize.fn('COUNT', Sequelize.col(field)), { [ Op.gte ]: threshold }
1559       ),
1560       order: [ (this.sequelize as any).random() ]
1561     }
1562
1563     return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
1564                      .findAll(query)
1565                      .then(rows => rows.map(r => r[ field ]))
1566   }
1567
1568   static buildTrendingQuery (trendingDays: number) {
1569     return {
1570       attributes: [],
1571       subQuery: false,
1572       model: VideoViewModel,
1573       required: false,
1574       where: {
1575         startDate: {
1576           [ Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1577         }
1578       }
1579     }
1580   }
1581
1582   private static buildActorWhereWithFilter (filter?: VideoFilter) {
1583     if (filter && (filter === 'local' || filter === 'all-local')) {
1584       return {
1585         serverId: null
1586       }
1587     }
1588
1589     return {}
1590   }
1591
1592   private static async getAvailableForApi (
1593     query: FindOptions,
1594     options: AvailableForListIDsOptions,
1595     countVideos = true
1596   ) {
1597     const idsScope: ScopeOptions = {
1598       method: [
1599         ScopeNames.AVAILABLE_FOR_LIST_IDS, options
1600       ]
1601     }
1602
1603     // Remove trending sort on count, because it uses a group by
1604     const countOptions = Object.assign({}, options, { trendingDays: undefined })
1605     const countQuery: CountOptions = Object.assign({}, query, { attributes: undefined, group: undefined })
1606     const countScope: ScopeOptions = {
1607       method: [
1608         ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
1609       ]
1610     }
1611
1612     const [ count, rowsId ] = await Promise.all([
1613       countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve<number>(undefined),
1614       VideoModel.scope(idsScope).findAll(query)
1615     ])
1616     const ids = rowsId.map(r => r.id)
1617
1618     if (ids.length === 0) return { data: [], total: count }
1619
1620     const secondQuery: FindOptions = {
1621       offset: 0,
1622       limit: query.limit,
1623       attributes: query.attributes,
1624       order: [ // Keep original order
1625         Sequelize.literal(
1626           ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ')
1627         )
1628       ]
1629     }
1630
1631     const apiScope: (string | ScopeOptions)[] = []
1632
1633     if (options.user) {
1634       apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1635     }
1636
1637     apiScope.push({
1638       method: [
1639         ScopeNames.FOR_API, {
1640           ids,
1641           withFiles: options.withFiles,
1642           videoPlaylistId: options.videoPlaylistId
1643         } as ForAPIOptions
1644       ]
1645     })
1646
1647     const rows = await VideoModel.scope(apiScope).findAll(secondQuery)
1648
1649     return {
1650       data: rows,
1651       total: count
1652     }
1653   }
1654
1655   static getCategoryLabel (id: number) {
1656     return VIDEO_CATEGORIES[ id ] || 'Misc'
1657   }
1658
1659   static getLicenceLabel (id: number) {
1660     return VIDEO_LICENCES[ id ] || 'Unknown'
1661   }
1662
1663   static getLanguageLabel (id: string) {
1664     return VIDEO_LANGUAGES[ id ] || 'Unknown'
1665   }
1666
1667   static getPrivacyLabel (id: number) {
1668     return VIDEO_PRIVACIES[ id ] || 'Unknown'
1669   }
1670
1671   static getStateLabel (id: number) {
1672     return VIDEO_STATES[ id ] || 'Unknown'
1673   }
1674
1675   getOriginalFile () {
1676     if (Array.isArray(this.VideoFiles) === false) return undefined
1677
1678     // The original file is the file that have the higher resolution
1679     return maxBy(this.VideoFiles, file => file.resolution)
1680   }
1681
1682   async addAndSaveThumbnail (thumbnail: ThumbnailModel, transaction: Transaction) {
1683     thumbnail.videoId = this.id
1684
1685     const savedThumbnail = await thumbnail.save({ transaction })
1686
1687     if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1688
1689     // Already have this thumbnail, skip
1690     if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1691
1692     this.Thumbnails.push(savedThumbnail)
1693   }
1694
1695   getVideoFilename (videoFile: VideoFileModel) {
1696     return this.uuid + '-' + videoFile.resolution + videoFile.extname
1697   }
1698
1699   generateThumbnailName () {
1700     return this.uuid + '.jpg'
1701   }
1702
1703   getMiniature () {
1704     if (Array.isArray(this.Thumbnails) === false) return undefined
1705
1706     return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1707   }
1708
1709   generatePreviewName () {
1710     return this.uuid + '.jpg'
1711   }
1712
1713   getPreview () {
1714     if (Array.isArray(this.Thumbnails) === false) return undefined
1715
1716     return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1717   }
1718
1719   getTorrentFileName (videoFile: VideoFileModel) {
1720     const extension = '.torrent'
1721     return this.uuid + '-' + videoFile.resolution + extension
1722   }
1723
1724   isOwned () {
1725     return this.remote === false
1726   }
1727
1728   getTorrentFilePath (videoFile: VideoFileModel) {
1729     return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1730   }
1731
1732   getVideoFilePath (videoFile: VideoFileModel) {
1733     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1734   }
1735
1736   async createTorrentAndSetInfoHash (videoFile: VideoFileModel) {
1737     const options = {
1738       // Keep the extname, it's used by the client to stream the file inside a web browser
1739       name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
1740       createdBy: 'PeerTube',
1741       announceList: [
1742         [ WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' ],
1743         [ WEBSERVER.URL + '/tracker/announce' ]
1744       ],
1745       urlList: [ WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
1746     }
1747
1748     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
1749
1750     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1751     logger.info('Creating torrent %s.', filePath)
1752
1753     await writeFile(filePath, torrent)
1754
1755     const parsedTorrent = parseTorrent(torrent)
1756     videoFile.infoHash = parsedTorrent.infoHash
1757   }
1758
1759   getWatchStaticPath () {
1760     return '/videos/watch/' + this.uuid
1761   }
1762
1763   getEmbedStaticPath () {
1764     return '/videos/embed/' + this.uuid
1765   }
1766
1767   getMiniatureStaticPath () {
1768     const thumbnail = this.getMiniature()
1769     if (!thumbnail) return null
1770
1771     return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1772   }
1773
1774   getPreviewStaticPath () {
1775     const preview = this.getPreview()
1776     if (!preview) return null
1777
1778     // We use a local cache, so specify our cache endpoint instead of potential remote URL
1779     return join(STATIC_PATHS.PREVIEWS, preview.filename)
1780   }
1781
1782   toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
1783     return videoModelToFormattedJSON(this, options)
1784   }
1785
1786   toFormattedDetailsJSON (): VideoDetails {
1787     return videoModelToFormattedDetailsJSON(this)
1788   }
1789
1790   getFormattedVideoFilesJSON (): VideoFile[] {
1791     return videoFilesModelToFormattedJSON(this, this.VideoFiles)
1792   }
1793
1794   toActivityPubObject (): VideoTorrentObject {
1795     return videoModelToActivityPubObject(this)
1796   }
1797
1798   getTruncatedDescription () {
1799     if (!this.description) return null
1800
1801     const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1802     return peertubeTruncate(this.description, maxLength)
1803   }
1804
1805   getOriginalFileResolution () {
1806     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1807
1808     return getVideoFileResolution(originalFilePath)
1809   }
1810
1811   getDescriptionAPIPath () {
1812     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1813   }
1814
1815   removeFile (videoFile: VideoFileModel, isRedundancy = false) {
1816     const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
1817
1818     const filePath = join(baseDir, this.getVideoFilename(videoFile))
1819     return remove(filePath)
1820       .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1821   }
1822
1823   removeTorrent (videoFile: VideoFileModel) {
1824     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1825     return remove(torrentPath)
1826       .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1827   }
1828
1829   removeStreamingPlaylist (isRedundancy = false) {
1830     const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_STREAMING_PLAYLIST_DIRECTORY
1831
1832     const filePath = join(baseDir, this.uuid)
1833     return remove(filePath)
1834       .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1835   }
1836
1837   isOutdated () {
1838     if (this.isOwned()) return false
1839
1840     return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1841   }
1842
1843   setAsRefreshed () {
1844     this.changed('updatedAt', true)
1845
1846     return this.save()
1847   }
1848
1849   getBaseUrls () {
1850     let baseUrlHttp
1851     let baseUrlWs
1852
1853     if (this.isOwned()) {
1854       baseUrlHttp = WEBSERVER.URL
1855       baseUrlWs = WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
1856     } else {
1857       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1858       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1859     }
1860
1861     return { baseUrlHttp, baseUrlWs }
1862   }
1863
1864   generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1865     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1866     const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1867     let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1868
1869     const redundancies = videoFile.RedundancyVideos
1870     if (isArray(redundancies)) urlList = urlList.concat(redundancies.map(r => r.fileUrl))
1871
1872     const magnetHash = {
1873       xs,
1874       announce,
1875       urlList,
1876       infoHash: videoFile.infoHash,
1877       name: this.name
1878     }
1879
1880     return magnetUtil.encode(magnetHash)
1881   }
1882
1883   getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1884     return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1885   }
1886
1887   getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1888     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1889   }
1890
1891   getTorrentDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1892     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1893   }
1894
1895   getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1896     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1897   }
1898
1899   getVideoRedundancyUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1900     return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getVideoFilename(videoFile)
1901   }
1902
1903   getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1904     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1905   }
1906
1907   getBandwidthBits (videoFile: VideoFileModel) {
1908     return Math.ceil((videoFile.size * 8) / this.duration)
1909   }
1910 }