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