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