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