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